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