better name finding (use top part, full width)
This commit is contained in:
1 parent
9d0e40cc4f
commit
5a54b5ac2b
2 files changed
+155
-24
No files matched your search
+78
-24
@@ -31,10 +31,13 @@ def save_crop(page: Image.Image, pdf: Path, directory: Path, dpi: int) -> None:
|
||||
})
|
||||
|
||||
|
||||
def build_grids(directory: Path, copies_dir: Path) -> list[dict]:
|
||||
"""Rebuild deterministic 6-by-4 sheets, excluding deleted or changed copies."""
|
||||
def build_grids(directory: Path, copies_dir: Path, *, columns: int = COLUMNS,
|
||||
rows: int = ROWS, stems: set[str] | None = None) -> list[dict]:
|
||||
"""Rebuild numbered sheets, excluding deleted or changed copies."""
|
||||
crops = []
|
||||
for path in sorted(directory.glob("*.jpg"), key=natural_key):
|
||||
if stems is not None and path.stem not in stems:
|
||||
continue
|
||||
pdf = copies_dir / f"{path.stem}.pdf"
|
||||
try:
|
||||
if pdf.is_file() and read_json(path.with_suffix('.json'))['source_sha256'] == fingerprint(pdf):
|
||||
@@ -44,8 +47,8 @@ def build_grids(directory: Path, copies_dir: Path) -> list[dict]:
|
||||
grids_dir = directory / "Grids"
|
||||
grids = []
|
||||
with staged_files(grids_dir, remove=[p.name for p in grids_dir.glob('*') if p.is_file()]) as staging:
|
||||
for offset in range(0, len(crops), COLUMNS * ROWS):
|
||||
batch = crops[offset:offset + COLUMNS * ROWS]
|
||||
for offset in range(0, len(crops), columns * rows):
|
||||
batch = crops[offset:offset + columns * rows]
|
||||
images = []
|
||||
for path in batch:
|
||||
with Image.open(path) as image:
|
||||
@@ -54,11 +57,11 @@ def build_grids(directory: Path, copies_dir: Path) -> list[dict]:
|
||||
height = max(image.height for image in images)
|
||||
border, header = 6, 32
|
||||
cell_width, cell_height = width + border, height + header + border
|
||||
sheet = Image.new('RGB', (COLUMNS * cell_width + border, ROWS * cell_height + border), 'black')
|
||||
sheet = Image.new('RGB', (columns * cell_width + border, rows * cell_height + border), 'black')
|
||||
draw = ImageDraw.Draw(sheet)
|
||||
cells = []
|
||||
for index in range(COLUMNS * ROWS):
|
||||
row, column = divmod(index, COLUMNS)
|
||||
for index in range(columns * rows):
|
||||
row, column = divmod(index, columns)
|
||||
x, y = border + column * cell_width, border + row * cell_height
|
||||
draw.rectangle((x, y, x + width - 1, y + height + header - 1), fill='white')
|
||||
if index < len(images):
|
||||
@@ -70,7 +73,7 @@ def build_grids(directory: Path, copies_dir: Path) -> list[dict]:
|
||||
draw.text((x + 5, y + 5), 'EMPTY', fill='black')
|
||||
filename = f"grid_{len(grids) + 1:03d}.jpg"
|
||||
sheet.save(staging / filename, 'JPEG', quality=95)
|
||||
grids.append({'image': filename, 'columns': COLUMNS, 'rows': ROWS, 'cells': cells})
|
||||
grids.append({'image': filename, 'columns': columns, 'rows': rows, 'cells': cells})
|
||||
atomic_write_json(staging / 'manifest.json', grids)
|
||||
return grids
|
||||
|
||||
@@ -103,27 +106,31 @@ def name_bottom_height_fraction(workspace, stem: str) -> float | None:
|
||||
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
|
||||
for candidate in (directory, directory / 'Wide'):
|
||||
try:
|
||||
cached = read_json(candidate / 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(candidate / 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]:
|
||||
def _identify_names(workspace, groups, *, client, model: str, names_text: str, overwrite: bool,
|
||||
thinking_level: str | None = "low", wide: bool = False) -> dict[str, str]:
|
||||
"""Identify pending copies before label requests; safely fall back on any failure."""
|
||||
directory = workspace.cutleft_dir / 'Names'
|
||||
if wide:
|
||||
directory /= 'Wide'
|
||||
result = {stem: MISSING for stem in groups}
|
||||
if not groups or not directory.is_dir():
|
||||
return result # Older workspaces retain their existing label/name workflow.
|
||||
grids = build_grids(directory, workspace.copies_dir)
|
||||
grids = build_grids(directory, workspace.copies_dir, columns=1 if wide else COLUMNS,
|
||||
rows=4 if wide else ROWS, stems=set(groups) if wide else None)
|
||||
roster = {line.strip() for line in names_text.splitlines() if line.strip()} - {MISSING, 'Unknown', 'Continued'}
|
||||
for grid in grids:
|
||||
selected = {cell['copy']: cell for cell in grid['cells'] if cell['copy'] in groups}
|
||||
@@ -145,8 +152,9 @@ def identify_names(workspace, groups, *, client, model: str, names_text: str, ov
|
||||
if not pending:
|
||||
continue
|
||||
positions = '\n'.join(f"Cell {c['cell']}: row {c['row']}, column {c['column']}" for c in grid['cells'])
|
||||
prompt = f"""Identify student names from the top-left corners of exam first pages.
|
||||
The image is a grid with exactly 6 columns and 4 rows. Read rows left to right,
|
||||
region = 'full-width top quarters' if wide else 'top-left corners'
|
||||
prompt = f"""Identify student names from the {region} of exam first pages.
|
||||
The image is a grid with exactly {grid['columns']} columns and {grid['rows']} 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. Do not include class names such as MPSI 3 in the name.
|
||||
@@ -165,7 +173,7 @@ 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.
|
||||
Authorized names:
|
||||
{names_text}"""
|
||||
print(f"Identifying student names: {grid['image']} ({len(grid['cells'])} occupied cells, model={model}, thinking={thinking_level or 'default'}).", flush=True)
|
||||
print(f"Identifying student names{' (full-width retry)' if wide else ''}: {grid['image']} ({len(grid['cells'])} occupied cells, model={model}, thinking={thinking_level or 'default'}).", flush=True)
|
||||
try:
|
||||
response = client.models.generate_content(
|
||||
model=model,
|
||||
@@ -202,3 +210,49 @@ Authorized names:
|
||||
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.
|
||||
return result
|
||||
|
||||
|
||||
def identify_names(workspace, groups, *, client, model: str, names_text: str, overwrite: bool,
|
||||
thinking_level: str | None = "low") -> dict[str, str]:
|
||||
"""Try narrow grids, then collated full-width headers before label fallback."""
|
||||
options = dict(client=client, model=model, names_text=names_text,
|
||||
overwrite=overwrite, thinking_level=thinking_level)
|
||||
result = _identify_names(workspace, groups, **options)
|
||||
directory = workspace.cutleft_dir / 'Names'
|
||||
if not directory.is_dir():
|
||||
return result
|
||||
wide_directory = directory / 'Wide'
|
||||
pending = {}
|
||||
for stem, name in result.items():
|
||||
if name != MISSING:
|
||||
continue
|
||||
pdf = workspace.copies_dir / f'{stem}.pdf'
|
||||
try:
|
||||
source_hash = fingerprint(pdf)
|
||||
crop_path = wide_directory / f'{stem}.jpg'
|
||||
try:
|
||||
current = (crop_path.is_file() and
|
||||
read_json(crop_path.with_suffix('.json'))['source_sha256'] == source_hash)
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
current = False
|
||||
if not current:
|
||||
from pdf2image import convert_from_path
|
||||
page = convert_from_path(pdf, dpi=200, first_page=1, last_page=1,
|
||||
use_cropbox=False)[0]
|
||||
try:
|
||||
height = max(1, page.height // 4)
|
||||
with staged_files(wide_directory) as staging:
|
||||
page.crop((0, 0, page.width, height)).save(
|
||||
staging / crop_path.name, 'JPEG', quality=95)
|
||||
atomic_write_json(staging / f'{stem}.json', {
|
||||
'source_sha256': source_hash,
|
||||
'height_fraction': height / page.height,
|
||||
})
|
||||
finally:
|
||||
page.close()
|
||||
pending[stem] = groups[stem]
|
||||
except Exception as exc:
|
||||
print(f'Full-width name crop failed for {stem}: {type(exc).__name__}: {exc}; using label/name fallback.')
|
||||
if pending:
|
||||
result.update(_identify_names(workspace, pending, wide=True, **options))
|
||||
return result
|
||||
Reference in new issue
Block a user