diff --git a/.claude/commands/penquify.md b/.claude/commands/penquify.md index fbe2106..ac544dc 100644 --- a/.claude/commands/penquify.md +++ b/.claude/commands/penquify.md @@ -136,7 +136,7 @@ PhotoVariation( # Failure modes cropped_header=True, # bool missing_area="top 15%", # "" / any description - overexposed_patch=True, # bool + overexposure=0.7, # float 0.0–1.0 shadow_band=True, # bool # Multi-page diff --git a/docs/sdk/variation.mdx b/docs/sdk/variation.mdx index 2f50b8f..1650813 100644 --- a/docs/sdk/variation.mdx +++ b/docs/sdk/variation.mdx @@ -73,7 +73,7 @@ class PhotoVariation: # Failure modes cropped_header: bool = False missing_area: str = "" - overexposed_patch: bool = False + overexposure: float = 0.0 shadow_band: bool = False # Multi-page diff --git a/docs/variations.mdx b/docs/variations.mdx index 9fec557..8c8e5c5 100644 --- a/docs/variations.mdx +++ b/docs/variations.mdx @@ -82,7 +82,7 @@ When generating a photo, the variation is serialized to JSON and sent to Gemini |-------|------|---------|-------------| | `cropped_header` | `bool` | `False` | Header cut off by framing | | `missing_area` | `str` | `""` | Missing area description (e.g. `"top 10-15%"`) | - | `overexposed_patch` | `bool` | `False` | Overexposed area | + | `overexposure` | `float` | `0.0` | Global overexposure intensity (0.0–1.0) | | `shadow_band` | `bool` | `False` | Dark shadow band across document | diff --git a/penquify/generators/config.py b/penquify/generators/config.py index 0cb0f23..e3c6eb6 100644 --- a/penquify/generators/config.py +++ b/penquify/generators/config.py @@ -41,7 +41,7 @@ - torn_edge: true | false - cropped_header: true | false - missing_area: "" | "top 10-15%" | "left edge" | "bottom 5%" -- overexposed_patch: true | false +- overexposure: 0.0-1.0 (float, 0=none, 0.3=light wash, 0.6=moderate bleaching, 1.0=fully washed out) - shadow_band: true | false - stapled: true | false - stacked_sheets_behind: 0-5 (int) diff --git a/penquify/generators/verify.py b/penquify/generators/verify.py index 1a6622d..1253096 100644 --- a/penquify/generators/verify.py +++ b/penquify/generators/verify.py @@ -258,6 +258,8 @@ def build_occlusion_manifest( elif status == "illegible": if variation.motion_blur: reasons.append(f"blurred_by_motion({variation.blur_direction or 'general'})") + if variation.overexposure > 0: + reasons.append(f"washed_out_by_overexposure(intensity={variation.overexposure:.1f})") if variation.jpeg_compression in ("moderate", "heavy"): reasons.append(f"degraded_by_compression({variation.jpeg_compression})") if variation.glare in ("strong",): diff --git a/penquify/models/variation.py b/penquify/models/variation.py index 75a0afd..e4bee6c 100644 --- a/penquify/models/variation.py +++ b/penquify/models/variation.py @@ -64,7 +64,7 @@ class PhotoVariation: # Failure modes (for dataset edge cases) cropped_header: bool = False missing_area: str = "" # "top 10-15%", "left edge" - overexposed_patch: bool = False + overexposure: float = 0.0 # 0.0 (none) to 1.0 (fully washed out) shadow_band: bool = False # Multi-page @@ -109,11 +109,21 @@ def to_prompt_json(self) -> dict: } if self.stain: d["damage"] = {"stain": self.stain.__dict__} + failure_modes = {} if self.cropped_header or self.missing_area: - d["failure_modes"] = { - "cropped_header": self.cropped_header, - "missing_area": self.missing_area, - } + failure_modes["cropped_header"] = self.cropped_header + failure_modes["missing_area"] = self.missing_area + if self.overexposure > 0: + failure_modes["overexposure"] = self.overexposure + failure_modes["overexposure_description"] = ( + "uniform global overexposure across entire document — " + f"intensity {self.overexposure:.1f} " + f"({'light wash' if self.overexposure <= 0.3 else 'moderate bleaching' if self.overexposure <= 0.6 else 'severely washed out, text barely visible'})" + ) + if self.shadow_band: + failure_modes["shadow_band"] = True + if failure_modes: + d["failure_modes"] = failure_modes if self.stapled or self.stacked_sheets_behind: d["multi_page"] = { "stapled": self.stapled, @@ -176,4 +186,11 @@ def to_prompt_json(self) -> dict: stacked_sheets_behind=2, folds="dog_ear", ), + "overexposed": PhotoVariation( + name="overexposed", + overexposure=0.7, + glare="strong", + glare_location="center", + uneven_lighting=True, + ), } diff --git a/tests/test_models.py b/tests/test_models.py index 980f0bf..fd0b9ea 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -51,6 +51,38 @@ def test_presets_exist(): assert "coffee_stain" in PRESETS +def test_overexposure_default(): + v = PhotoVariation(name="clean") + assert v.overexposure == 0.0 + j = v.to_prompt_json() + assert "failure_modes" not in j or "overexposure" not in j.get("failure_modes", {}) + + +def test_overexposure_in_prompt_json(): + v = PhotoVariation(name="washed", overexposure=0.7) + j = v.to_prompt_json() + assert "failure_modes" in j + assert j["failure_modes"]["overexposure"] == 0.7 + assert "severely washed out" in j["failure_modes"]["overexposure_description"] + + +def test_overexposure_light(): + v = PhotoVariation(name="light_wash", overexposure=0.2) + j = v.to_prompt_json() + assert "light wash" in j["failure_modes"]["overexposure_description"] + + +def test_overexposure_moderate(): + v = PhotoVariation(name="mod_wash", overexposure=0.5) + j = v.to_prompt_json() + assert "moderate bleaching" in j["failure_modes"]["overexposure_description"] + + +def test_overexposed_preset_exists(): + assert "overexposed" in PRESETS + assert PRESETS["overexposed"].overexposure == 0.7 + + def test_cameras_library(): assert len(CAMERAS) >= 20 assert "galaxy_s8" in CAMERAS diff --git a/tests/test_verify.py b/tests/test_verify.py index 68101df..847e21b 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -96,6 +96,16 @@ def test_occlusion_manifest_stain(): assert "coffee_stain" in str(manifest["item_2_qty"]["reasons"]) +def test_occlusion_manifest_overexposure(): + verification = {"fields": { + "item_1_qty": {"status": "illegible", "extracted_value": None, "source_value": "50", "confidence": 0}, + }} + variation = PhotoVariation(overexposure=0.8) + manifest = build_occlusion_manifest(verification, variation) + assert "overexposure" in str(manifest["item_1_qty"]["reasons"]) + assert "0.8" in str(manifest["item_1_qty"]["reasons"]) + + def test_occlusion_manifest_blur(): verification = {"fields": { "total": {"status": "illegible", "extracted_value": None, "source_value": "1077164", "confidence": 0},