Skip to content
2 changes: 1 addition & 1 deletion .claude/commands/penquify.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/sdk/variation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/variations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
</Accordion>

Expand Down
2 changes: 1 addition & 1 deletion penquify/generators/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions penquify/generators/verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",):
Expand Down
27 changes: 22 additions & 5 deletions penquify/models/variation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
),
}
32 changes: 32 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions tests/test_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down