From e561639987bdad39231b0a4e50d82703f3b769f0 Mon Sep 17 00:00:00 2001 From: Marcelo Arias Date: Fri, 17 Apr 2026 17:14:52 -0400 Subject: [PATCH 1/8] refactor: replace overexposed_patch bool with overexposure float field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change the overexposure model from a simple on/off toggle to a graduated float (0.0–1.0) for finer control over how washed out the generated document photo appears. --- penquify/models/variation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/penquify/models/variation.py b/penquify/models/variation.py index 75a0afd..fec06a6 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 From ef6f8d1bee738ee867c7f1d0f30a7d677f3375ca Mon Sep 17 00:00:00 2001 From: Marcelo Arias Date: Fri, 17 Apr 2026 17:15:04 -0400 Subject: [PATCH 2/8] feat: wire overexposure into Gemini prompt via to_prompt_json Include overexposure intensity and a human-readable description in the failure_modes section of the prompt JSON so Gemini knows how washed out to render the document photo. Also surface shadow_band which was previously missing from the prompt. --- penquify/models/variation.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/penquify/models/variation.py b/penquify/models/variation.py index fec06a6..aa50127 100644 --- a/penquify/models/variation.py +++ b/penquify/models/variation.py @@ -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, From f9b9d0d09bd4236ab20846b1225ca8e0d5bfb23f Mon Sep 17 00:00:00 2001 From: Marcelo Arias Date: Fri, 17 Apr 2026 17:15:14 -0400 Subject: [PATCH 3/8] feat: add 'overexposed' preset for dataset generation New preset combines 0.7 overexposure with strong center glare to simulate a document photographed under harsh direct lighting, a common failure mode in warehouse environments. --- penquify/models/variation.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/penquify/models/variation.py b/penquify/models/variation.py index aa50127..e4bee6c 100644 --- a/penquify/models/variation.py +++ b/penquify/models/variation.py @@ -186,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, + ), } From 8e44c3badaf1aa5764f03ea142d553e8719b3803 Mon Sep 17 00:00:00 2001 From: Marcelo Arias Date: Fri, 17 Apr 2026 17:15:29 -0400 Subject: [PATCH 4/8] feat: add overexposure to occlusion manifest reasoning When a field is illegible and the variation has overexposure > 0, the manifest now explains the failure as washed_out_by_overexposure with the intensity value so downstream consumers can distinguish intentional degradation from image gen errors. --- penquify/generators/verify.py | 2 ++ 1 file changed, 2 insertions(+) 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",): From d4e2627ccfa94e969a12ad05ca084c7879333b9c Mon Sep 17 00:00:00 2001 From: Marcelo Arias Date: Fri, 17 Apr 2026 17:15:37 -0400 Subject: [PATCH 5/8] feat: update text_to_variation schema with graduated overexposure Replace the boolean overexposed_patch field with the new float overexposure field in the Gemini schema prompt so natural language descriptions like 'slightly washed out' map to appropriate intensity values. --- penquify/generators/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) From 9915f3e706736758dfc7dc95e9f9179b7610d3c4 Mon Sep 17 00:00:00 2001 From: Marcelo Arias Date: Fri, 17 Apr 2026 17:15:59 -0400 Subject: [PATCH 6/8] docs: update all references from overexposed_patch to overexposure float Sync documentation and command reference with the new graduated overexposure field across variations.mdx, sdk/variation.mdx, and the penquify command template. --- .claude/commands/penquify.md | 2 +- docs/sdk/variation.mdx | 2 +- docs/variations.mdx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 | From 098fa374b5b4930dab460b5347253da6629c79e4 Mon Sep 17 00:00:00 2001 From: Marcelo Arias Date: Fri, 17 Apr 2026 17:16:12 -0400 Subject: [PATCH 7/8] test: add unit tests for overexposure model and serialization Cover the default (no overexposure), all three intensity tiers (light/moderate/severe), prompt JSON serialization, and the new overexposed preset. --- tests/test_models.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) 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 From f1a85c486dcf6e1394bd3199b96af058219cc1b9 Mon Sep 17 00:00:00 2001 From: Marcelo Arias Date: Fri, 17 Apr 2026 17:16:22 -0400 Subject: [PATCH 8/8] test: add occlusion manifest test for overexposure reasoning Verify that when a field is illegible and the variation has overexposure set, the manifest includes the correct reason string with the intensity value. --- tests/test_verify.py | 10 ++++++++++ 1 file changed, 10 insertions(+) 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},