Allow Gemini to report insufficient crop, and deal with it
This commit is contained in:
1 parent
247b124754
commit
ad7813ea8f
4 files changed
+215
-32
No files matched your search
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox
|
||||
@@ -19,6 +20,7 @@ from copienator import (
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
atomic_write_json,
|
||||
read_json,
|
||||
execute,
|
||||
target_parser,
|
||||
workspace_from_target,
|
||||
@@ -221,6 +223,42 @@ def save_results(
|
||||
print(f"Saved schema: {base_name}_schema.json")
|
||||
|
||||
|
||||
def regenerate_full_page_image(workspace: EvaluationWorkspace, image_path: Path) -> None:
|
||||
"""Replace one strip montage with the same pages at their full width."""
|
||||
match = re.fullmatch(r"(.+)_(\d+)", image_path.stem)
|
||||
if not match or image_path.parent != workspace.cutleft_dir:
|
||||
raise ValueError(f"Cannot map Cutleft image to PDF pages: {image_path}")
|
||||
stem, part = match.group(1), int(match.group(2)) - 1
|
||||
schema_path = workspace.cutleft_dir / f"{stem}_schema.json"
|
||||
schema = read_json(schema_path)
|
||||
counts = schema["columns_per_file"]
|
||||
if (not isinstance(counts, list) or not counts
|
||||
or any(type(count) is not int or count < 1 for count in counts)
|
||||
or not 0 <= part < len(counts)):
|
||||
raise ValueError("Invalid Cutleft page distribution")
|
||||
first = sum(counts[:part])
|
||||
last = first + counts[part]
|
||||
page_numbers = set(range(first + 1, last + 1))
|
||||
if page_numbers <= set(schema.get("full_width_pages", [])):
|
||||
raise ValueError("Cutleft image already contains full pages")
|
||||
pages = get_pdf_pages(workspace.copies_dir / f"{stem}.pdf")
|
||||
if len(pages) != sum(counts):
|
||||
raise ValueError("Cutleft schema does not match the PDF page count")
|
||||
combined = stitch_images(pages[first:last])
|
||||
if combined is None:
|
||||
raise ValueError("No pages available for full-page fallback")
|
||||
crops = {crop["page"]: crop for crop in schema.get("page_crops", [])}
|
||||
for index in range(first, last):
|
||||
width, height = pages[index].size
|
||||
crops[index + 1] = {"page": index + 1, "left_px": 0, "right_px": width,
|
||||
"width_px": width, "height_px": height}
|
||||
schema["page_crops"] = [crops[key] for key in sorted(crops)]
|
||||
schema["full_width_pages"] = sorted(set(schema.get("full_width_pages", [])) | page_numbers)
|
||||
with staged_files(workspace.cutleft_dir) as staging:
|
||||
combined.save(staging / image_path.name, "JPEG", quality=95)
|
||||
atomic_write_json(staging / schema_path.name, schema)
|
||||
|
||||
|
||||
class ImageReviewer:
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -12,7 +12,7 @@ from pathlib import Path
|
||||
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from copienator import configuration as config
|
||||
from copienator.gemini_config import fresh_seed, thinking_config
|
||||
@@ -149,10 +149,20 @@ class BoxItem(BaseModel):
|
||||
label: str = Field(description="The label associated with the specific box")
|
||||
|
||||
class AnnotationData(BaseModel):
|
||||
name: str = Field(description="The name identifier")
|
||||
list: typing.List[BoxItem] = Field( # noqa: UP006 - field name shadows list
|
||||
description="List of bounding box items"
|
||||
error: typing.Literal["insufficient_crop"] | None = Field(
|
||||
default=None, description="Report insufficient_crop when the crop hides information needed to identify labels"
|
||||
)
|
||||
error_reason: str | None = Field(default=None, description="Explanation of the crop error")
|
||||
name: str = Field(default="Unknown", description="The name identifier")
|
||||
list: typing.List[BoxItem] = Field( # noqa: UP006 - field name shadows list
|
||||
default_factory=list, description="List of bounding box items"
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_success_payload(self):
|
||||
if self.error is None and not {"name", "list"} <= self.model_fields_set:
|
||||
raise ValueError("Successful detection must include name and list")
|
||||
return self
|
||||
|
||||
|
||||
TEXT_CONTEXT_MAX_CHARS = 4000
|
||||
@@ -216,6 +226,7 @@ def generate_request(
|
||||
seed: int | None = None,
|
||||
*,
|
||||
retry: bool = False,
|
||||
full_pages: bool = False,
|
||||
):
|
||||
"""Generates request for Gemini with context."""
|
||||
|
||||
@@ -263,6 +274,18 @@ def generate_request(
|
||||
text = text.replace("##wrong_label_text_context##", "")
|
||||
|
||||
|
||||
text += """
|
||||
|
||||
If the cropped margins hide labels or exercise headings needed to identify
|
||||
questions reliably, return error="insufficient_crop", a short error_reason,
|
||||
and an empty list. Do not guess labels from incomplete cropped information.
|
||||
Missing answers or an empty page alone are not evidence of insufficient crop:
|
||||
students may skip questions. On success, return error=null.
|
||||
"""
|
||||
if full_pages:
|
||||
text = text.replace("image of the left columns", "image of the full pages")
|
||||
text += "\nThis image now contains full pages, in the same reading order. Identify the labels using this expanded view."
|
||||
|
||||
contents = [
|
||||
types.Content(
|
||||
role="user",
|
||||
@@ -399,6 +422,23 @@ def _existing_context(output_json: Path) -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
def image_contains_full_pages(workspace: EvaluationWorkspace, image_file: Path) -> bool:
|
||||
"""Recognize expanded images on subsequent detection runs."""
|
||||
match = re.fullmatch(r"(.+)_(\d+)", image_file.stem)
|
||||
if not match:
|
||||
return False
|
||||
try:
|
||||
schema = read_json(workspace.cutleft_dir / f"{match.group(1)}_schema.json")
|
||||
counts = schema["columns_per_file"]
|
||||
part = int(match.group(2)) - 1
|
||||
if not 0 <= part < len(counts) or any(type(n) is not int or n < 1 for n in counts):
|
||||
return False
|
||||
first = sum(counts[:part]) + 1
|
||||
return set(range(first, first + counts[part])) <= set(schema.get("full_width_pages", []))
|
||||
except (OSError, KeyError, TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def process_copy_group(
|
||||
workspace: EvaluationWorkspace,
|
||||
group_key: str,
|
||||
@@ -433,13 +473,14 @@ def process_copy_group(
|
||||
label_retry_count = 0
|
||||
wrong_labels: list[str] = []
|
||||
request_seed = None
|
||||
full_page_fallback = image_contains_full_pages(workspace, image_file)
|
||||
while attempt < MAX_ATTEMPTS:
|
||||
if progress:
|
||||
progress(group_key, f"{image_file.name}, attempt {attempt + 1}/{MAX_ATTEMPTS}", started)
|
||||
if attempt > 0:
|
||||
print(f"[{group_key}] Retrying {image_file.name} in {10 * attempt}s...")
|
||||
sleep(10 * attempt)
|
||||
level = "low" if attempt > 0 else config.THINKING_LEVEL_FOR_LABEL
|
||||
level = "low" if attempt > 0 or full_page_fallback else config.THINKING_LEVEL_FOR_LABEL
|
||||
request_seed = fresh_seed(request_seed)
|
||||
print(
|
||||
f"[{group_key}] {image_file.name}: attempt {attempt + 1}/{MAX_ATTEMPTS} "
|
||||
@@ -464,7 +505,8 @@ def process_copy_group(
|
||||
text_context,
|
||||
text_context_file,
|
||||
seed=request_seed,
|
||||
retry=attempt > 0,
|
||||
retry=attempt > 0 or full_page_fallback,
|
||||
full_pages=full_page_fallback,
|
||||
)
|
||||
response = client.models.generate_content(
|
||||
model=MODEL_ID,
|
||||
@@ -472,6 +514,25 @@ def process_copy_group(
|
||||
config=request_config,
|
||||
)
|
||||
annotation = AnnotationData.model_validate_json(response.text)
|
||||
if annotation.error == "insufficient_crop":
|
||||
print(f"[{group_key}] {image_file.name}: insufficient crop: "
|
||||
f"{annotation.error_reason or 'labels require a wider view'}")
|
||||
if full_page_fallback:
|
||||
raise IncompleteCopyError(files[image_index:])
|
||||
# Keep numbering and page grouping stable for downstream consumers.
|
||||
from copienator.commands.cutleft import regenerate_full_page_image
|
||||
try:
|
||||
regenerate_full_page_image(workspace, image_file)
|
||||
except Exception as exc:
|
||||
print(f"[{group_key}] Full-page regeneration failed: {exc}")
|
||||
raise IncompleteCopyError(files[image_index:]) from exc
|
||||
output_json.unlink(missing_ok=True)
|
||||
full_page_fallback = True
|
||||
attempt = 0
|
||||
label_retry_count = 0
|
||||
wrong_labels = []
|
||||
print(f"[{group_key}] Regenerated {image_file.name} with full pages; resending to Gemini.")
|
||||
continue
|
||||
unknown = [
|
||||
item.label
|
||||
for item in annotation.list
|
||||
@@ -512,11 +573,13 @@ def process_copy_group(
|
||||
annotation.list = sort_boxes_for_image(
|
||||
workspace, image_file, annotation.list
|
||||
)
|
||||
atomic_write_json(output_json, annotation.model_dump())
|
||||
atomic_write_json(output_json, annotation.model_dump(exclude={"error", "error_reason"}))
|
||||
accumulated_labels.extend(box.label for box in annotation.list)
|
||||
generated += 1
|
||||
print(f"[{group_key}] Saved {image_file.name} after {time.monotonic() - started:.0f}s.")
|
||||
break
|
||||
except IncompleteCopyError:
|
||||
raise
|
||||
except KeyboardInterrupt:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 - remote API retry boundary
|
||||
|
||||
Reference in new issue
Block a user