Margin detection update

This commit is contained in:
sebastien committed 2026-10-30 12:34:37 +01:00
1 parent 2c9dc65640
commit b295f871a7
6 files changed
+292 -68

No files matched your search

+113 -17
View File
@@ -53,23 +53,53 @@ class LeftMarginTests(unittest.TestCase):
group = result['groups'][0]
self.assertEqual(group['edge_mm'], 42)
self.assertEqual(group['left_mm'], 22)
self.assertEqual(group['discarded_bands'], 14)
self.assertEqual(result['starts_px'], [round(22 * 200 / 25.4)] * 4)
self.assertEqual(group['aggregation'], 'median-of-page-deciles')
self.assertEqual(result['starts_px'], [round(22 * 200 / 25.4)] * 3 + [0])
def test_short_exception_requires_prior_margin_and_one_small_block(self):
# At 4 mm per band, eight bands cover four standard 8 mm lines.
for prior, low_positions, expected in (
(2, list(range(8)), True),
(2, list(range(9)), False),
(1, list(range(8)), False),
(0, list(range(8)), False),
(2, [0, 2, 4, 6, 8], False)):
with self.subTest(prior=prior, low_positions=low_positions):
images = [Image.new('RGB', (1654, 200)) for _ in range(5)]
def estimate(image, _dpi):
index = next(i for i, candidate in enumerate(images) if candidate is image)
bands = [{'start_mm': 5 if index == prior and i in low_positions else 42,
'top_mm': i * 4, 'bottom_mm': (i + 1) * 4} for i in range(30)]
starts = [b['start_mm'] for b in bands]
return {'edge_mm': left_margin.trimmed_left_edge(starts),
'bands': len(bands), 'body_bands': bands, 'band_starts_mm': starts}
with patch.object(left_margin, 'estimate_page', side_effect=estimate):
result = left_margin.analyse_margins(images, 200, 1654)
self.assertEqual(result['pages'][prior]['short_margin_exception'], expected)
self.assertEqual(result['starts_px'][prior], round(22 * 200 / 25.4) if expected else 0)
self.assertGreater(result['starts_px'][-1], 0)
def test_short_exception_rejects_inconsistent_or_insufficient_remainder(self):
for remainder in ([65] * 22, [42] * 5):
starts = [5] * 8 + remainder
estimate = {'body_bands': [{'start_mm': x, 'top_mm': i * 4, 'bottom_mm': (i + 1) * 4}
for i, x in enumerate(starts)]}
self.assertFalse(left_margin.short_margin_exception(estimate, 42))
def test_repeated_left_writing_beyond_ten_percent_is_retained(self):
self.assertEqual(left_margin.trimmed_left_edge([10] * 20 + [42] * 80), 10)
def test_disagreeing_pages_use_half_margin_when_it_removes_more(self):
result = self.analyse([25, 31, 68, 49])
self.assertEqual(result['groups'][0]['left_mm'], 12.5)
self.assertEqual(result['starts_px'][1:], [round(12.5 * 200 / 25.4)] * 3)
result = self.analyse([33, 35, 41, 39])
self.assertEqual(result['groups'][0]['left_mm'], 18.5)
self.assertEqual(result['starts_px'][1:], [round(18.5 * 200 / 25.4)] * 3)
def test_small_margin_starts_at_left(self):
result = self.analyse([10, 15, 8, 13])
self.assertEqual(result['groups'][0]['left_mm'], 0)
self.assertEqual(result['starts_px'], [0] * 4)
def test_two_consecutive_no_margin_pages_enforce_left_edge_afterwards(self):
def test_low_pages_do_not_cancel_later_pages_margin(self):
images = [Image.new('RGB', (1654, 200)) for _ in range(8)]
# Low pages have fewer occupied bands, so pooling still proposes a margin.
edges = [42, 42, 10, 12, 42, 42, 42, 42]
@@ -81,8 +111,8 @@ class LeftMarginTests(unittest.TestCase):
with patch.object(left_margin, 'estimate_page', side_effect=estimate):
result = left_margin.analyse_margins(images, 200, 1654)
self.assertEqual(result['groups'][0]['left_mm'], 22)
self.assertEqual(result['no_margin_from_page'], 3)
self.assertEqual(result['starts_px'], [round(22 * 200 / 25.4)] * 2 + [0] * 6)
self.assertIsNone(result['no_margin_from_page'])
self.assertEqual(result['starts_px'], [round(22 * 200 / 25.4)] * 2 + [0] * 2 + [round(22 * 200 / 25.4)] * 4)
def test_isolated_low_or_blank_pages_do_not_trigger_no_margin_rule(self):
for edges in ([42, 10, 42, 12], [42, 10, None, 12]):
@@ -90,21 +120,24 @@ class LeftMarginTests(unittest.TestCase):
result = self.analyse(edges)
self.assertIsNone(result['no_margin_from_page'])
def test_two_no_margin_pages_enforce_rule_across_later_width_changes(self):
def test_low_pages_do_not_cancel_later_width_groups(self):
result = self.analyse([42, 42, 10, 12, 42, 42],
widths=[1654, 1654, 1654, 1654, 2000, 2000],
band_counts=[100, 100, 6, 6, 100, 100])
self.assertEqual(result['no_margin_from_page'], 3)
self.assertGreater(result['starts_px'][0], 0)
self.assertEqual(result['starts_px'][2:], [0] * 4)
self.assertIsNone(result['no_margin_from_page'])
# First group's median is 26 mm; the later width group still has 42 mm.
self.assertEqual(result['starts_px'][0], 0)
self.assertEqual(result['starts_px'][2:4], [0, 0])
self.assertTrue(all(result['starts_px'][4:]))
def test_larger_cut_distance_and_no_margin_threshold(self):
for edge, expected in ((0, 0), (19.9, 0), (20, 10), (20.1, 10.05), (28.4, 14.2), (40, 20), (60, 40)):
for edge, expected in ((0, 0), (20, 0), (25.4, 0), (26.4, 0), (27.9, 0), (28, 0), (31, 0), (32.5, 0), (32.9, 0), (33, 16.5), (33.1, 16.55), (40, 20), (60, 40)):
with self.subTest(edge=edge):
result = self.analyse([edge] * 4)
self.assertEqual(result['starts_px'][1:], [round(expected * 200 / 25.4)] * 3)
self.assertEqual(result['groups'][0]['status'], 'near-edge' if edge < 20 else 'margin')
self.assertEqual(result['groups'][0]['status'], 'near-edge' if edge < 33 else 'margin')
self.assertEqual(result['margin_clearance_mm'], 20)
self.assertEqual(result['minimum_margin_mm'], 33)
def test_body_bands_ignore_header_footer_and_abstain_on_sparse_ink(self):
mask = np.zeros((1169, 827), bool)
@@ -155,7 +188,7 @@ class LeftMarginTests(unittest.TestCase):
self.assertTrue(group['labels_in_margin'])
self.assertEqual(result['starts_px'], [0] * 6)
def test_unsampled_coherent_page_can_move_the_whole_group_left(self):
def test_any_coherent_page_can_move_the_whole_group_left(self):
images = [Image.new('RGB', (1654, 200)) for _ in range(6)]
def estimate(image, _dpi):
labels = [5.] * 20 if image is images[2] else []
@@ -164,7 +197,7 @@ class LeftMarginTests(unittest.TestCase):
with patch.object(left_margin, 'estimate_page', side_effect=estimate):
result = left_margin.analyse_margins(images, 200, 1654)
group = result['groups'][0]
self.assertNotIn(3, [p['page'] for p in group['samples']])
self.assertIn(3, [p['page'] for p in group['samples']])
self.assertIn(3, group['coherent_pages'])
self.assertEqual(result['starts_px'], [0] * 6)
@@ -193,6 +226,69 @@ class LeftMarginTests(unittest.TestCase):
self.assertFalse(result['groups'][0]['labels_in_margin'])
self.assertGreater(result['starts_px'][0], 0)
def test_localized_name_bottom_reveals_labels_above_old_cutoff(self):
mask = np.zeros((1169, 827), bool)
mask[50:150, 4:100] = True # Name above localized bottom.
mask[250:265, 30:110] = True # Heading between 20% and 30%.
for y in range(400, 920, 35):
mask[y:y + 5, 170:270] = True
old = left_margin.body_edge(mask)
localized = left_margin.body_edge(mask, top_fraction=.20)
self.assertGreater(min(old['label_band_starts_mm']), 40)
self.assertAlmostEqual(min(localized['label_band_starts_mm']), 7.62)
self.assertTrue(all(start > 2 for start in localized['label_band_starts_mm']))
def test_first_page_name_fraction_is_reused_on_every_page(self):
images = [Image.new('RGB', (1654, 200)) for _ in range(6)]
estimate = {'edge_mm': 42, 'bands': 12, 'band_starts_mm': [42.] * 12,
'label_band_starts_mm': [42.] * 12}
with patch.object(left_margin, 'estimate_page', return_value=estimate) as estimator:
result = left_margin.analyse_margins(images, 200, 1654,
name_bottom_height_fraction=.14)
self.assertEqual(estimator.call_count, 6)
self.assertTrue(all(call.kwargs == {'top_fraction': .14}
for call in estimator.call_args_list))
self.assertEqual(result['body_height_fraction'], [.14, .82])
self.assertEqual(result['label_height_fraction'], [.14, .92])
self.assertEqual(result['top_cutoff_source'], 'localized-name')
def test_unavailable_or_invalid_name_fraction_keeps_fallback(self):
images = [Image.new('RGB', (1654, 200))]
estimate = {'edge_mm': None, 'bands': 0, 'band_starts_mm': [],
'label_band_starts_mm': []}
for value in (None, 0, -1, 1.1, True, '0.1', float('nan')):
with self.subTest(value=value), patch.object(left_margin, 'estimate_page', return_value=estimate):
result = left_margin.analyse_margins(images, 200, 1654,
name_bottom_height_fraction=value)
self.assertEqual(result['body_height_fraction'][0], .30)
self.assertEqual(result['top_cutoff_source'], 'fallback')
self.assertIsNone(result['name_bottom_height_fraction'])
self.assertIsNone(left_margin.body_edge(np.zeros((100, 100), bool), .95)['edge_mm'])
def test_cutleft_reads_validated_name_cache_and_rejects_stale_source(self):
import json
import tempfile
from copienator.annotation_layout import fingerprint
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / 'Copies').mkdir()
names = root / 'Cutleft' / 'Names'
names.mkdir(parents=True)
pdf = root / 'Copies' / 'Copie01.pdf'
pdf.write_bytes(b'original-pdf')
crop = names / 'Copie01.jpg'
crop.write_bytes(b'name-crop')
(names / 'Copie01_result.json').write_text(json.dumps({
'name': 'Student', 'source_sha256': fingerprint(pdf),
'crop_sha256': fingerprint(crop), 'name_bottom_height_fraction': .14}))
images = [Image.new('RGB', (900, 600), 'white')]
for expected in (.14, None):
with patch.object(cutleft, 'get_pdf_pages', return_value=images), \
patch.object(cutleft, 'analyse_margins', return_value={'starts_px': [0], 'groups': []}) as analyse:
self.assertIsNotNone(cutleft.process_single_pdf(pdf))
self.assertEqual(analyse.call_args.kwargs['name_bottom_height_fraction'], expected)
pdf.write_bytes(b'changed-pdf')
def test_real_mask_rejects_ruling_and_preserves_handwriting(self):
image = Image.new('RGB', (827, 1169), 'white')
draw = ImageDraw.Draw(image)
@@ -224,7 +320,7 @@ class LeftMarginTests(unittest.TestCase):
def test_clear_margin_uses_same_conservative_edge_on_every_page(self):
result = self.analyse([43, 41, 46, 44])
self.assertEqual(result['groups'][0]['status'], 'margin')
self.assertEqual(result['starts_px'], [round(21 * 200 / 25.4)] * 4)
self.assertEqual(result['starts_px'], [round(23.5 * 200 / 25.4)] * 4)
self.assertNotIn('first_page_body_start_px', result)
def test_sparse_groups_stay_at_left(self):