diff --git a/copienator/annotation_layout.py b/copienator/annotation_layout.py index 77eb511..75ae0cc 100644 --- a/copienator/annotation_layout.py +++ b/copienator/annotation_layout.py @@ -61,13 +61,26 @@ def move_answer_pdf(source, destination): metadata_path(source).unlink(missing_ok=True) +def added_margin(margin_mm): + """Interpolate the configured (millimetres, pixels) points, clamping at ends.""" + if margin_mm is None: + return configuration.ANNOTATION_MARGIN_LEFT + points = configuration.ANNOTATION_MARGIN_POINTS + if (not points or any(x < 0 or type(y) is not int or y < 0 for x, y in points) + or any(a[0] >= b[0] for a, b in zip(points, points[1:]))): + raise ValueError('Annotation margin points must have increasing mm and non-negative integer pixels') + if margin_mm <= points[0][0]: + return points[0][1] + for (x0, y0), (x1, y1) in zip(points, points[1:]): + if margin_mm <= x1: + return round(y0 + (y1 - y0) * (margin_mm - x0) / (x1 - x0)) + return points[-1][1] + + def layout_for(pdf): - low = any(part.get('margin_status') == 'low' for part in answer_parts(pdf)) - margin = (configuration.ANNOTATION_LOW_MARGIN_LEFT if low - else configuration.ANNOTATION_MARGIN_LEFT) - if type(margin) is not int or margin < 0: - raise ValueError('Annotation left margins must be non-negative integers') - return {'version': 1, 'margin_left': margin, 'narrow_left_pad_limit': 300} + margins = [added_margin(part.get('margin_mm')) for part in answer_parts(pdf)] + return {'version': 2, 'margin_left': max(margins, default=added_margin(None)), + 'page_margins': margins, 'narrow_left_pad_limit': 0} def legacy_layout(): diff --git a/copienator/commands/annotating.py b/copienator/commands/annotating.py index 888e562..d835cbe 100644 --- a/copienator/commands/annotating.py +++ b/copienator/commands/annotating.py @@ -63,9 +63,13 @@ def make_base_image(pdf_path): base_img = Image.new("RGBA", (max_w, total_h), "white") current_y = 0 + page_regions = [] for page in pages: - base_img.paste(page.convert("RGBA"), ((max_w - page.width) // 2, current_y)) + x = (max_w - page.width) // 2 + base_img.paste(page.convert("RGBA"), (x, current_y)) + page_regions.append({'x': x, 'y': current_y, 'width': page.width, 'height': page.height}) current_y += page.height + base_img.info['page_regions'] = page_regions return (base_img, total_h, max_w) def normalize_mathtext(text): @@ -352,6 +356,10 @@ def compose_label_image(base_img, label, result, hmin, layout = dict(layout if layout is not None else layout_for(source_pdf)) margin_left = layout['margin_left'] + page_regions = base_img.info.get('page_regions', [ + {'x': 0, 'y': 0, 'width': base_img.width, 'height': base_img.height}]) + page_margins = layout.get('page_margins', []) + per_page = layout.get('version', 1) >= 2 left_pad = 0 if base_img.width < TARGET_MIN_WIDTH: total_missing = TARGET_MIN_WIDTH - base_img.width @@ -411,7 +419,11 @@ def compose_label_image(base_img, label, result, hmin, total_height = base_img.height + header_height # Create Canvas - final_img = Image.new("RGB", (base_img.width + margin_left, total_height), "white") + final_width = (max(TARGET_MIN_WIDTH + margin_left, + max(region['width'] + (page_margins[i] if i < len(page_margins) else margin_left) + for i, region in enumerate(page_regions))) + if per_page else base_img.width + margin_left) + final_img = Image.new("RGB", (final_width, total_height), "white") # Draw Headers current_y = 0 @@ -434,7 +446,13 @@ def compose_label_image(base_img, label, result, hmin, # Paste Base Image image_offset_y = current_y - final_img.paste(base_img, (margin_left, image_offset_y)) + if per_page: + for i, region in enumerate(page_regions): + x, y, w, h = (region[key] for key in ('x', 'y', 'width', 'height')) + margin = page_margins[i] if i < len(page_margins) else margin_left + final_img.paste(base_img.crop((x, y, x + w, y + h)), (margin, image_offset_y + y)) + else: + final_img.paste(base_img, (margin_left, image_offset_y)) # 2. Draw Local Annotations draw = ImageDraw.Draw(final_img, "RGBA") # Refresh draw object @@ -451,12 +469,24 @@ def compose_label_image(base_img, label, result, hmin, # Draw Rectangle (if not suppressed) if "norectangle" not in fb: - draw.rectangle([target_xmin, target_ymin, target_xmax, target_ymax], outline="red", width=3) + boxes = [[target_xmin, target_ymin, target_xmax, target_ymax]] + if per_page: + boxes = [] + for i, region in enumerate(page_regions): + top = max(ymin - hmin, region['y']) + bottom = min(ymax - hmin, region['y'] + region['height']) + if bottom <= top: + continue + margin = page_margins[i] if i < len(page_margins) else margin_left + shift = margin - region['x'] + boxes.append([xmin + shift, top + image_offset_y, + xmax + shift, bottom + image_offset_y]) + for box in boxes: + draw.rectangle(box, outline="red", width=3) - if draw_callback: - callback_items.append(("local_rect", - {"box": [target_xmin, target_ymin, target_xmax, target_ymax]}, - {"data": fb, "index": idx})) + if draw_callback: + callback_items.append(("local_rect", {"box": box}, + {"data": fb, "index": idx})) # Render Text txt_img = render_fn(fb['text'], width_px=ANNOT_WIDTH, diff --git a/copienator/commands/cutleft.py b/copienator/commands/cutleft.py index e92bb29..3055f49 100644 --- a/copienator/commands/cutleft.py +++ b/copienator/commands/cutleft.py @@ -428,6 +428,11 @@ class ImageReviewer: for group in analysis['groups']) margin_info = (f"Auto margin: {offsets} mm from left" + (' | Check uncertain pages' if needs_review else '') + '\n') + for page in analysis.get('pages', []): + if page.get('status') == 'unknown': + value = page.get('effective_edge_mm') + fallback = f" (copy average: {value:.1f} mm)" if value is not None else '' + margin_info += f"Page {page['page']}: unknown margin{fallback}\n" if analysis.get('no_margin_from_page') is not None: margin_info += f"No margin from page {analysis['no_margin_from_page']} onwards\n" self.label_info.configure( diff --git a/copienator/commands/splitting_int.py b/copienator/commands/splitting_int.py index 43ee19f..28311cf 100644 --- a/copienator/commands/splitting_int.py +++ b/copienator/commands/splitting_int.py @@ -23,6 +23,7 @@ from copienator import ( ) from copienator.filesystem import staged_directory from copienator.annotation_layout import fingerprint, metadata_path, save_parts +from copienator.left_margin import margins_with_fallback SQUARE = 1000 // 38 ANSWER_TOP_PADDING_POINTS = 4 * 72 / 25.4 @@ -133,7 +134,7 @@ def _render_split_outputs( provenance: defaultdict[str, list[dict]] = defaultdict(list) schema = read_json(input_pdf.parent.parent / 'Cutleft' / f'{input_pdf.stem}_schema.json', default={}) analysis = schema.get('auto_margin') or {} - measurements = ({item['page']: item for item in analysis.get('pages', [])} + measurements = ({item['page']: item for item in margins_with_fallback(analysis.get('pages', []))} if schema.get('source_sha256') == fingerprint(input_pdf) else {}) with tempfile.TemporaryDirectory(prefix="copienator-split-") as temp_directory: temporary = Path(temp_directory) @@ -206,13 +207,14 @@ def _render_split_outputs( part_path, ) parts_by_label[clean_label].append(part_path) - edge = measurements.get(page_number + 1, {}).get('edge_mm') + measurement = measurements.get(page_number + 1, {}) + edge = measurement.get('effective_edge_mm') remaining = None if edge is None else max(0, edge - clip.x0 * 25.4 / 72) provenance[clean_label].append({ 'source': input_pdf.name, 'source_sha256': schema.get('source_sha256'), 'page': page_number + 1, 'crop_points': list(clip), 'margin_mm': remaining, - 'margin_status': ('unknown' if remaining is None else + 'margin_status': ('unknown' if measurement.get('edge_mm') is None else 'low' if remaining < 20 else 'normal'), }) diff --git a/copienator/configuration.py b/copienator/configuration.py index 4cfe238..ab7ac0c 100644 --- a/copienator/configuration.py +++ b/copienator/configuration.py @@ -40,7 +40,7 @@ THINKING_LEVEL_FLASH = "medium" THINKING_LEVEL_PRO = "high" ALWAYS_CROP = False ANNOTATION_MARGIN_LEFT = 300 -ANNOTATION_LOW_MARGIN_LEFT = 400 +ANNOTATION_MARGIN_POINTS = [(10, 450), (20, 400), (35, 300)] ANNOTATION_MAX_HEIGHT_PX = 33000 ANNOTATION_MAX_PIXELS = 67_108_864 SHOW_PERSONAL_STEPS = False diff --git a/copienator/left_margin.py b/copienator/left_margin.py index bfbd32e..0e609a1 100644 --- a/copienator/left_margin.py +++ b/copienator/left_margin.py @@ -27,6 +27,15 @@ STANDARD_LINE_HEIGHT_MM = 8 MAX_EXCEPTION_LINES = 4 +def margins_with_fallback(pages: list[dict]) -> list[dict]: + """Keep measurements intact; unknown pages borrow the copy's measured mean.""" + known = [page['edge_mm'] for page in pages if page.get('edge_mm') is not None] + average = sum(known) / len(known) if known else None + return [{**page, 'effective_edge_mm': ( + page['edge_mm'] if page.get('edge_mm') is not None else average + )} for page in pages] + + def trimmed_left_edge(starts: list[float]) -> float | None: """Discard the leftmost 10% of occupied bands; retain the next minimum.""" if not starts: @@ -142,9 +151,13 @@ def analyse_margins(images: list[Image.Image], render_dpi: int, reference_width: if name_localized else estimate_page(images[index], render_dpi)) return page_estimates[index] + page_estimates = dict(enumerate(margins_with_fallback( + [page_estimate(index) for index in range(len(images))]))) + for indices in format_groups(images, reference_width): estimates = {index: page_estimate(index) for index in indices} - values = [item['edge_mm'] for item in estimates.values() if item['edge_mm'] is not None] + values = [item['effective_edge_mm'] for item in estimates.values() + if item['effective_edge_mm'] is not None] band_starts = [start for item in estimates.values() for start in item['band_starts_mm']] status = 'insufficient-ink' edge = round(float(np.median(values)), 1) if values else None @@ -178,10 +191,15 @@ def analyse_margins(images: list[Image.Image], render_dpi: int, reference_width: previous_coherent = 0 for index in indices: estimate = page_estimate(index) - low = estimate['edge_mm'] is not None and estimate['edge_mm'] < MIN_MARGIN_MM + effective_edge = estimate['effective_edge_mm'] + low = effective_edge is not None and effective_edge < MIN_MARGIN_MM tolerated = (status == 'margin' and low and previous_coherent >= 2 and short_margin_exception(estimate, edge)) starts[index] = 0 if low and not tolerated else start + if estimate['edge_mm'] is None and status not in {'narrow-page', 'labels-in-margin'}: + fallback_left = (max(effective_edge / 2, effective_edge - MARGIN_CLEARANCE_MM) + if effective_edge is not None and effective_edge >= MIN_MARGIN_MM else 0) + starts[index] = round(fallback_left * render_dpi / 25.4) estimate['short_margin_exception'] = tolerated estimate['decision'] = ('short-exception' if tolerated else 'page-near-edge' if low else status) @@ -209,7 +227,7 @@ def analyse_margins(images: list[Image.Image], render_dpi: int, reference_width: 'status': 'unknown' if edge is None else ('low' if edge < MIN_MARGIN_MM else 'normal')}) - return {'version': 13, 'pages': pages, 'starts_px': starts, 'groups': groups, + return {'version': 14, 'pages': pages, 'starts_px': starts, 'groups': groups, 'no_margin_from_page': None, 'discard_left_fraction': DISCARD_LEFT_FRACTION, 'margin_clearance_mm': MARGIN_CLEARANCE_MM, 'cut_fraction_of_margin': .5, 'cut_rule': 'maximum', diff --git a/default_config.py b/default_config.py index baee267..5e7b61c 100644 --- a/default_config.py +++ b/default_config.py @@ -8,7 +8,9 @@ IMPORT_DIR = Path("Import") # Espace ajouté à gauche des réponses annotées, en pixels à 200 dpi. ANNOTATION_MARGIN_LEFT = 300 -ANNOTATION_LOW_MARGIN_LEFT = 400 +# (Marge mesurée en mm, espace ajouté en pixels à 200 dpi). +# Interpolation linéaire entre les points ; valeurs constantes aux extrémités. +ANNOTATION_MARGIN_POINTS = [(10, 450), (20, 400), (35, 300)] # Hauteur maximale des documents Bnot et des groupes BGnot, en pixels. # Les blocs d'élèves trop grands sont séparés entre deux réponses. diff --git a/docs/final_output.md b/docs/final_output.md index 65a6391..ff3044f 100644 --- a/docs/final_output.md +++ b/docs/final_output.md @@ -10,13 +10,18 @@ extension **`.jpg`**, not `.jpeg`. ```python ANNOTATION_MARGIN_LEFT = 300 -ANNOTATION_LOW_MARGIN_LEFT = 400 +ANNOTATION_MARGIN_POINTS = [(10, 450), (20, 400), (35, 300)] ``` -These are added pixels at the 200 dpi annotation rendering scale. An answer -uses the larger margin if any of its source fragments has a measured left -margin below 20 mm (half the standard 40 mm). Unknown or insufficiently written -pages retain the normal spacing. The 600 px comment width is unchanged, so +The reference points are (measured margin in mm, added pixels at 200 dpi). +Each page fragment gets its own spacing: 450 px at or below 10 mm, 400 px at +20 mm, and 300 px at or above 35 mm, with linear interpolation between points. +Unknown pages retain their unknown status but use the arithmetic mean of the +copy's measured page margins, including during label-strip positioning. +Inferred values never contribute to that mean. If no page has a measurement, +`ANNOTATION_MARGIN_LEFT` supplies the default 300 px. +The old `ANNOTATION_LOW_MARGIN_LEFT` setting is replaced by the reference points. +The 600 px comment width is unchanged, so comments can still overlap handwriting on pages with very little whitespace. Automatic `crop-labels` saves per-page measurements in the Cutleft schema. @@ -33,7 +38,10 @@ save their rendering geometry and reuse it when read back, even after settings change. Older batches without saved geometry keep their original 300 px offset. Do not overwrite a batch that still contains handwritten corrections to import. -All fragments of one answer share one left offset. BGnot pads narrower answer +Each fragment is positioned independently, with its annotation rectangles shifted +by the same amount. Rectangles crossing page boundaries are split at those boundaries. +Narrow pages receive any additional width on the right, so left padding does not +stack with the configured allowance. BGnot pads narrower answer images with white without resizing handwriting; fitting the wider document to screen or paper may reduce the displayed scale, and return-PDF page breaks can change. The extra space is also used by Anot and Bnot. diff --git a/tests/test_annotation_layout.py b/tests/test_annotation_layout.py index fa47d90..baafbe6 100644 --- a/tests/test_annotation_layout.py +++ b/tests/test_annotation_layout.py @@ -9,7 +9,7 @@ from PIL import Image from copienator import atomic_write_json, configuration, read_json from copienator.annotation_layout import ( answer_parts, copy_answer_pdf, fingerprint, layout_for, legacy_layout, - move_answer_pdf, save_parts, + move_answer_pdf, save_parts, added_margin, ) from copienator.commands import annotating, annotating_by_label, splitting_int from copienator.commands.annotating_with_checks import CheckboxRenderer @@ -42,7 +42,8 @@ class AnnotationLayoutTests(unittest.TestCase): with patch('copienator.left_margin.estimate_page', side_effect=estimate): result = analyse_margins(images, 200, 600) self.assertEqual([p['status'] for p in result['pages']], - ['low', 'low', 'normal', 'unknown', 'low', 'normal']) + ['low', 'low', 'normal', 'unknown', 'low', 'low']) + self.assertAlmostEqual(result['pages'][3]['effective_edge_mm'], 19.98) def test_split_records_pages_and_rejects_stale_measurements(self): schema_path = self.root / 'Cutleft' / 'Copie01_schema.json' @@ -55,7 +56,7 @@ class AnnotationLayoutTests(unittest.TestCase): splitting_int._render_split_outputs(self.pdf, [('Ex 1', 0, 0, 10, 0, 10)], staging) answer = staging / 'Ex 1.pdf' self.assertEqual([p['page'] for p in answer_parts(answer)], [1, 2]) - self.assertEqual(layout_for(answer)['margin_left'], 400) + self.assertEqual(layout_for(answer)['page_margins'], [300, 450]) atomic_write_json(schema_path, {'source_sha256': 'stale', 'auto_margin': { 'pages': [{'page': 1, 'edge_mm': 10}]}}) splitting_int._render_split_outputs(self.pdf, [('Ex 1', 0, 0, 10, 0, 10)], staging) @@ -63,8 +64,9 @@ class AnnotationLayoutTests(unittest.TestCase): def test_configurable_margin_unknown_fallback_and_stale_sidecar(self): self.assertEqual(layout_for(self.pdf)['margin_left'], 300) - save_parts(self.pdf, [{'margin_status': 'normal'}, {'margin_status': 'low'}]) - with patch.object(configuration, 'ANNOTATION_LOW_MARGIN_LEFT', 450): + save_parts(self.pdf, [{'margin_status': 'normal', 'margin_mm': 40}, + {'margin_status': 'low', 'margin_mm': 20}]) + with patch.object(configuration, 'ANNOTATION_MARGIN_POINTS', [(10, 500), (20, 450), (35, 300)]): self.assertEqual(layout_for(self.pdf)['margin_left'], 450) self.pdf.write_bytes(self.pdf.read_bytes() + b'\n') self.assertEqual(layout_for(self.pdf)['margin_left'], 300) @@ -79,11 +81,12 @@ class AnnotationLayoutTests(unittest.TestCase): # 20% of a 600-point page removes 42.3 mm from the left. splitting_int._render_split_outputs(self.pdf, [('|Ex 1', 0, 0, 10, 100, 110)], staging) answer = staging / 'Ex 1.pdf' - self.assertEqual(layout_for(answer)['margin_left'], 400) + self.assertEqual(layout_for(answer)['margin_left'], 450) self.assertEqual(answer_parts(answer)[0]['margin_mm'], 0) def test_copy_move_and_vertical_cut_preserve_page_classification(self): - save_parts(self.pdf, [{'margin_status': 'normal'}, {'margin_status': 'low'}]) + save_parts(self.pdf, [{'margin_status': 'normal', 'margin_mm': 40}, + {'margin_status': 'low', 'margin_mm': 20}]) copied = self.root / 'copied.pdf' moved = self.root / 'moved.pdf' copy_answer_pdf(self.pdf, copied) @@ -105,6 +108,47 @@ class AnnotationLayoutTests(unittest.TestCase): box = next(call.args[2]['box'] for call in callback.call_args_list if call.args[0] == 'local_rect') return image, header, box + def test_interpolation_and_clamping(self): + for mm, pixels in [(None, 300), (0, 450), (10, 450), (15, 425), + (20, 400), (27.5, 350), (35, 300), (80, 300)]: + with self.subTest(mm=mm): + self.assertEqual(added_margin(mm), pixels) + + def test_unknown_measurement_inherits_average_during_answer_extraction(self): + atomic_write_json(self.root / 'Cutleft' / 'Copie01_schema.json', { + 'source_sha256': fingerprint(self.pdf), + 'auto_margin': {'pages': [{'page': 1, 'edge_mm': 15}, {'page': 2, 'edge_mm': None}]}, + }) + staging = self.root / 'inferred' + staging.mkdir() + splitting_int._render_split_outputs(self.pdf, [('Ex 1', 0, 0, 10, 0, 10)], staging) + answer = staging / 'Ex 1.pdf' + self.assertEqual(answer_parts(answer)[1]['margin_status'], 'unknown') + self.assertEqual(answer_parts(answer)[1]['margin_mm'], 15) + self.assertEqual(layout_for(answer)['page_margins'], [425, 425]) + + def test_per_page_offsets_narrow_pages_and_saved_layout(self): + save_parts(self.pdf, [{'margin_mm': 10}, {'margin_mm': 35}]) + pages = [Image.new('RGB', (1000, 100), 'blue'), Image.new('RGB', (800, 100), 'green')] + with patch.object(annotating, 'convert_from_path', return_value=pages): + base, _, _ = annotating.make_base_image(self.pdf) + result = {'score': 2, 'feedback': [{'text': 'Across pages', 'box_2d': [80, 150, 120, 200]}]} + callback = Mock() + with patch.object(annotating, 'render_score_text', return_value=Image.new('RGB', (200, 40))): + def render(**kwargs): + return annotating.compose_label_image( + base, 'Ex 1', result, 0, render_fn=lambda *a, **k: Image.new('RGBA', (10, 10)), + **kwargs) + image, header = render(source_pdf=self.pdf, draw_callback=callback) + boxes = [call.args[2]['box'] for call in callback.call_args_list if call.args[0] == 'local_rect'] + self.assertEqual(boxes, [[600, 120, 650, 140], [350, 140, 400, 160]]) + for x, y, color in [(450, header + 20, (0, 0, 255)), (300, header + 150, (0, 128, 0))]: + self.assertEqual(image.getpixel((x, y)), color) + self.assertEqual(image.getpixel((x - 1, y)), (255, 255, 255)) + with patch.object(configuration, 'ANNOTATION_MARGIN_POINTS', [(10, 900)]): + restored, _ = render(layout=image.info['annotation_layout']) + self.assertEqual(restored.tobytes(), image.tobytes()) + def test_wider_layout_translates_copy_and_feedback_without_scaling(self): normal, height, box = self.render(legacy_layout()) low, low_height, low_box = self.render({**legacy_layout(), 'margin_left': 400}) @@ -145,7 +189,7 @@ class AnnotationLayoutTests(unittest.TestCase): self.assertEqual(meta['width'], low.width) with Image.open(output / 'Reference.jpg') as reference: self.assertGreater(min(reference.getpixel((low.width - 10, 80))), 245) - with patch.object(reader, 'detect_checks_and_notes', return_value=([], Image.new('RGBA', (low.width, low.height * 2)))), patch.object(configuration, 'ANNOTATION_LOW_MARGIN_LEFT', 500): + with patch.object(reader, 'detect_checks_and_notes', return_value=([], Image.new('RGBA', (low.width, low.height * 2)))), patch.object(configuration, 'ANNOTATION_MARGIN_POINTS', [(10, 500)]): _, notes = reader._scan_annotation_directory(output) self.assertEqual(notes['02']['Ex 1']['layout']['margin_left'], 400) rerender, _, _ = self.render(notes['02']['Ex 1']['layout']) diff --git a/tests/test_left_margin.py b/tests/test_left_margin.py index d6523b0..aae0a8d 100644 --- a/tests/test_left_margin.py +++ b/tests/test_left_margin.py @@ -324,9 +324,18 @@ class LeftMarginTests(unittest.TestCase): self.assertNotIn('first_page_body_start_px', result) def test_sparse_groups_stay_at_left(self): - for edges in ([None] * 4, [40, None, None, None]): - with self.subTest(edges=edges): - self.assertEqual(self.analyse(edges)['starts_px'], [0] * 4) + result = self.analyse([None] * 4) + self.assertEqual(result['starts_px'], [0] * 4) + self.assertTrue(all(page['effective_edge_mm'] is None for page in result['pages'])) + + def test_unknown_pages_borrow_only_measured_copy_average(self): + result = self.analyse([40, 50, None, None]) + self.assertEqual([page['effective_edge_mm'] for page in result['pages']], [40, 50, 45, 45]) + self.assertEqual(result['pages'][2]['status'], 'unknown') + self.assertIsNone(result['pages'][2]['edge_mm']) + self.assertEqual(result['starts_px'][2:], [round(25 * 200 / 25.4)] * 2) + sparse = self.analyse([40, None, None, None]) + self.assertEqual(sparse['starts_px'], [round(20 * 200 / 25.4)] * 4) def test_narrow_subpages_stay_at_left_even_with_indented_text(self): result = self.analyse([45, 45, 70, 72], widths=[1654, 1654, 800, 800])