import unittest import cv2 import numpy as np from copienator.ink_detection import detect_bounds, detect_page_content, _paper_blur, _blank_content_support class InkDetectionTests(unittest.TestCase): def test_lightweight_content_masks_match_full_diagnostics(self): for image,colour in [(self.scan(),False),(self.dark_grid(),False), (self.scan(),True),(np.full((300,220,3),255,np.uint8),False)]: with self.subTest(colour_cleanup=colour,shape=image.shape): full,light = {},{} expected=detect_bounds(image,dpi=150,diagnostics=full,colour_cleanup=colour) actual=detect_bounds(image,dpi=150,content_masks=light,colour_cleanup=colour) self.assertEqual(actual,expected) self.assertEqual(set(light),{'foreground','weak_candidates'}) for name in light: np.testing.assert_array_equal(light[name],full['masks'][name]) def test_blank_detection_ignores_remote_specks(self): image = np.full((1200,850,3),255,np.uint8) for x,y in ((150,200),(400,600),(700,1000)): cv2.rectangle(image,(x,y),(x+3,y+4),(30,30,30),-1) mask,result = detect_page_content(image,dpi=150) self.assertFalse(mask.any()) self.assertGreater(result['content_unsupported_pixels'],0) def test_isolated_question_numbers_remain_content(self): for text in ('1','7','16','x'): with self.subTest(text=text): image = np.full((1200,850,3),255,np.uint8) cv2.putText(image,text,(300,600),cv2.FONT_HERSHEY_SIMPLEX,.7,(30,30,30),2) self.assertTrue(detect_page_content(image,dpi=150)[0].any()) def test_faint_edge_question_number_remains_content(self): image = np.full((1200,850,3),255,np.uint8) cv2.putText(image,'1',(1,600),cv2.FONT_HERSHEY_SIMPLEX,.7,(190,190,190),2) self.assertTrue(detect_page_content(image,dpi=150)[0].any()) def test_isolated_circle_diagram_remains_content(self): image = np.full((1200,850,3),255,np.uint8) cv2.circle(image,(400,600),20,(30,30,30),2) self.assertTrue(detect_page_content(image,dpi=150)[0].any()) def test_solid_ink_dots_do_not_establish_content_at_multiple_resolutions(self): for dpi in (150,200,300): px=dpi/25.4 mask=np.zeros((round(100*px),round(70*px)),np.uint8) cv2.ellipse(mask,(round(30*px),round(45*px)), (round(.6*px),round(.8*px)),0,0,360,1,-1) self.assertFalse(_blank_content_support(mask>0,dpi)[0].any()) def test_compact_dot_is_kept_with_neighbouring_question_number(self): mask=np.zeros((400,300),np.uint8) cv2.putText(mask,'7',(100,200),cv2.FONT_HERSHEY_SIMPLEX,.7,1,2) mask[194:200,117:122]=1 retained,_=_blank_content_support(mask>0,150) self.assertTrue(retained[194:200,117:122].all()) def test_large_isolated_numbers_and_diagram_near_bottom_remain_content(self): for text in ('1','7','x','?','2','16'): with self.subTest(text=text): image=np.full((1200,850,3),255,np.uint8) cv2.putText(image,text,(300,1150),cv2.FONT_HERSHEY_SIMPLEX, 1.5,(30,30,30),2) self.assertTrue(detect_page_content(image,200)[0].any()) image=np.full((1200,850,3),255,np.uint8) cv2.circle(image,(400,1140),20,(30,30,30),2) self.assertTrue(detect_page_content(image,200)[0].any()) def test_content_analysis_recognizes_blank_grid_without_changing_crop_fallback(self): image = np.full((1200,850,3),255,np.uint8) for y in range(20,1200,20): cv2.line(image,(0,y),(849,y),(65,65,65),1) for x in range(10,850,20): cv2.line(image,(x,0),(x,1199),(65,65,65),1) for y in (110,370,630,890,1130): cv2.ellipse(image,(25,y),(12,20),0,0,300,(35,35,35),2) before = detect_bounds(image,dpi=150) mask,result = detect_page_content(image,dpi=150) self.assertTrue(result['content_ruling_confirmed']) self.assertFalse(mask.any()) self.assertEqual(detect_bounds(image,dpi=150),before) def test_content_analysis_preserves_pencil_only_writing(self): image = np.full((1200,850,3),255,np.uint8) cv2.putText(image,'pencil',(100,600),cv2.FONT_HERSHEY_SIMPLEX, 1,(195,195,195),2) mask,result = detect_page_content(image,dpi=150) self.assertTrue(mask[560:610,90:250].any()) self.assertEqual(result['content_state'],'content') def test_faint_scanner_border_does_not_count_as_pencil(self): image = np.full((1200,850,3),255,np.uint8) cv2.rectangle(image,(1,1),(848,300),(195,195,195),1) mask,_ = detect_page_content(image,dpi=150) self.assertFalse(mask.any()) def test_faint_unruled_diagram_is_retained(self): image = np.full((1200,850,3),255,np.uint8) cv2.rectangle(image,(100,100),(750,1100),(195,195,195),2) mask,_ = detect_page_content(image,dpi=150) self.assertTrue(mask.any()) def test_content_analysis_preserves_faint_grid_note_and_fraction_bar(self): image = self.dark_grid() cv2.putText(image,'pencil',(100,1100),cv2.FONT_HERSHEY_SIMPLEX, .7,(190,190,190),2) cv2.line(image,(150,1020),(700,1020),(0,0,0),3) mask,_ = detect_page_content(image,dpi=150) self.assertTrue(mask[1070:1110,90:250].any()) self.assertTrue(mask[1018:1023,150:700].any()) def test_fast_background_blur_matches_opencv_pixel_for_pixel(self): rng = np.random.default_rng(42) for shape in ((97,131), (241,319)): gray = rng.integers(0,256,shape,dtype=np.uint8) for dpi in (100,150,200,300): with self.subTest(shape=shape,dpi=dpi): expected = cv2.GaussianBlur(gray,(0,0),dpi/8) np.testing.assert_array_equal(_paper_blur(gray,dpi/8),expected) def scan(self): image = np.full((1200, 850, 3), 255, np.uint8) for y in range(20, 1200, 20): cv2.line(image, (0, y), (849, y), (160, 155, 205), 1) for x in range(10, 850, 20): cv2.line(image, (x, 0), (x, 1199), (160, 155, 205), 1) for y in (100, 180, 400, 480, 800, 880, 1050, 1130): cv2.ellipse(image, (25, y), (12, 20), 0, 0, 300, (155,155,155), 2) cv2.putText(image, 'x + y = 2', (110, 400), cv2.FONT_HERSHEY_SIMPLEX, 1, (20, 90, 190), 2) cv2.putText(image, 'answer = 42', (110, 700), cv2.FONT_HERSHEY_SIMPLEX, 1, (20, 90, 190), 2) return image def test_crops_past_holes_and_grid(self): r = detect_bounds(self.scan(), dpi=150) self.assertGreater(r['top_px'], 250) self.assertLess(r['top_px'], 370) self.assertGreater(r['bottom_px'], 700) self.assertLess(r['bottom_px'], 800) def test_isolated_margin_note_survives(self): image = self.scan() cv2.putText(image, '1', (4, 1110), cv2.FONT_HERSHEY_SIMPLEX, .65, (20, 90, 190), 2) self.assertGreater(detect_bounds(image, dpi=150)['bottom_px'], 1110) def test_long_fraction_bar_on_grid_survives(self): for colour in ((20,90,190), (20,20,20), (190,20,20)): with self.subTest(colour=colour): image = self.scan() cv2.line(image, (100, 1020), (700, 1020), colour, 3) self.assertGreater(detect_bounds(image, dpi=150)['bottom_px'], 1020) def test_black_annotation_survives(self): image = self.scan() cv2.putText(image, 'note', (600, 1100), cv2.FONT_HERSHEY_SIMPLEX, .7, (30,30,30), 2) self.assertGreater(detect_bounds(image, dpi=150)['bottom_px'], 1100) def test_blank_and_pencil_only_pages_are_retained(self): for pencil in (False, True): image = np.full((1200,850,3),255,np.uint8) if pencil: cv2.putText(image, 'pencil', (100,600), cv2.FONT_HERSHEY_SIMPLEX, 1, (195,195,195), 2) r = detect_bounds(image, dpi=150) self.assertEqual((r['top_px'],r['bottom_px']), (0,1200)) self.assertEqual(r['status'], 'review-no-ink-seeds') def test_skewed_colour_scan(self): matrix = cv2.getRotationMatrix2D((425,600),2,1) image = cv2.warpAffine(self.scan(),matrix,(850,1200),borderValue=(255,255,255)) r = detect_bounds(image,dpi=150) self.assertGreater(r['top_px'],250) self.assertLess(r['top_px'],370) self.assertGreater(r['bottom_px'],715) self.assertLess(r['bottom_px'],820) def test_weak_stroke_attached_to_ink_is_recovered(self): image = np.full((1200,850,3),255,np.uint8) cv2.rectangle(image,(400,500),(410,530),(20,90,190),-1) cv2.rectangle(image,(400,531),(410,540),(130,170,205),-1) r = detect_bounds(image,dpi=150,padding_mm=0,min_crop_mm=0) self.assertGreaterEqual(r['bottom_px'],541) def dark_grid(self): image = np.full((1200,850,3),255,np.uint8) for y in range(20,1200,20): cv2.line(image,(0,y),(849,y),(65,65,65),1) for x in range(10,850,20): cv2.line(image,(x,0),(x,1199),(65,65,65),1) cv2.putText(image,'x + y = 2',(100,400),cv2.FONT_HERSHEY_SIMPLEX, 1,(20,20,20),2) cv2.putText(image,'answer = 42',(100,700),cv2.FONT_HERSHEY_SIMPLEX, 1,(20,20,20),2) return image def test_dark_grid_does_not_seed_entire_page(self): r = detect_bounds(self.dark_grid(),dpi=150) self.assertTrue(r['paper_cleanup']) self.assertGreater(r['top_px'],250) self.assertLess(r['top_px'],370) self.assertGreater(r['bottom_px'],700) self.assertLess(r['bottom_px'],820) def test_faint_isolated_note_on_dark_grid_survives(self): image = self.dark_grid() cv2.putText(image,'pencil',(100,1100),cv2.FONT_HERSHEY_SIMPLEX, .7,(190,190,190),2) self.assertGreater(detect_bounds(image,dpi=150)['bottom_px'],1100) def test_sparse_central_pencil_on_dark_grid_survives(self): image = self.dark_grid() # Thin, pale handwriting crossing the ruling is split into sparse # components. Its central position distinguishes it from page holes. cv2.putText(image,'result',(330,1090),cv2.FONT_HERSHEY_SCRIPT_SIMPLEX, .85,(155,155,155),1) self.assertGreater(detect_bounds(image,dpi=150)['bottom_px'],1090) def test_large_unruled_diagram_is_not_paper(self): image = np.full((1200,850,3),255,np.uint8) cv2.rectangle(image,(100,100),(750,1100),(20,20,20),3) r = detect_bounds(image,dpi=150) self.assertFalse(r['paper_cleanup']) self.assertLess(r['top_px'],100) self.assertGreater(r['bottom_px'],1100) def test_dark_grid_preserves_black_fraction_bar(self): image = self.dark_grid() cv2.line(image,(150,1020),(700,1020),(0,0,0),3) self.assertGreater(detect_bounds(image,dpi=150)['bottom_px'],1020) def test_disconnected_dark_grid_is_still_recognized(self): image = self.dark_grid() for x in range(170,850,170): image[:,x:x+5] = 255 for y in range(200,1200,200): image[y:y+5,:] = 255 cv2.putText(image,'x + y = 2',(100,400),cv2.FONT_HERSHEY_SIMPLEX, 1,(20,20,20),2) r = detect_bounds(image,dpi=150) self.assertTrue(r['paper_cleanup']) self.assertGreater(r['top_px'],250) self.assertLess(r['top_px'],370) self.assertGreater(r['bottom_px'],700) self.assertLess(r['bottom_px'],850) def test_repeated_dark_holes_on_either_side(self): for right in (False, True): with self.subTest(right=right): image = self.dark_grid() x = 820 if right else 25 for y in (110, 370, 630, 890, 1130): cv2.ellipse(image, (x,y), (12,20), 0, 0, 300, (35,35,35), 2) r = detect_bounds(image,dpi=150) self.assertLess(r['bottom_px'],850) # The same column can contain handwriting as well as holes. cv2.putText(image,'7',(x-5,1060),cv2.FONT_HERSHEY_SIMPLEX, .7,(20,20,20),2) r = detect_bounds(image,dpi=150) self.assertGreater(r['bottom_px'],1060) def test_faint_page_number_in_outer_band_does_not_block_crop(self): image = self.dark_grid() cv2.putText(image,'4/',(3,1160),cv2.FONT_HERSHEY_SIMPLEX, .55,(130,130,130),1) self.assertLess(detect_bounds(image,dpi=150)['bottom_px'],850) def test_faded_neutral_grid(self): image = self.dark_grid() image[np.all(image == 65,axis=2)] = 145 r = detect_bounds(image,dpi=150) self.assertTrue(r['paper_cleanup']) self.assertLess(r['bottom_px'],850) if __name__ == '__main__': unittest.main()