Margin detection update
This commit is contained in:
1 parent
2c9dc65640
commit
b295f871a7
6 files changed
+292
-68
No files matched your search
+113
-17
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user