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

+56 -7
View File
@@ -2,10 +2,11 @@
from __future__ import annotations
from pathlib import Path
import math
from google.genai import types
from PIL import Image, ImageDraw
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from copienator import atomic_write_json, read_json
from copienator.annotation_layout import fingerprint
@@ -26,7 +27,7 @@ def save_crop(page: Image.Image, pdf: Path, directory: Path, dpi: int) -> None:
crop.save(staging / f"{pdf.stem}.jpg", "JPEG", quality=95)
atomic_write_json(staging / f"{pdf.stem}.json", {
"source_sha256": fingerprint(pdf), "width_mm": MARGIN_MM,
"height_fraction": 0.25,
"height_fraction": crop.height / page.height,
})
@@ -77,12 +78,44 @@ def build_grids(directory: Path, copies_dir: Path) -> list[dict]:
class NameCell(BaseModel):
cell: int = Field(strict=True, ge=1, le=24)
name: str
box_2d: list[float] | None = Field(
default=None, description="[ymin, xmin, ymax, xmax], 0–1000 relative to the individual crop, excluding its header")
@field_validator('box_2d', mode='before')
@classmethod
def valid_box(cls, value):
# Bad localization must not discard an otherwise readable name.
if (not isinstance(value, list) or len(value) != 4
or any(type(v) not in (int, float) or not math.isfinite(v)
or not 0 <= v <= 1000 for v in value)):
return None
return value if value[0] < value[2] and value[1] < value[3] else None
class NameResponse(BaseModel):
cells: list[NameCell]
def name_bottom_height_fraction(workspace, stem: str) -> float | None:
"""Read the name/class bottom on page 1, in cutleft's 0–1 height units.
Returns None when localization is unavailable or the source/crop changed.
This is intended as a future lower boundary for margin analysis.
"""
directory = workspace.cutleft_dir / 'Names'
try:
cached = read_json(directory / f'{stem}_result.json')
value = cached['name_bottom_height_fraction']
if (cached['source_sha256'] == fingerprint(workspace.copies_dir / f'{stem}.pdf')
and cached['crop_sha256'] == fingerprint(directory / f'{stem}.jpg')
and cached['name'] != MISSING
and type(value) in (int, float) and 0 < value <= 1):
return float(value)
except (OSError, ValueError, KeyError, TypeError):
pass
return None
def identify_names(workspace, groups, *, client, model: str, names_text: str, overwrite: bool,
thinking_level: str | None = "low") -> dict[str, str]:
"""Identify pending copies before label requests; safely fall back on any failure."""
@@ -101,6 +134,7 @@ def identify_names(workspace, groups, *, client, model: str, names_text: str, ov
try:
cached = read_json(cache_path)
if (not overwrite and cached['crop_sha256'] == crop_hash
and 'name_bottom_height_fraction' in cached
and cached['names_text'] == names_text
and cached['name'] in roster | {MISSING}):
result[stem] = cached['name']
@@ -115,11 +149,17 @@ def identify_names(workspace, groups, *, client, model: str, names_text: str, ov
The image is a grid with exactly 6 columns and 4 rows. Read rows left to right,
then top to bottom. Thick black borders separate cells; headers show row, column,
and cell number. Each cell belongs to a DIFFERENT student. Never carry a name
from one cell into another. Ignore class names such as MPSI 3.
from one cell into another. Do not include class names such as MPSI 3 in the name.
Occupied cells are exactly:
{positions}
All other cells are EMPTY padding: do not return them.
Return JSON {{"cells": [{{"cell": 1, "name": "exact authorized name"}}]}}.
Return JSON {{"cells": [{{"cell": 1, "name": "exact authorized name", "box_2d": [100, 50, 400, 900]}}]}}.
For each identified student, box_2d must tightly enclose the handwritten name AND
any adjacent class designation, including a class written just below the name.
Use [ymin, xmin, ymax, xmax] coordinates normalized to 0–1000 relative to that
individual page crop, NOT the whole grid. Exclude the numbered header, black
borders and any padding outside the crop. Return box_2d=null for Missing names
or if the position cannot be determined reliably.
Return one entry per occupied cell, using its cell number. Match only confidently
readable handwriting to an exact authorized name below. Do not guess or assign
names by elimination. Return "Missing" for an unreadable, absent, or ambiguous name.
@@ -143,12 +183,21 @@ Authorized names:
entries = NameResponse.model_validate_json(response.text).cells
by_cell = {}
for entry in entries:
by_cell.setdefault(entry.cell, []).append(entry.name)
by_cell.setdefault(entry.cell, []).append(entry)
for stem, (cell, crop_hash, cache_path) in pending.items():
candidates = by_cell.get(cell['cell'], [])
name = candidates[0] if len(candidates) == 1 and candidates[0] in roster else MISSING
entry = candidates[0] if len(candidates) == 1 else None
name = entry.name if entry is not None and entry.name in roster else MISSING
box = entry.box_2d if name != MISSING else None
metadata = read_json(directory / f'{stem}.json')
bottom = box[2] / 1000 * metadata['height_fraction'] if box else None
result[stem] = name
atomic_write_json(cache_path, {'name': name, 'crop_sha256': crop_hash, 'names_text': names_text})
atomic_write_json(cache_path, {
'name': name, 'crop_sha256': crop_hash, 'names_text': names_text,
'source_sha256': metadata['source_sha256'],
'box_2d': box, # 0–1000 coordinates relative to the name crop.
'name_bottom_height_fraction': bottom, # Fraction of full page 1.
})
except Exception as exc: # Remote failures must preserve the existing fallback.
print(f"Name recognition failed for {grid['image']}: {type(exc).__name__}: {exc}; using label/name fallback.")
# Do not cache transport/schema failures: a resumed run should retry.