Cropping investigation
This commit is contained in:
1 parent
48602c24f1
commit
bd1964ffcd
10 files changed
+814
-24
No files matched your search
@@ -0,0 +1,118 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from copienator.crop_diagnostics import check_protected_regions, save_diagnostics
|
||||
from copienator.ink_detection import detect_bounds
|
||||
|
||||
|
||||
class CropDiagnosticsTests(unittest.TestCase):
|
||||
def scan(self, *, dark=False, skew=False):
|
||||
rgb = np.full((900,650,3),255,np.uint8)
|
||||
if dark:
|
||||
for y in range(15,900,20):
|
||||
cv2.line(rgb,(0,y),(649,y),(65,65,65),1)
|
||||
for x in range(10,650,20):
|
||||
cv2.line(rgb,(x,0),(x,899),(65,65,65),1)
|
||||
cv2.putText(rgb,'answer',(100,400),cv2.FONT_HERSHEY_SIMPLEX,1,(20,50,180),2)
|
||||
cv2.putText(rgb,'more',(100,560),cv2.FONT_HERSHEY_SIMPLEX,.8,(30,30,30),2)
|
||||
if skew:
|
||||
rgb = cv2.warpAffine(rgb,cv2.getRotationMatrix2D((325,450),2,1),
|
||||
(650,900),borderValue=(255,255,255))
|
||||
return rgb
|
||||
|
||||
def test_diagnostics_leave_decisions_and_json_contract_unchanged(self):
|
||||
for dark,skew in [(False,False),(True,False),(True,True)]:
|
||||
with self.subTest(dark=dark,skew=skew):
|
||||
rgb = self.scan(dark=dark,skew=skew)
|
||||
before = rgb.copy()
|
||||
normal = detect_bounds(rgb,dpi=150)
|
||||
debug = {'stale': 'discard this'}
|
||||
observed = detect_bounds(rgb,dpi=150,diagnostics=debug)
|
||||
self.assertEqual(normal,observed)
|
||||
json.dumps(observed)
|
||||
np.testing.assert_array_equal(rgb,before)
|
||||
self.assertNotIn('stale',debug)
|
||||
for name,mask in debug['masks'].items():
|
||||
self.assertEqual(mask.shape,rgb.shape[:2],name)
|
||||
self.assertEqual(mask.dtype,np.bool_,name)
|
||||
masks = debug['masks']
|
||||
self.assertFalse(np.any(masks['strong_seeds'] & ~masks['foreground']))
|
||||
self.assertFalse(np.any(masks['recovered_weak'] & masks['strong_seeds']))
|
||||
for item in debug['thresholds']:
|
||||
for edge in ['top','bottom']:
|
||||
for component in item['limiting_components'][edge]:
|
||||
box = component['bbox_px']
|
||||
self.assertEqual(box[1] if edge=='top' else box[3],
|
||||
item['extent'][0 if edge=='top' else 1])
|
||||
self.assertGreater(component['seed_pixels'],0)
|
||||
|
||||
def test_blank_fallback_has_empty_foreground_and_explanation(self):
|
||||
rgb = np.full((400,300,3),255,np.uint8)
|
||||
debug = {}
|
||||
result = detect_bounds(rgb,diagnostics=debug)
|
||||
self.assertEqual((result['top_px'],result['bottom_px']),(0,400))
|
||||
self.assertFalse(debug['masks']['foreground'].any())
|
||||
self.assertEqual(debug['edges']['bottom']['reason'],'no-reliable-ink-seeds')
|
||||
|
||||
def test_faint_unseeded_candidate_is_distinguished_from_recovered_ink(self):
|
||||
rgb = self.scan()
|
||||
cv2.putText(rgb,'weak',(100,800),cv2.FONT_HERSHEY_SIMPLEX,1,(130,160,205),2)
|
||||
debug = {}
|
||||
detect_bounds(rgb,dpi=150,diagnostics=debug)
|
||||
self.assertTrue(debug['masks']['unretained_weak'][770:810,100:220].any())
|
||||
self.assertFalse(debug['masks']['foreground'][770:810,100:220].any())
|
||||
|
||||
def test_exported_masks_match_actual_pixels_and_report_is_serializable(self):
|
||||
rgb = self.scan(dark=True,skew=True)
|
||||
debug = {}
|
||||
result = detect_bounds(rgb,dpi=150,diagnostics=debug)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
files = save_diagnostics(rgb,result,debug,Path(tmp),width_mm=110,height_mm=152)
|
||||
for name,path in files['masks'].items():
|
||||
with Image.open(Path(tmp)/path) as im:
|
||||
np.testing.assert_array_equal(np.array(im).astype(bool),debug['masks'][name])
|
||||
report = json.loads((Path(tmp)/files['report']).read_text())
|
||||
self.assertEqual(report['detector'],result)
|
||||
self.assertEqual(report['shape'],list(rgb.shape[:2]))
|
||||
for path in files['overlays'].values():
|
||||
self.assertTrue((Path(tmp)/path).is_file())
|
||||
|
||||
|
||||
class ProtectedContentTests(unittest.TestCase):
|
||||
def check(self,top,bottom,box):
|
||||
return check_protected_regions([dict(id='ink',label='Real ink',bbox_mm=box)],
|
||||
dict(top_px=top,bottom_px=bottom),width_mm=80,height_mm=100,shape=(1000,800))[0]
|
||||
|
||||
def test_both_boundaries_can_clip_a_protected_region(self):
|
||||
top = self.check(210,800,[10,20,50,30])
|
||||
self.assertFalse(top['passed'])
|
||||
self.assertEqual(top['clipped_edges'],['top'])
|
||||
self.assertAlmostEqual(top['clipped_area_fraction'],.1)
|
||||
bottom = self.check(100,790,[10,70,50,80])
|
||||
self.assertFalse(bottom['passed'])
|
||||
self.assertEqual(bottom['clipped_edges'],['bottom'])
|
||||
self.assertAlmostEqual(bottom['clipped_area_fraction'],.1)
|
||||
|
||||
def test_contact_at_boundary_and_short_page_physical_height(self):
|
||||
self.assertTrue(self.check(200,300,[10,20,50,30])['passed'])
|
||||
row = check_protected_regions([dict(id='end',label='End',bbox_mm=[2,17,20,19])],
|
||||
dict(top_px=0,bottom_px=180),width_mm=40,height_mm=20,shape=(200,400))[0]
|
||||
self.assertFalse(row['passed'])
|
||||
self.assertAlmostEqual(row['clipped_area_fraction'],.5)
|
||||
|
||||
def test_full_removal_and_invalid_ground_truth(self):
|
||||
self.assertEqual(self.check(400,800,[10,20,50,30])['clipped_area_fraction'],1)
|
||||
with self.assertRaises(ValueError):
|
||||
self.check(0,1000,[10,20,90,30])
|
||||
with self.assertRaises(ValueError):
|
||||
self.check(0,1000,[10,30,50,20])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -20,7 +20,7 @@ class CropExerciseBottomsTests(unittest.TestCase):
|
||||
page = document.new_page(width=600, height=height)
|
||||
page.insert_text((80, text_y), "student answer", fontsize=16)
|
||||
if footer:
|
||||
page.insert_text((20, height - 3), "Ex 2", fontsize=10)
|
||||
page.insert_text((20, height - 15), "9/9", fontsize=10)
|
||||
document.save(path)
|
||||
return path
|
||||
|
||||
@@ -33,7 +33,7 @@ class CropExerciseBottomsTests(unittest.TestCase):
|
||||
self.assertEqual(records[0]["status"], "skipped-short")
|
||||
self.assertFalse(destination.exists())
|
||||
|
||||
def test_footer_fragment_is_ignored_for_a_large_bottom_crop(self):
|
||||
def test_isolated_page_number_allows_a_large_bottom_crop(self):
|
||||
source = self.make_pdf("large.pdf", 400, 100)
|
||||
destination = self.root / "out" / source.name
|
||||
records = process_exercise_pdf(
|
||||
@@ -45,6 +45,18 @@ class CropExerciseBottomsTests(unittest.TestCase):
|
||||
self.assertLess(result[0].rect.height, 250)
|
||||
self.assertAlmostEqual(result[0].rect.width, 600)
|
||||
|
||||
def test_content_in_former_ignored_bottom_strip_is_retained(self):
|
||||
source = self.make_pdf("bottom-content.pdf", 400, 100, footer=False)
|
||||
with pymupdf.open(source) as doc:
|
||||
doc[0].insert_text((20, 399), "Ex 2", fontsize=10)
|
||||
doc.save(self.root / "bottom-label.pdf")
|
||||
records = process_exercise_pdf(
|
||||
self.root / "bottom-label.pdf", self.root / "retained.pdf",
|
||||
self.review, 800, dpi=150
|
||||
)
|
||||
self.assertEqual(records[0]["status"], "unchanged-small-crop")
|
||||
self.assertFalse((self.root / "retained.pdf").exists())
|
||||
|
||||
def test_crop_smaller_than_four_lines_is_not_written(self):
|
||||
source = self.make_pdf("small.pdf", 400, 330, footer=False)
|
||||
destination = self.root / "out" / source.name
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import unittest
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from copienator.ink_detection import detect_bounds
|
||||
|
||||
|
||||
class EdgeMarksTests(unittest.TestCase):
|
||||
def page(self, last_answer=1000):
|
||||
rgb = np.full((2340,1654,3),255,np.uint8)
|
||||
cv2.putText(rgb,'answer',(500,600),cv2.FONT_HERSHEY_SIMPLEX,1.5,(20,60,180),2)
|
||||
cv2.putText(rgb,'more',(500,last_answer),cv2.FONT_HERSHEY_SIMPLEX,1.5,(20,60,180),2)
|
||||
return rgb
|
||||
|
||||
def test_isolated_footer_fraction_is_removed(self):
|
||||
rgb=self.page()
|
||||
cv2.putText(rgb,'9/9',(1400,2220),cv2.FONT_HERSHEY_SIMPLEX,1.2,(20,20,20),2)
|
||||
debug={}
|
||||
result=detect_bounds(rgb,diagnostics=debug)
|
||||
self.assertLess(result['bottom_px'],1100)
|
||||
self.assertTrue(debug['masks']['isolated_edge_marks'].any())
|
||||
self.assertFalse(debug['masks']['foreground'][2150:].any())
|
||||
self.assertFalse(debug['masks']['strong_seeds'][2150:].any())
|
||||
self.assertEqual(result,detect_bounds(rgb))
|
||||
|
||||
def test_close_footer_stays_with_answer(self):
|
||||
rgb=self.page(last_answer=2170)
|
||||
cv2.putText(rgb,'9/9',(1400,2220),cv2.FONT_HERSHEY_SIMPLEX,1.2,(20,20,20),2)
|
||||
self.assertGreater(detect_bounds(rgb)['bottom_px'],2220)
|
||||
|
||||
def test_central_sparse_ratio_stays(self):
|
||||
rgb=self.page()
|
||||
cv2.putText(rgb,'9/9',(760,2220),cv2.FONT_HERSHEY_SIMPLEX,1.2,(20,20,20),2)
|
||||
self.assertGreater(detect_bounds(rgb)['bottom_px'],2220)
|
||||
|
||||
def test_isolated_bare_border_digit_needs_no_recognition(self):
|
||||
rgb=self.page()
|
||||
cv2.putText(rgb,'7',(1400,2240),cv2.FONT_HERSHEY_SIMPLEX,1.2,(20,20,20),2)
|
||||
self.assertLess(detect_bounds(rgb)['bottom_px'],1100)
|
||||
|
||||
def test_underlined_question_at_upper_edge_stays(self):
|
||||
rgb=self.page()
|
||||
cv2.putText(rgb,'16',(310,85),cv2.FONT_HERSHEY_SIMPLEX,1.2,(20,20,20),2)
|
||||
cv2.line(rgb,(310,90),(355,90),(20,20,20),2)
|
||||
self.assertLess(detect_bounds(rgb)['top_px'],60)
|
||||
|
||||
def test_name_in_other_margin_blocks_header_removal(self):
|
||||
rgb=self.page()
|
||||
cv2.circle(rgb,(90,55),22,(20,20,20),2)
|
||||
cv2.putText(rgb,'1',(81,64),cv2.FONT_HERSHEY_SIMPLEX,.7,(20,20,20),1)
|
||||
cv2.putText(rgb,'Name',(1350,120),cv2.FONT_HERSHEY_SIMPLEX,1,(20,20,20),2)
|
||||
result=detect_bounds(rgb)
|
||||
self.assertEqual(result['top_px'],0)
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,87 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from copienator.ink_detection import detect_bounds
|
||||
|
||||
|
||||
class PaperRulingTests(unittest.TestCase):
|
||||
def scan(self, colour, *, warp=False):
|
||||
image = np.full((1200, 850, 3), 255, np.uint8)
|
||||
for y in range(20, 1200, 20):
|
||||
cv2.line(image, (0, y), (849, y), colour, 1)
|
||||
for x in range(10, 850, 20):
|
||||
cv2.line(image, (x, 0), (x, 1199), colour, 1)
|
||||
cv2.line(image, (80, 0), (80, 1199), (210, 70, 80), 2)
|
||||
cv2.putText(image, 'answer', (120, 410), cv2.FONT_HERSHEY_SIMPLEX,
|
||||
1, (20, 60, 180), 2)
|
||||
cv2.putText(image, 'more', (120, 710), cv2.FONT_HERSHEY_SIMPLEX,
|
||||
1, (20, 60, 180), 2)
|
||||
if warp:
|
||||
yy, xx = np.indices(image.shape[:2], dtype=np.float32)
|
||||
image = cv2.remap(image, xx+2*np.sin(yy/95), yy,
|
||||
cv2.INTER_LINEAR, borderValue=(255,255,255))
|
||||
return image
|
||||
|
||||
def test_default_path_does_not_run_experimental_colour_model(self):
|
||||
image = self.scan((70, 110, 225))
|
||||
with patch('copienator.ink_detection.ruling_suppression',
|
||||
side_effect=AssertionError('Expensive colour model invoked')):
|
||||
result = detect_bounds(image, dpi=150)
|
||||
self.assertLess(result['top_px'], 410)
|
||||
self.assertGreater(result['bottom_px'], 710)
|
||||
self.assertFalse(result.get('ruling_cleanup', False))
|
||||
|
||||
def test_saturated_coloured_grid_and_margin_do_not_seed_blank_edges(self):
|
||||
for colour in [(65, 65, 65), (70, 110, 225), (155, 170, 240)]:
|
||||
for warp in [False, True]:
|
||||
with self.subTest(colour=colour, warp=warp):
|
||||
image = self.scan(colour, warp=warp)
|
||||
debug = {}
|
||||
result = detect_bounds(image, dpi=150, colour_cleanup=True, diagnostics=debug)
|
||||
self.assertGreater(result['top_px'], 280)
|
||||
self.assertLess(result['bottom_px'], 810)
|
||||
self.assertTrue(result['ruling_cleanup'])
|
||||
self.assertTrue(debug['masks']['paper_suppressed'].any())
|
||||
self.assertFalse(debug['masks']['foreground'][:250].any())
|
||||
|
||||
def test_writing_crossing_ruling_keeps_both_outer_bounds(self):
|
||||
image = self.scan((70, 110, 225))
|
||||
cv2.putText(image, 'top', (120, 200), cv2.FONT_HERSHEY_SIMPLEX,
|
||||
.7, (190, 20, 20), 2)
|
||||
cv2.putText(image, 'bottom', (120, 1000), cv2.FONT_HERSHEY_SIMPLEX,
|
||||
.7, (20, 20, 20), 2)
|
||||
result = detect_bounds(image, dpi=150, colour_cleanup=True)
|
||||
self.assertLess(result['top_px'], 180)
|
||||
self.assertGreater(result['bottom_px'], 1000)
|
||||
|
||||
def test_isolated_bars_and_axes_are_not_a_ruling_colour_family(self):
|
||||
for colour in [(190, 20, 20), (20, 20, 20), (20, 90, 190)]:
|
||||
with self.subTest(colour=colour):
|
||||
image = self.scan((70, 110, 225))
|
||||
cv2.line(image, (100, 1020), (700, 1020), colour, 3)
|
||||
cv2.line(image, (425, 980), (425, 1120), colour, 3)
|
||||
result = detect_bounds(image, dpi=150, colour_cleanup=True)
|
||||
self.assertGreater(result['bottom_px'], 1120)
|
||||
|
||||
def test_grid_colour_with_a_thicker_answer_bar_is_preserved(self):
|
||||
colour = (70, 110, 225)
|
||||
image = self.scan(colour)
|
||||
cv2.line(image, (150, 1020), (650, 1020), colour, 5)
|
||||
self.assertGreater(detect_bounds(image, dpi=150, colour_cleanup=True)['bottom_px'], 1020)
|
||||
|
||||
def test_regular_fraction_bars_inside_an_answer_are_not_paper(self):
|
||||
image = np.full((1200,850,3),255,np.uint8)
|
||||
cv2.putText(image,'answer',(120,300),cv2.FONT_HERSHEY_SIMPLEX,
|
||||
1,(20,60,180),2)
|
||||
for y in (400,550,700,850,1000):
|
||||
cv2.line(image,(130,y),(700,y),(20,20,20),3)
|
||||
result = detect_bounds(image,dpi=150, colour_cleanup=True)
|
||||
self.assertGreater(result['bottom_px'],1000)
|
||||
self.assertFalse(result.get('ruling_cleanup',False))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user