double-exposure / tests /test_scribble.py
Eddie Faillace
double-exposure app deploy snapshot 2026-07-21 (WP-24 calibration pass)
7dff04f
Raw
History Blame Contribute Delete
26.1 kB
"""Offline tests for app.scribble (scribble-guided exposure split).
No network anywhere; asserts compare independently computed quantities.
"""
import numpy as np
import pytest
from app.scribble import (
PALETTE,
parse_editor_scribbles,
propagate_w,
split_by_scribbles,
)
def _two_region_image(h=96, w=96):
"""Left half bright / right half dark with a crisp vertical edge at w//2."""
img = np.full((h, w), 0.25, np.float32)
img[:, : w // 2] = 0.85
return np.stack([img] * 3, axis=-1)
def test_propagate_w_saturates_per_region():
"""Seeds in each half -> w saturates toward 1 (A side) / 0 (B side), edge-aware."""
rgb = _two_region_image()
h, w = rgb.shape[:2]
seeds_a = np.zeros((h, w), bool)
seeds_b = np.zeros((h, w), bool)
seeds_a[h // 2 - 2 : h // 2 + 2, 8:20] = True # stroke in left (bright) half
seeds_b[h // 2 - 2 : h // 2 + 2, w - 20 : w - 8] = True # stroke in right half
field = propagate_w(rgb, seeds_a, seeds_b)
# Independent references: mean of the field over each region interior
left = field[:, : w // 2 - 4].mean()
right = field[:, w // 2 + 4 :].mean()
assert left > 0.8, f"left (A-seeded) region mean w = {left:.3f}, want > 0.8"
assert right < 0.2, f"right (B-seeded) region mean w = {right:.3f}, want < 0.2"
# Teeth: swapping the seeds must flip the field, not reproduce it
flipped = propagate_w(rgb, seeds_b, seeds_a)
assert flipped[:, : w // 2 - 4].mean() < 0.2
def test_split_by_scribbles_sum_exact_and_shapes():
"""H_A + H_B == H_total everywhere (by construction — verify independently)."""
rgb = _two_region_image()
h, w = rgb.shape[:2]
h_total = np.linspace(0.1, 2.0, h * w, dtype=np.float32).reshape(h, w)
cm = np.ones((h, w), np.uint8)
seeds_a = np.zeros((h, w), bool)
seeds_b = np.zeros((h, w), bool)
seeds_a[10:14, 10:30] = True
seeds_b[10:14, 60:80] = True
lay_a, lay_b, field = split_by_scribbles(rgb, h_total, cm, seeds_a, seeds_b)
assert lay_a.shape == rgb.shape and lay_b.shape == rgb.shape
# Reconstruct the split from the returned w and compare to h_total independently
recon = field * h_total + (1.0 - field) * h_total
assert np.abs(recon - h_total).max() < 1e-5
# The two layers must differ (teeth: not the same render twice)
assert float(np.abs(lay_a - lay_b).mean()) > 0.01
def test_parse_editor_scribbles_colors_and_empty():
"""Red strokes -> seeds_a, blue -> seeds_b; empty/None values -> all-False."""
h, w = 32, 40
layer = np.zeros((h, w, 4), np.uint8)
layer[4:8, 4:12, :3] = PALETTE["red"]
layer[4:8, 4:12, 3] = 255
layer[20:24, 20:30, :3] = PALETTE["blue"]
layer[20:24, 20:30, 3] = 255
value = {"background": None, "layers": [layer], "composite": None}
sa, sb = parse_editor_scribbles(value, (h, w))
# Independent references: the exact painted boxes
ref_a = np.zeros((h, w), bool); ref_a[4:8, 4:12] = True
ref_b = np.zeros((h, w), bool); ref_b[20:24, 20:30] = True
assert np.array_equal(sa, ref_a)
assert np.array_equal(sb, ref_b)
for empty in (None, {}, {"layers": []}, {"layers": [None]}):
sa, sb = parse_editor_scribbles(empty, (h, w))
assert not sa.any() and not sb.any()
def test_parse_editor_scribbles_resizes_layer():
"""A layer at a different resolution is mapped onto the target grid."""
h, w = 40, 40
layer = np.zeros((20, 20, 4), np.uint8) # half-res canvas
layer[2:6, 2:6, :3] = PALETTE["red"]
layer[2:6, 2:6, 3] = 255
sa, sb = parse_editor_scribbles({"layers": [layer]}, (h, w))
assert sa.any() and not sb.any()
ys, xs = np.where(sa)
# The painted box (rows/cols 2..6 of 20) must land in the upper-left quadrant
assert ys.max() < h // 2 and xs.max() < w // 2
def test_recover_scribble_path_uses_bundle_for_both_layers():
"""WP-19: with seeds, both calls are evidence-bundle prompts on the OBSERVED frame.
Each prompt recovers its own scene and names the OTHER as removable
contamination; the primary input stays the observed frame (references carry
the physics layers on the live path — the offline hook sees only the primary).
"""
from app.restore import recover
rgb = _two_region_image()
h, w = rgb.shape[:2]
h_total = np.full((h, w), 1.0, np.float32)
cm = np.ones((h, w), np.uint8)
seeds_h = np.zeros((h, w), bool); seeds_h[10:14, 5:25] = True
seeds_o = np.zeros((h, w), bool); seeds_o[10:14, 60:85] = True
prompts: list[str] = []
inputs: list[np.ndarray] = []
def hook(img, prompt):
prompts.append(prompt)
inputs.append(np.asarray(img))
return img
res = recover(rgb, h_total, cm, restore_fn=hook,
scene_headline="scene one", scene_other="scene two",
seeds_headline=seeds_h, seeds_other=seeds_o)
assert res.dominant is not None and res.second is not None
assert len(prompts) == 2
assert "Recover this photo: scene one" in prompts[0]
assert "scene two" in prompts[0] and "contamination" in prompts[0]
assert "Recover this photo: scene two" in prompts[1]
assert "scene one" in prompts[1]
# the primary edit input is the observed frame, not the muddy layer
assert all(np.array_equal(i, rgb) for i in inputs)
# dreamed_frac reports the contested-w fraction: recompute independently
from app.scribble import propagate_w
field = propagate_w(rgb, seeds_h, seeds_o)
expected = 100.0 * float(np.mean((field > 0.3) & (field < 0.7)))
assert res.dreamed_frac == pytest.approx(expected, abs=1.0)
def test_recover_empty_seeds_falls_back_to_default_path(monkeypatch):
"""All-False seed masks must not trigger the scribble path (separate mode leads).
Discriminated via a kontext-family model override: kontext keeps the legacy
single-image prompts, where only the DEFAULT path opens with _SEPARATE (the
scribble path would use _RESTORE for both layers).
"""
from app.restore import recover, _SEPARATE
monkeypatch.setenv("REPLICATE_RESTORE_MODEL", "black-forest-labs/flux-kontext-pro")
rgb = _two_region_image()
h, w = rgb.shape[:2]
prompts: list[str] = []
res = recover(rgb, np.ones((h, w), np.float32), np.ones((h, w), np.uint8),
restore_fn=lambda img, p: (prompts.append(p) or img),
scene_headline="x", scene_other="y",
seeds_headline=np.zeros((h, w), bool),
seeds_other=np.zeros((h, w), bool))
assert res.dominant is not None
assert prompts[0].startswith(_SEPARATE.split("{")[0])
def test_recover_context_folded_into_prompts():
"""The whole-photo context string reaches both layer prompts."""
from app.restore import recover
rgb = _two_region_image()
h, w = rgb.shape[:2]
prompts: list[str] = []
recover(rgb, np.ones((h, w), np.float32), np.ones((h, w), np.uint8),
restore_fn=lambda img, p: (prompts.append(p) or img),
scene_headline="a pool", scene_other="a frame",
context="honeymoon roll, backyard over France")
assert len(prompts) == 2
assert all("honeymoon roll, backyard over France" in p for p in prompts)
def test_parse_tagged_scribbles_assignments_and_hints():
"""Tagged colors route to their assigned scenes and yield located hints; an
override (blue -> Scene 1) is honored over the default."""
from app.scribble import parse_tagged_scribbles, PALETTE
h, w = 60, 90
layer = np.zeros((h, w, 4), np.uint8)
layer[5:12, 5:20, :3] = PALETTE["red"] # top-left red stroke
layer[5:12, 5:20, 3] = 255
layer[45:52, 60:80, :3] = PALETTE["blue"] # bottom-right blue stroke
layer[45:52, 60:80, 3] = 255
value = {"background": None, "layers": [layer], "composite": None}
assignments = {
"red": {"scene": "Scene 1", "tag": "pool"},
"blue": {"scene": "Scene 1", "tag": "frame"}, # override: blue joins scene 1
}
s1, s2, h1, h2 = parse_tagged_scribbles(value, (h, w), assignments)
assert s1[8, 10] and s1[48, 70] # both strokes landed in scene 1
assert not s2.any()
assert "pool" in h1 and "frame" in h1 and h2 == ""
assert "top left" in h1 and "bottom right" in h1 # located hints
# Defaults (no assignments): red->1, blue->2, no hints without tags
s1d, s2d, h1d, h2d = parse_tagged_scribbles(value, (h, w), None)
assert s1d[8, 10] and not s1d[48, 70]
assert s2d[48, 70]
assert h1d == "" and h2d == ""
def test_region_phrase_thirds():
from app.scribble import region_phrase
m = np.zeros((30, 30), bool)
m[2:5, 2:5] = True
assert region_phrase(m) == "top left"
m2 = np.zeros((30, 30), bool)
m2[13:17, 13:17] = True
assert region_phrase(m2) == "center"
assert region_phrase(np.zeros((30, 30), bool)) == ""
def test_recover_hints_reach_prompts():
"""Tagged-stroke hints are folded into the layer prompts on the scribble path."""
from app.restore import recover
from tests.test_restore import _toy_scan
pre = _toy_scan()
h, w = pre.rgb.shape[:2]
sa = np.zeros((h, w), bool); sa[10:20, 10:30] = True
sb = np.zeros((h, w), bool); sb[60:70, 60:80] = True
prompts: list[str] = []
recover(pre.rgb, pre.h_total, pre.confidence_mask,
restore_fn=lambda rgb, p: prompts.append(p) or rgb,
seeds_headline=sa, seeds_other=sb,
hints_headline="the pool (top left)", hints_other="the frame (bottom right)")
assert len(prompts) == 2
assert "the pool (top left)" in prompts[0]
assert "the frame (bottom right)" in prompts[1]
def test_recover_best_pair_used_as_anchor():
"""A supplied best separation pair becomes the physics anchor (not the p45 split)."""
from app.restore import recover
from tests.test_restore import _toy_scan
pre = _toy_scan()
# Distinctive dominant layer: bright constant; other: dark constant
bright = np.full_like(pre.rgb, 0.9)
dark = np.full_like(pre.rgb, 0.1)
res = recover(pre.rgb, pre.h_total, pre.confidence_mask,
restore_fn=lambda rgb, p: rgb, best_pair=(bright, dark))
assert np.allclose(res.anchor_rgb, bright) # picked the larger-share member
res2 = recover(pre.rgb, pre.h_total, pre.confidence_mask,
restore_fn=lambda rgb, p: rgb, best_pair=(dark, bright))
assert np.allclose(res2.anchor_rgb, bright) # order-independent
def test_hard_attribution_of_painted_pixels():
"""Seed pixels are attributed EXACTLY (w=1/0) in the rendered split, while
unpainted pixels keep the soft clamp (no full erasure)."""
from app.scribble import split_by_scribbles
from tests.test_restore import _toy_scan
pre = _toy_scan()
h, w = pre.rgb.shape[:2]
sa = np.zeros((h, w), bool); sa[10:14, 10:14] = True
sb = np.zeros((h, w), bool); sb[60:64, 60:64] = True
lay_a, lay_b, _wf = split_by_scribbles(pre.rgb, pre.h_total, pre.confidence_mask, sa, sb)
# Recover the implied w_r from the H split identity: h_a = w_r * h_total.
# Independent check at seed pixels: layer A owns ALL of H at sa, none at sb.
# Render is monotone in h, so compare via the pre-render arrays' proxy:
# rebuild h arrays through the same identity the function guarantees.
from densitometry import phi_display
# At sa, layer B's render must carry ~zero luminance relative to layer A's;
# at sb the reverse. Compare within the same location across the two layers.
la, lb = phi_display(lay_a), phi_display(lay_b)
assert float(lb[sa].mean()) < 0.05 * max(float(la[sa].mean()), 1e-6) or float(lb[sa].mean()) < 1e-3
assert float(la[sb].mean()) < 0.05 * max(float(lb[sb].mean()), 1e-6) or float(la[sb].mean()) < 1e-3
# ---------------------------------------------------------------------------
# WP-18 D1 — scribble correctness
# ---------------------------------------------------------------------------
def test_both_scene_overlap_pixels_stay_contested():
"""D1a: a pixel painted with BOTH scenes' colors keeps the soft value in both
renders and counts toward the contested fraction; exclusive pixels still pin."""
from app.scribble import split_by_scribbles, propagate_w
from tests.test_restore import _toy_scan
pre = _toy_scan()
h, w = pre.rgb.shape[:2]
sa = np.zeros((h, w), bool); sa[10:20, 10:20] = True # A-only
sb = np.zeros((h, w), bool); sb[60:70, 60:70] = True # B-only
sa[40:50, 40:50] = True; sb[40:50, 40:50] = True # painted BOTH
lay_a, lay_b, wf = split_by_scribbles(pre.rgb, pre.h_total, pre.confidence_mask, sa, sb)
# Recompute the render weights independently to check the pin rule
contested = sa & sb
w_r = np.clip(wf, 0.12, 0.88)
w_r[sa & ~contested] = 1.0
w_r[sb & ~contested] = 0.0
h_a = w_r * pre.h_total
# A-only pixels: layer A owns the full exposure; B-only: none of it
assert np.allclose(h_a[15, 15], pre.h_total[15, 15])
assert np.allclose(h_a[65, 65], 0.0)
# Contested pixels: NEITHER side owns them fully in the render weights
assert 0.12 - 1e-6 <= float(w_r[45, 45]) <= 0.88 + 1e-6
assert not np.isclose(float(w_r[45, 45]), 1.0) and not np.isclose(float(w_r[45, 45]), 0.0)
# propagate_w does not hard-pin contested pixels to 0/1 either
assert 0.0 < float(wf[45, 45]) < 1.0
def test_marks_unreadable_distinguishes_blended_from_empty():
"""D1b: blended off-axis paint => unreadable warning; clean stroke => readable;
nothing painted => not flagged."""
from app.scribble import marks_unreadable, parse_tagged_scribbles, PALETTE
h, w = 40, 40
# NOTE: an equal red+blue blend (128,0,128) is exactly magenta's hue and is
# legitimately read as a magenta stroke — the palette's known residual risk.
# A muddy multi-color blend (gray-ish) is off EVERY palette axis:
blended = np.zeros((h, w, 4), np.uint8)
blended[5:30, 5:30, :3] = (120, 120, 120)
blended[5:30, 5:30, 3] = 255
v_blend = {"background": None, "layers": [blended], "composite": None}
s1, s2, _h1, _h2 = parse_tagged_scribbles(v_blend, (h, w))
assert not s1.any() and not s2.any() # gate rejected everything
assert marks_unreadable(v_blend, (h, w)) # ...and we can SAY so
clean = np.zeros((h, w, 4), np.uint8)
clean[5:15, 5:15, :3] = PALETTE["red"]
clean[5:15, 5:15, 3] = 255
v_clean = {"background": None, "layers": [clean], "composite": None}
assert not marks_unreadable(v_clean, (h, w))
assert not marks_unreadable({"background": None, "layers": [], "composite": None}, (h, w))
assert not marks_unreadable(None, (h, w))
def test_trim_bbox_crops_stroke_layers_registered():
"""D1c: with a trim bbox, a stroke at a known untrimmed landmark lands on the
same landmark in trimmed coordinates (compared against a hand-computed crop)."""
from app.scribble import parse_tagged_scribbles, PALETTE
# Untrimmed canvas 100x100; working image = central crop [10:90, 20:80] -> 80x60
trim = (0.10, 0.90, 0.20, 0.80)
th, tw = 80, 60
layer = np.zeros((100, 100, 4), np.uint8)
layer[50:54, 50:54, :3] = PALETTE["red"] # landmark at untrimmed (50..54)^2
layer[50:54, 50:54, 3] = 255
v = {"background": None, "layers": [layer], "composite": None}
s1, _s2, _h1, _h2 = parse_tagged_scribbles(v, (th, tw), None, trim_bbox_frac=trim)
# Hand-computed: crop rows 10:90 cols 20:80 puts the stroke at rows 40:44, cols 30:34
assert s1[42, 32], "stroke missing at the hand-computed trimmed location"
assert not s1[42, 50], "stroke leaked to an untrimmed-coordinate location"
# Teeth: WITHOUT the bbox the same stroke lands misregistered (squashed resize)
s1_no, _s2n, _h1n, _h2n = parse_tagged_scribbles(v, (th, tw), None)
assert not s1_no[42, 32] or s1_no.sum() != s1.sum()
# ---------------------------------------------------------------------------
# WP-19 — annotated-copy markup + per-scene legends
# ---------------------------------------------------------------------------
def test_render_markup_touches_only_masked_pixels():
from app.scribble import render_markup, PALETTE
rgb = np.full((20, 20, 3), 0.5, np.float32)
m = np.zeros((20, 20), bool); m[5:8, 5:8] = True
out = render_markup(rgb, {"red": m}, alpha=0.5)
assert np.array_equal(out[~m], rgb[~m])
expect = 0.5 * 0.5 + 0.5 * np.asarray(PALETTE["red"], np.float32) / 255.0
assert np.allclose(out[m], expect, atol=1e-5)
def test_markup_and_legends_perspective_flip():
"""The same strokes read 'this photo' from their scene and 'the other photo'
from the opposite scene; nothing painted -> no markup image."""
from app.scribble import markup_and_legends
h, w = 24, 24
rgb = np.full((h, w, 3), 0.4, np.float32)
layer = np.zeros((h, w, 4), np.uint8)
layer[3:7, 3:12] = (255, 0, 0, 255) # red -> scene 1
layer[15:19, 12:20] = (0, 0, 255, 255) # blue -> scene 2
val = {"layers": [layer]}
asg = {"red": {"scene": "1", "tag": "paintings"},
"blue": {"scene": "2", "tag": "women"}}
mk, l1, l2 = markup_and_legends(val, (h, w), asg, rgb=rgb)
assert mk is not None and mk.shape == rgb.shape
assert "red strokes mark 'paintings' — belongs to this photo" in l1
assert "blue strokes mark 'women' — belongs to the other photo" in l1
assert "red strokes mark 'paintings' — belongs to the other photo" in l2
assert "blue strokes mark 'women' — belongs to this photo" in l2
mk_none, l1e, l2e = markup_and_legends({"layers": []}, (h, w), asg, rgb=rgb)
assert mk_none is None and l1e == "" and l2e == ""
# ---------------------------------------------------------------------------
# WP-22 — tapped-object guidance (objects_guidance) and the tap UI handlers
# ---------------------------------------------------------------------------
def _toy_objects(h=40, w=60):
m1 = np.zeros((h, w), bool); m1[5:12, 5:15] = True
m2 = np.zeros((h, w), bool); m2[5:12, 20:30] = True
m3 = np.zeros((h, w), bool); m3[25:35, 40:55] = True
return [
{"mask": m1, "tag": "painting", "scene": "2"},
{"mask": m2, "tag": "painting", "scene": "2"},
{"mask": m3, "tag": "pool", "scene": "1"},
]
def test_objects_guidance_seeds_counts_and_legends():
from app.scribble import objects_guidance
objs = _toy_objects()
s1, s2, h1, h2, mk, l1, l2 = objects_guidance(
objs, (40, 60), base_rgb=np.full((40, 60, 3), 0.5, np.float32)
)
assert s1.sum() == objs[2]["mask"].sum()
assert s2.sum() == (objs[0]["mask"] | objs[1]["mask"]).sum()
assert "2× painting" in h2 and "the pool" in h1
# Legends: same objects, opposite perspectives, count included
assert "this photo: 2× painting" in l2 and "other photo: 2× painting" in l1
assert "green shapes" in l1 and "cyan shapes" in l2
# Markup: fills only where masks are; elsewhere untouched
assert mk is not None
untouched = ~(s1 | s2)
assert np.allclose(mk[untouched], 0.5, atol=1e-5)
assert not np.allclose(mk[s1], 0.5, atol=1e-2)
def test_objects_guidance_empty_and_geometry():
from app.scribble import objects_guidance
s1, s2, h1, h2, mk, l1, l2 = objects_guidance(
None, (40, 60), base_rgb=np.zeros((40, 60, 3), np.float32)
)
assert not s1.any() and not s2.any() and mk is None and l1 == "" and h2 == ""
# A mask in click geometry (80x120) lands registered in target (40x60)
big = np.zeros((80, 120), bool); big[10:24, 10:30] = True
s1, _s2, *_ = objects_guidance(
[{"mask": big, "tag": "t", "scene": "1"}], (40, 60)
)
assert s1.any() and abs(s1.mean() - big.mean()) < 0.02
def test_sam_ui_handlers_flow(monkeypatch):
"""Tap -> refine -> add -> summary, with a stubbed segmenter."""
import app.main as m
import app.segment as seg
calls = {"n": 0}
def fake_point_mask(rgb, points):
calls["n"] += 1
h, w = rgb.shape[:2]
mask = np.zeros((h, w), bool)
x, y = int(points[-1][0]), int(points[-1][1])
mask[max(0, y - 3):y + 3, max(0, x - 3):x + 3] = True
return mask, 0.9
monkeypatch.setattr(seg, "point_mask", fake_point_mask)
frame = np.full((50, 70, 3), 128, np.uint8)
class Evt: # gr.SelectData stand-in
index = [30, 20]
disp, pts, pending, note = m.sam_click(frame, None, None, "Magic select (auto edges)", Evt())
assert len(pts) == 1 and pending.any() and "confidence 0.90" in note
# Commit with a tag
disp, pts, pending, objects, summary, note, tag_out = m.sam_add(
frame, pts, pending, None, "painting", "Scene 2"
)
assert len(objects) == 1 and objects[0]["scene"] == "2" and tag_out == ""
assert "painting" in summary
# Undo with no pending points clears cleanly
disp, pts, pending, note = m.sam_undo(frame, [], objects, "Magic select (auto edges)")
assert pts == [] and pending is None
# Clear drops everything
disp, pts, pending, objects, summary, note = m.sam_clear(frame)
assert objects == [] and summary == ""
def test_restore_handler_merges_tapped_objects(monkeypatch):
"""sam_objects reach recover as seeds + legend even with no brush strokes."""
import app.main as m
import app.restore as r
got = {}
def fake_recover(observed_rgb, h_total=None, confidence_mask=None, **kw):
got.update(kw)
return r.RecoverResult(notes=kw.get("notes", []))
monkeypatch.setattr(r, "recover", fake_recover)
from PIL import Image as PILImage
rng = np.random.default_rng(3)
upload = PILImage.fromarray((rng.random((80, 120, 3)) * 255).astype(np.uint8))
mask = np.zeros((80, 120), bool); mask[10:30, 10:40] = True
objs = [{"mask": mask, "tag": "painting", "scene": "1"}]
m.restore_best_scene(
None, "Scene 1", "a", "b", "", None,
"", "Scene 1", "", "Scene 1", "", "Scene 2", "", "Scene 2",
upload, "Generic", "Auto", "auto-exposed", False,
best_of_3=False, ref_photo_1=None, ref_photo_2=None, sam_objects=objs,
)
assert got["seeds_headline"] is not None and got["seeds_headline"].any()
assert "tapped objects" in (got["legend_headline"] or "")
assert got["markup_rgb"] is not None
# ---------------------------------------------------------------------------
# WP-23 — dots-to-fill shapes and the targeted repair loop
# ---------------------------------------------------------------------------
def test_fill_mode_dots_close_a_shape():
import app.main as m
frame = np.full((60, 90, 3), 100, np.uint8)
class Evt:
def __init__(self, xy): self.index = xy
mode = "Fill shape from dots"
disp, pts, mask, note = m.sam_click(frame, None, None, mode, Evt([10, 10]))
assert mask is None and "add 2 more" in note
disp, pts, mask, note = m.sam_click(frame, pts, None, mode, Evt([50, 10]))
assert mask is None
disp, pts, mask, note = m.sam_click(frame, pts, None, mode, Evt([30, 40]))
assert mask is not None and mask.any() and "Shape filled" in note
# The filled triangle centroid is inside; far corner is out
assert mask[20, 30] and not mask[55, 85]
# Undo reopens the shape
disp, pts, mask, note = m.sam_undo(frame, pts, None, mode)
assert len(pts) == 2 and mask is None
def test_repair_region_outside_pixels_untouched():
from app.restore import repair_region
base = np.full((80, 80, 3), 0.30, np.float32)
mask = np.zeros((80, 80), bool)
mask[20:40, 20:40] = True
prompts = {}
def hook(rgb, prompt):
prompts["p"] = prompt
return np.full_like(rgb, 0.90)
notes: list[str] = []
out, meta = repair_region(
base, mask, "a second woman in the chair",
observed_rgb=np.zeros((80, 80, 3), np.float32),
restore_fn=hook, notes=notes,
)
assert out is not None
assert "a second woman in the chair" in prompts["p"]
assert "Image 3 is the original damaged" in prompts["p"]
# Far from the region: EXACTLY the base (hard composite, feather decayed)
assert np.allclose(out[:5, :5], 0.30, atol=1e-4)
assert np.allclose(out[70:, 70:], 0.30, atol=1e-4)
# Region core took the new content
assert np.allclose(out[29:31, 29:31], 0.90, atol=0.02)
assert any("pixel-identical" in n for n in notes)
def test_repair_region_refuses_without_shape():
from app.restore import repair_region
base = np.zeros((40, 40, 3), np.float32)
notes: list[str] = []
out, _ = repair_region(base, np.zeros((40, 40), bool), "x",
restore_fn=lambda r, p: r, notes=notes)
assert out is None and any("3+ dots" in n for n in notes)
def test_repair_apply_handler(monkeypatch):
import app.main as m
import app.restore as r
from PIL import Image as PILImage
def fake_repair(base, mask, instruction, observed=None, notes=None, **kw):
(notes or []).append("Repaired the marked region (…pixel-identical…).")
out = base.copy(); out[np.asarray(mask, bool)] = 0.9
return out, {"api_contacted": True}
monkeypatch.setattr(r, "repair_region", fake_repair)
main = PILImage.fromarray(np.full((50, 60, 3), 80, np.uint8))
second = PILImage.fromarray(np.full((50, 60, 3), 40, np.uint8))
pts = [[5, 5], [40, 5], [20, 30]]
pil, sec_u, status, note = m.repair_apply(
"Main scene", main, second, pts, "fix it", None, "old status")
assert pil is not None and "🩹 Repaired the main scene" in status and "Done" in note
# WP-24: the second scene is repairable too — only ITS image updates
main_u, sec_pil, status2, note2 = m.repair_apply(
"Second scene", main, second, pts, "fix it", None, "")
assert sec_pil is not None and "🩹 Repaired the second scene" in status2
# Guards
_p, _s, _st, note3 = m.repair_apply("Main scene", main, second, [[1, 1]], "x", None, "")
assert "3+ dots" in note3