Automatic page splitting
This commit is contained in:
1 parent
d4404e4c27
commit
78f3e4b2f0
13 files changed
+1959
-40
No files matched your search
@@ -0,0 +1,167 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pymupdf
|
||||
|
||||
from copienator.auto_page_split import analyse_ink, analyse_page, classify_format, plan_masks, run
|
||||
|
||||
|
||||
class AutomaticSplitTests(unittest.TestCase):
|
||||
def mask(self):
|
||||
return np.zeros((300, 420), bool)
|
||||
|
||||
def plan(self, mask, kind='a3'):
|
||||
return plan_masks(mask, 25.4, kind)
|
||||
|
||||
def test_formats_use_size_not_landscape_alone(self):
|
||||
pt = 72/25.4
|
||||
for w,h,expected in [(420,297,'a3'),(210,297,'a4'),(297,210,'a4'),
|
||||
(297,420,'unknown'),(320,200,'unknown')]:
|
||||
self.assertEqual(classify_format(w*pt,h*pt),expected)
|
||||
|
||||
def test_both_directions_extend_independently(self):
|
||||
mask = self.mask()
|
||||
mask[40:43,150:218] = True
|
||||
mask[100:103,203:280] = True
|
||||
plan = self.plan(mask)
|
||||
self.assertEqual([x['owner'] for x in plan['crossings']],['left','right'])
|
||||
self.assertEqual(plan['extensions_mm'],[10,9])
|
||||
self.assertTrue(all(o['keep'] for o in plan['outputs']))
|
||||
|
||||
def test_disconnected_letters_form_a_line(self):
|
||||
mask = self.mask()
|
||||
for x in range(170,219,4):
|
||||
mask[40:44,x:x+2] = True
|
||||
plan = self.plan(mask)
|
||||
self.assertGreater(plan['extensions_mm'][0],0)
|
||||
self.assertEqual(plan['crossings'][0]['owner'],'left')
|
||||
|
||||
def test_detached_terminal_strokes_follow_supported_text_on_either_side(self):
|
||||
mask=self.mask()
|
||||
mask[40:44,160:199]=True
|
||||
mask[41:45,205:219]=True
|
||||
mask[100:104,221:260]=True
|
||||
mask[101:105,201:215]=True
|
||||
plan=self.plan(mask)
|
||||
self.assertEqual([c['owner'] for c in plan['crossings']],['left','right'])
|
||||
self.assertGreater(plan['extensions_mm'][0],0)
|
||||
self.assertGreater(plan['extensions_mm'][1],0)
|
||||
|
||||
def test_fold_only_groups_are_artifacts_without_page_anchors(self):
|
||||
mask=self.mask()
|
||||
mask[40:43,205:215]=True
|
||||
plan=self.plan(mask)
|
||||
self.assertEqual(plan['crossings'][0]['owner'],'artifact')
|
||||
self.assertEqual(plan['extensions_mm'],[0,0])
|
||||
self.assertFalse(plan['review_reasons'])
|
||||
|
||||
def test_pale_ruling_does_not_attach_to_nearby_text_but_dark_bar_does(self):
|
||||
mask=np.zeros((600,840),bool)
|
||||
mask[100:103,418:468]=True
|
||||
mask[100:120,490:580]=True
|
||||
credible=np.zeros_like(mask)
|
||||
credible[100:120,490:580]=True
|
||||
for colour,owner in [((134,162,200),'artifact'),((80,80,80),'right')]:
|
||||
with self.subTest(colour=colour):
|
||||
rgb=np.full((*mask.shape,3),255,np.uint8)
|
||||
rgb[mask]=(20,20,20)
|
||||
rgb[100:103,418:468]=colour
|
||||
plan=plan_masks(mask,200,'a3',content_ink=credible,rgb=rgb)
|
||||
self.assertEqual(plan['crossings'][0]['owner'],owner)
|
||||
|
||||
def test_overflow_does_not_make_other_page_nonblank(self):
|
||||
mask = self.mask()
|
||||
mask[40:43,150:218] = True
|
||||
plan = self.plan(mask)
|
||||
self.assertEqual([o['state'] for o in plan['outputs']],['content','blank'])
|
||||
self.assertFalse(plan['outputs'][1]['keep'])
|
||||
|
||||
def test_ambiguous_crossing_preserved_on_both(self):
|
||||
mask = self.mask()
|
||||
mask[40:43,180:250] = True
|
||||
plan = self.plan(mask)
|
||||
self.assertIn('ambiguous-centre-writing',plan['review_reasons'])
|
||||
self.assertLess(plan['outputs'][1]['rect_px'][0],180)
|
||||
self.assertGreater(plan['outputs'][0]['rect_px'][2],250)
|
||||
|
||||
def test_unknown_format_and_a4(self):
|
||||
mask = self.mask()
|
||||
self.assertTrue(self.plan(mask,kind='unknown')['outputs'][0]['keep'])
|
||||
mask[40:44,50:54] = True
|
||||
self.assertEqual(self.plan(mask,kind='a4')['outputs'][0]['rect_px'],[0,0,420,300])
|
||||
|
||||
def test_unseeded_scan_noise_does_not_veto_blank(self):
|
||||
rgb = np.full((600,420,3),255,np.uint8)
|
||||
rgb[200:203,200:202] = 195
|
||||
plan = analyse_page(rgb,100,'a4')
|
||||
self.assertEqual(plan['outputs'][0]['state'],'blank')
|
||||
self.assertEqual(plan['analysis']['early_written'],[False])
|
||||
|
||||
def test_faint_strokes_near_reliable_ink_are_recovered(self):
|
||||
rgb = np.full((600,420,3),255,np.uint8)
|
||||
rgb[200:210,200:204] = 195
|
||||
rgb[200:210,204:208] = 30
|
||||
ink,_ = analyse_ink(rgb,100)
|
||||
self.assertTrue(ink[200:210,204:208].any())
|
||||
self.assertEqual(analyse_page(rgb,100,'a4')['outputs'][0]['state'],'content')
|
||||
|
||||
def test_spread_artifacts_cannot_veto_blank_logical_half(self):
|
||||
spread=self.mask()
|
||||
spread[100:105,208:213]=True
|
||||
content=self.mask()
|
||||
content[30:40,300:350]=True
|
||||
plan=plan_masks(spread,25.4,'a3',content_ink=content)
|
||||
self.assertEqual([o['state'] for o in plan['outputs']],['blank','content'])
|
||||
self.assertEqual(plan['crossings'][0]['owner'],'artifact')
|
||||
self.assertFalse(plan['review_reasons'])
|
||||
|
||||
def test_export_preserves_visible_rotation_cropbox_and_sources(self):
|
||||
for rotation in (0,90,180,270):
|
||||
with self.subTest(rotation=rotation), tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
source = root/'source.pdf'
|
||||
with pymupdf.open() as doc:
|
||||
# Crop offsets and all rotations; displayed geometry is A3.
|
||||
w,h = (420,297) if rotation%180==0 else (297,420)
|
||||
pt = 72/25.4
|
||||
p = doc.new_page(width=w*pt+20,height=h*pt+30)
|
||||
p.draw_rect(pymupdf.Rect(50,50,w*pt-50,h*pt-50),fill=(0,0,1))
|
||||
p.draw_rect(pymupdf.Rect(100,100,150,180),fill=(1,0,0))
|
||||
p.set_cropbox(pymupdf.Rect(10,15,w*pt+10,h*pt+15))
|
||||
p.set_rotation(rotation)
|
||||
doc.save(source)
|
||||
before = source.read_bytes()
|
||||
summary = run(source,root/'result',dpi=100)
|
||||
self.assertEqual(summary['output_pages'],2)
|
||||
self.assertEqual(source.read_bytes(),before)
|
||||
with pymupdf.open(source) as original, pymupdf.open(root/'result/pdfs/source.pdf') as result:
|
||||
outputs=json.loads((root/'result/report.json').read_text())['pages'][0]['outputs']
|
||||
for page,output in zip(result,outputs):
|
||||
self.assertEqual(page.rotation,0)
|
||||
self.assertAlmostEqual(page.rect.height,original[0].rect.height,places=3)
|
||||
# Ambiguous full-width rectangle is deliberately duplicated.
|
||||
a = original[0].get_pixmap(matrix=pymupdf.Matrix(.3,.3))
|
||||
b = page.get_pixmap(matrix=pymupdf.Matrix(.3,.3))
|
||||
self.assertGreater(b.width, a.width*.8)
|
||||
expected=original[0].get_pixmap(clip=pymupdf.Rect(output['crop_points']))
|
||||
actual=page.get_pixmap()
|
||||
aa=np.frombuffer(expected.samples,np.uint8).reshape(expected.height,expected.width,3)
|
||||
bb=np.frombuffer(actual.samples,np.uint8).reshape(actual.height,actual.width,3)
|
||||
h,w=min(aa.shape[0],bb.shape[0]),min(aa.shape[1],bb.shape[1])
|
||||
self.assertLess(np.abs(aa[:h,:w].astype(float)-bb[:h,:w]).mean(),2)
|
||||
with self.assertRaises(FileExistsError):
|
||||
run(source,root/'result',dpi=100)
|
||||
|
||||
def test_all_blank_copy_has_report_without_invalid_empty_pdf(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root=Path(directory)
|
||||
with pymupdf.open() as doc:
|
||||
doc.new_page(width=210*72/25.4,height=297*72/25.4)
|
||||
doc.save(root/'blank.pdf')
|
||||
summary=run(root/'blank.pdf',root/'result',dpi=100)
|
||||
self.assertEqual(summary['blank_removed'],1)
|
||||
self.assertFalse((root/'result/pdfs/blank.pdf').exists())
|
||||
self.assertTrue((root/'result/report.json').exists())
|
||||
@@ -0,0 +1,204 @@
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import numpy as np
|
||||
import pymupdf
|
||||
|
||||
from copienator import EvaluationWorkspace, ExitCode
|
||||
from copienator.commands import automatic_page_splitter as automatic
|
||||
from copienator.commands.page_splitter import PDFPreviewer, _selected_inputs, main
|
||||
from copienator.copy_errors import copy_errors
|
||||
from copienator.orientation import load_provenance
|
||||
from copienator_gui.workflow import build_command, build_workflow
|
||||
|
||||
|
||||
class AutomaticPageSplitterTests(unittest.TestCase):
|
||||
def make_source(self, root, *, blank=False):
|
||||
(root / "labels").write_text("Ex 1\n")
|
||||
source = root / "Copie01.pdf"
|
||||
with pymupdf.open() as doc:
|
||||
page = doc.new_page(width=420 * 72 / 25.4, height=297 * 72 / 25.4)
|
||||
if not blank:
|
||||
page.insert_text((60, 100), "Answer on the left page", fontsize=16)
|
||||
page.insert_text((750, 100), "Answer on the right page", fontsize=16)
|
||||
page.insert_text((560, 300), "x + y = z", fontsize=16)
|
||||
doc.new_page(width=420 * 72 / 25.4, height=297 * 72 / 25.4)
|
||||
page = doc.new_page(width=210 * 72 / 25.4, height=297 * 72 / 25.4)
|
||||
page.insert_text((60, 100), "Exercise 3", fontsize=16)
|
||||
doc.save(source)
|
||||
return source
|
||||
|
||||
def test_gui_choices_build_manual_and_automatic_commands(self):
|
||||
step = next(s for s in build_workflow(False) if s.id == "page_splitter")
|
||||
self.assertEqual(
|
||||
[v.label for v in step.variants], ["Manuellement", "Automatiquement"]
|
||||
)
|
||||
manual, auto = [
|
||||
build_command(Path.cwd(), step, v, {"workers": 3}, "Exam")
|
||||
for v in step.variants
|
||||
]
|
||||
self.assertNotIn("--automatic", manual)
|
||||
self.assertNotIn("--workers", manual)
|
||||
self.assertIn("--automatic", auto)
|
||||
self.assertEqual(auto[auto.index("--workers") + 1], "3")
|
||||
|
||||
def test_cli_publishes_without_tk_and_preserves_original_and_provenance(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
source = self.make_source(root)
|
||||
before = source.read_bytes()
|
||||
workspace = EvaluationWorkspace(root)
|
||||
with patch("copienator.commands.page_splitter.tk.Tk") as tk:
|
||||
self.assertEqual(main([str(root), "--automatic", "--workers", "1"]), 0)
|
||||
tk.assert_not_called()
|
||||
preserved = workspace.original_copies_dir / source.name
|
||||
self.assertEqual(preserved.read_bytes(), before)
|
||||
self.assertFalse(source.exists())
|
||||
output = workspace.copies_dir / source.name
|
||||
with pymupdf.open(output) as doc:
|
||||
self.assertEqual(len(doc), 3)
|
||||
origins = load_provenance(workspace, output, len(doc))
|
||||
self.assertEqual([o["origin"] for o in origins], [1, 1, 3])
|
||||
self.assertGreater(doc[0].rect.width, 210 * 72 / 25.4)
|
||||
saved = automatic.load_decisions(workspace, preserved)
|
||||
self.assertEqual(len(saved), 3)
|
||||
self.assertEqual(_selected_inputs(workspace, root), [preserved])
|
||||
self.assertFalse(copy_errors(workspace))
|
||||
runs = list(workspace.runs_dir.glob("page-split-*"))
|
||||
self.assertTrue((runs[0] / "result/pdfs" / source.name).exists())
|
||||
self.assertEqual(
|
||||
automatic.run(workspace, [preserved], workers=1), ExitCode.SUCCESS
|
||||
)
|
||||
self.assertEqual(preserved.read_bytes(), before)
|
||||
|
||||
def test_processing_failure_leaves_existing_files_in_place(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
source = self.make_source(root)
|
||||
before = source.read_bytes()
|
||||
workspace = EvaluationWorkspace(root)
|
||||
workspace.copies_dir.mkdir()
|
||||
output = workspace.copies_dir / source.name
|
||||
output.write_bytes(b"previous processed copy")
|
||||
with (
|
||||
patch.object(
|
||||
automatic,
|
||||
"analyse_batch",
|
||||
side_effect=RuntimeError("analysis failed"),
|
||||
),
|
||||
self.assertRaisesRegex(RuntimeError, "analysis failed"),
|
||||
):
|
||||
automatic.run(workspace, [source], workers=1)
|
||||
self.assertEqual(source.read_bytes(), before)
|
||||
self.assertEqual(output.read_bytes(), b"previous processed copy")
|
||||
self.assertFalse(workspace.original_copies_dir.exists())
|
||||
|
||||
def test_parallel_run_preserves_unselected_copies_and_ignores_stale_proposals(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
source = self.make_source(root)
|
||||
second = root / "Copie02.pdf"
|
||||
shutil.copy2(source, second)
|
||||
workspace = EvaluationWorkspace(root)
|
||||
workspace.copies_dir.mkdir()
|
||||
other = workspace.copies_dir / "Copie99.pdf"
|
||||
other.write_bytes(b"unselected copy")
|
||||
automatic.run(workspace, [source, second], workers=2)
|
||||
self.assertEqual(other.read_bytes(), b"unselected copy")
|
||||
original = workspace.original_copies_dir / source.name
|
||||
self.assertIsNotNone(automatic.load_decisions(workspace, original))
|
||||
output = workspace.copies_dir / source.name
|
||||
output.write_bytes(b"edited after automatic splitting")
|
||||
self.assertIsNone(automatic.load_decisions(workspace, original))
|
||||
|
||||
def test_wholly_blank_copy_is_preserved_and_flagged_without_stale_output(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
source = self.make_source(root, blank=True)
|
||||
before = source.read_bytes()
|
||||
workspace = EvaluationWorkspace(root)
|
||||
workspace.copies_dir.mkdir()
|
||||
(workspace.copies_dir / source.name).write_bytes(b"stale output")
|
||||
automatic.run(workspace, [source], workers=1)
|
||||
self.assertFalse((workspace.copies_dir / source.name).exists())
|
||||
self.assertEqual(
|
||||
(workspace.original_copies_dir / source.name).read_bytes(), before
|
||||
)
|
||||
self.assertIn(source.name, copy_errors(workspace))
|
||||
run = next(workspace.runs_dir.glob("page-split-*"))
|
||||
self.assertEqual(
|
||||
(run / "previous_copies" / source.name).read_bytes(), b"stale output"
|
||||
)
|
||||
|
||||
def test_manual_acceptance_preserves_saved_overlap_blanks_and_order(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
source = self.make_source(root)
|
||||
workspace = EvaluationWorkspace(root)
|
||||
automatic.run(workspace, [source], workers=1)
|
||||
original = workspace.original_copies_dir / source.name
|
||||
before = original.read_bytes()
|
||||
output = workspace.copies_dir / source.name
|
||||
expected = pymupdf.open(output)
|
||||
self.addCleanup(expected.close)
|
||||
preview = PDFPreviewer.__new__(PDFPreviewer)
|
||||
preview.workspace = workspace
|
||||
preview.inputs = [original]
|
||||
preview.num = preview.global_rotation = 0
|
||||
preview.history = []
|
||||
preview.output_dir = None
|
||||
preview.failed = False
|
||||
preview.master = Mock()
|
||||
preview.load_page = Mock()
|
||||
self.assertTrue(preview.setup_next_file())
|
||||
self.assertTrue(preview.automatic_review)
|
||||
preview._initialize_current_page_settings()
|
||||
for _ in range(3):
|
||||
preview.confirm_and_next_page()
|
||||
self.assertFalse(preview.failed)
|
||||
self.assertEqual(
|
||||
[s["keep"] for s in preview.page_settings], ["both", "none", "as_is"]
|
||||
)
|
||||
self.assertEqual(original.read_bytes(), before)
|
||||
with pymupdf.open(output) as actual:
|
||||
self.assertEqual(len(actual), len(expected))
|
||||
for a, b in zip(actual, expected):
|
||||
self.assertAlmostEqual(a.rect.width, b.rect.width, places=3)
|
||||
aa, bb = a.get_pixmap(dpi=40), b.get_pixmap(dpi=40)
|
||||
self.assertEqual((aa.width, aa.height), (bb.width, bb.height))
|
||||
difference = np.abs(
|
||||
np.frombuffer(aa.samples, np.uint8).astype(float)
|
||||
- np.frombuffer(bb.samples, np.uint8)
|
||||
)
|
||||
self.assertLess(difference.mean(), 0.1)
|
||||
|
||||
def test_accepting_wholly_blank_proposal_never_creates_an_empty_pdf(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
source = self.make_source(root, blank=True)
|
||||
workspace = EvaluationWorkspace(root)
|
||||
automatic.run(workspace, [source], workers=1)
|
||||
original = workspace.original_copies_dir / source.name
|
||||
before = original.read_bytes()
|
||||
preview = PDFPreviewer.__new__(PDFPreviewer)
|
||||
preview.workspace = workspace
|
||||
preview.inputs = [original]
|
||||
preview.num = preview.global_rotation = 0
|
||||
preview.history = []
|
||||
preview.failed = False
|
||||
preview.master = Mock()
|
||||
preview.load_page = Mock()
|
||||
preview.setup_next_file()
|
||||
preview._initialize_current_page_settings()
|
||||
preview.confirm_and_next_page()
|
||||
self.assertFalse(preview.failed)
|
||||
self.assertEqual(original.read_bytes(), before)
|
||||
self.assertFalse((workspace.copies_dir / source.name).exists())
|
||||
self.assertNotIn(source.name, copy_errors(workspace))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,88 @@
|
||||
import unittest
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pymupdf
|
||||
|
||||
from copienator.auto_page_split import _analyse_page_full as analyse_page
|
||||
from copienator.fast_page_split import analyse_page_fast, analyse_pdf_page_fast, quick_page_content
|
||||
|
||||
|
||||
class FastPageSplitTests(unittest.TestCase):
|
||||
def test_direct_pdf_strip_respects_rotation_and_crop_offsets(self):
|
||||
image = np.full((1400, 2000, 3), 255, np.uint8)
|
||||
for y in range(150, 650, 70):
|
||||
for x in (150, 1300):
|
||||
cv2.putText(image, 'answer x y z', (x, y),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 1, (20, 20, 20), 2)
|
||||
cv2.putText(image, 'x + y = z', (870, 1000),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 1, (20, 20, 20), 2)
|
||||
for rotation in (0, 90, 180, 270):
|
||||
with self.subTest(rotation=rotation), pymupdf.open() as doc:
|
||||
source = np.ascontiguousarray(np.rot90(image, rotation//90))
|
||||
h,w = source.shape[:2]
|
||||
page = doc.new_page(width=w*72/150+20,height=h*72/150+30)
|
||||
box = pymupdf.Rect(10,15,w*72/150+10,h*72/150+15)
|
||||
pix = pymupdf.Pixmap(pymupdf.csRGB,w,h,source.tobytes(),False)
|
||||
page.insert_image(box,pixmap=pix)
|
||||
page.set_cropbox(box)
|
||||
page.set_rotation(rotation)
|
||||
rendered = page.get_pixmap(dpi=150,alpha=False)
|
||||
full = np.frombuffer(rendered.samples,np.uint8).reshape(rendered.height,rendered.width,3)
|
||||
reference = analyse_page(full,150,'a3')
|
||||
fast,overview = analyse_pdf_page_fast(page,150,'a3')
|
||||
self.assertIsNone(fast['analysis']['fallback'])
|
||||
self.assertEqual(fast['size_px'],reference['size_px'])
|
||||
self.assertLess(overview.size,full.size)
|
||||
self.assertEqual([o['keep'] for o in fast['outputs']],[True,True])
|
||||
for a,b in zip(fast['outputs'],reference['outputs']):
|
||||
self.assertLessEqual(max(abs(x-y) for x,y in zip(a['rect_px'],b['rect_px'])),2)
|
||||
|
||||
def test_plain_blank_and_sparse_pencil_use_full_detector(self):
|
||||
for pencil in (False, True):
|
||||
with self.subTest(pencil=pencil):
|
||||
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)
|
||||
mask, early = quick_page_content(image, 150)
|
||||
self.assertFalse(early)
|
||||
expected = analyse_page(image, 150, 'a4')
|
||||
actual = analyse_page_fast(image, 150, 'a4')
|
||||
self.assertEqual(actual['outputs'][0]['keep'], expected['outputs'][0]['keep'])
|
||||
self.assertEqual(actual['outputs'][0]['rect_px'], [0, 0, 850, 1200])
|
||||
self.assertEqual(bool(mask.any()), pencil)
|
||||
|
||||
def test_compact_roi_maps_bounds_back_to_full_spread(self):
|
||||
image = np.full((1400, 2000, 3), 255, np.uint8)
|
||||
for y in range(150, 650, 70):
|
||||
for x in (150, 1300):
|
||||
cv2.putText(image, 'answer x y z', (x, y),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 1, (20, 20, 20), 2)
|
||||
cv2.putText(image, 'x + y = z', (870, 1000),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 1, (20, 20, 20), 2)
|
||||
for mirrored in (False, True):
|
||||
with self.subTest(mirrored=mirrored):
|
||||
rgb = image[:, ::-1].copy() if mirrored else image
|
||||
reference = analyse_page(rgb, 150, 'a3')
|
||||
fast = analyse_page_fast(rgb, 150, 'a3')
|
||||
self.assertIsNone(fast['analysis']['fallback'])
|
||||
self.assertEqual(fast['size_px'], [2000, 1400])
|
||||
self.assertEqual(fast['extensions_mm'], reference['extensions_mm'])
|
||||
self.assertEqual([o['rect_px'] for o in fast['outputs']],
|
||||
[o['rect_px'] for o in reference['outputs']])
|
||||
self.assertTrue(all(o['keep'] for o in fast['outputs']))
|
||||
|
||||
def test_ambiguous_or_wide_writing_falls_back_without_truncation(self):
|
||||
image = np.full((700, 1000, 3), 255, np.uint8)
|
||||
image[200:230, 300:650] = 20
|
||||
reference = analyse_page(image, 100, 'a3')
|
||||
fast = analyse_page_fast(image, 100, 'a3')
|
||||
self.assertEqual(fast['analysis']['fallback'], 'full-spread')
|
||||
self.assertEqual(fast['outputs'], reference['outputs'])
|
||||
self.assertEqual(fast['crossings'], reference['crossings'])
|
||||
|
||||
def test_unknown_geometry_keeps_reference_behaviour(self):
|
||||
image = np.full((400, 500, 3), 255, np.uint8)
|
||||
self.assertEqual(analyse_page_fast(image, 100, 'unknown'),
|
||||
analyse_page(image, 100, 'unknown'))
|
||||
+107
-1
@@ -3,10 +3,116 @@ import unittest
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from copienator.ink_detection import detect_bounds, _paper_blur
|
||||
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)):
|
||||
|
||||
Reference in new issue
Block a user