From d0596fdc11cb31b74aeccc4cc26353e5059bfa79 Mon Sep 17 00:00:00 2001 From: Kiko Date: Wed, 11 Nov 2026 21:11:34 +0000 Subject: [PATCH 1/4] raven-image: --margin pins the border band during diffusion; measures, repaints and fails loudly (exit 65); --alpha keys the backdrop Core nodes only (EmptyImage > VAEEncode > SetLatentNoiseMask, mask by SolidMask+MaskComposite): the sampler re-imposes the flat band at every step. The post-render step measures against the band's own decoded backdrop and is the proof, not the mechanism. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- client/README.md | 46 ++++++++++- client/raven-image | 165 +++++++++++++++++++++++++++++++++++++- tests/test-raven-image.py | 82 ++++++++++++++++++- 4 files changed, 286 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index e3237f9..1201ca9 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ The swarm's image service: Minds on Yggdrasil make images on Raven's GPU (ComfyU | `comfy-nodes/` | Custom ComfyUI nodes (`*.py`) | Raven `E:\ComfyUI\...\ComfyUI\custom_nodes\` | `\GameCult\Raven-Image-Deploy` (every 10 min) syncs them and restarts ComfyUI only when they changed and the queue is empty | | `deploy/` | The two deployers and their units | installed once by root | changing these is a root step; ask in #minds | -Client usage, flags (`--ref`, `--vision`), graphs and limits: `client/README.md`. +Client usage, flags (`--ref`, `--vision`, `--margin`), graphs and limits: `client/README.md`. Raven is Deru's gaming PC. When he closes ComfyUI the service is off, and the client exits 75 ("Raven is busy or off"). Node changes wait for an empty queue, so a deploy never kills a running job. Concept art only until the FLUX.3 Klein diff --git a/client/README.md b/client/README.md index eeaa7f3..92c19d1 100644 --- a/client/README.md +++ b/client/README.md @@ -5,8 +5,8 @@ file, standard library only, plus the ComfyUI API graph it submits. **Value:** a Mind can make concept art with one command and no ComfyUI knowledge. **Entry points:** `raven-image` (the client), `graphs/flux3-klein.json` (the graph). -**Live:** Klein t2i with the prompt upsampler. **Also live:** `--ref` reference-conditioned edit (below). **Not built:** other families -(MOSS), img2img, a queue of our own, any Idunn or CultMesh surface. +**Live:** Klein t2i with the prompt upsampler. **Also live:** `--ref` reference-conditioned edit and `--margin` clear edges (below). **Not built:** other families +(MOSS), img2img, a custom sampler node (not needed: see `--margin`), a queue of our own, any Idunn or CultMesh surface. ``` raven-image "concept art: a lighthouse on a basalt cliff at dusk" -o out.png @@ -39,7 +39,47 @@ Test 2026-11-11, Kiko's r1a 12-cell pose sheet as the only reference, same seed: but also pasted three of the sheet's cells into the frame; use one cropped cell as the reference. Images and verdict: `/opt/commons/handoff/raven-ref-edit-20261111/`. -Offline test (fake ComfyUI on 127.0.0.1): `python3 scripts/test-raven-image.py` in gamecult-ops. +## `--margin PCT`: clear edges by construction + +``` +raven-image "a goldfish mascot on a plain pale cream background" --no-enhance --margin 6 --bg fcf7ee -o fish.png +raven-image ... --margin 6 --bg 12fa5f --alpha -o sticker.png # RGBA, backdrop keyed out +``` + +The border band (PCT of each axis, rounded up to the 16 px latent grid, 6% of 1024 is 64 px) is held flat during +diffusion, not cleaned afterwards. The client replaces the empty latent with a `--bg`-coloured image run through the +graph's own VAE (`EmptyImage` > `VAEEncode`) and puts a `SetLatentNoiseMask` on it that is 0 on the band and 1 inside +(`SolidMask` + `MaskComposite`, nodes 30-35; node 6 goes). ComfyUI's sampler re-imposes the masked-out latent at every +step at that step's noise level (RePaint-style), so the model composes the interior around a frame it cannot change. +Core nodes only; no custom node, no Raven deploy. Denoise stays 1. Works on `flux3-klein` and `flux3-klein-edit` +(with or without `--vision`). Put the same backdrop in the prompt ("plain pale cream background"); the pin sets the band, +the prompt sets what the interior tries to match. + +After the render the client measures the band against its own backdrop (the per-channel median of the band: the VAE +shifts a saturated `--bg` by tens of levels, `#12fa5f` came back `#1fef6d`; cream stayed within 2), repaints the whole +band with that colour, and prints it with the per-side count of pixels that were content: +`margin 6.0%: backdrop #fef9ec, cleared 802 of 245760 band px (0.33%; left 646 top 0 right 156 bottom 0)`. +If more than `--max-clip` percent of the band (default 0.5) was content the file is still written, already cleared, and +the client exits **65**: the subject ran into the frame, so it is cut flat at the seam. Re-seed or ask for more room. +`-n` renders all images, then exits 65 if any was dirty. `--alpha` floods from the band through backdrop-coloured pixels +and makes them transparent (hard key; pure Python, about 1 s). The output is RGBA only with `--alpha`. + +Measured 2026-11-11 (1024x1024, 6%, one render each, same seed and ref as the unpinned image): + +| case | unpinned: band that was content | pinned | +|---|---|---| +| Aqua's facelift seed 2203 (`--ref --vision`, seaweed and bubbles in the corners) | 33% | 0.33% (corners clean; character unchanged) | +| Kiko r1a at a desk, seed 11 (green) | 31% (the desk ran off the bottom) | 0.43% (the desk now ends inside the frame) | + +Pinned and unpinned images and the comparison: `/opt/commons/handoff/raven-ref-edit-20261111/clear-edge/`. The pin +did not visibly degrade either render. The residue is subject touching the seam (fin, tail, desk corner), not clutter. +Limits: a hard key leaves a faint backdrop-coloured fringe on antialiased outlines (visible on saturated green, faint on +cream); backdrop enclosed by the subject (the gap between a headband and the head) stays opaque; a saturated key colour +the subject also contains (green in a green-eyed character) is untouched except where connected to the band; the band is +a rectangle, so the pin cannot ask for a vignette or a round sticker. A subject cannot be asked to stay off the seam: only +the exit code tells you it did not. + +Offline test (fake ComfyUI on 127.0.0.1, 127 checks): `python3 tests/test-raven-image.py`. ## What it does diff --git a/client/raven-image b/client/raven-image index 229d0d9..ca392bb 100644 --- a/client/raven-image +++ b/client/raven-image @@ -5,10 +5,12 @@ Talks to ComfyUI's own API: POST /prompt, poll GET /history/, GET /view, POST /free when the queue is empty. No retry loop: an unreachable Raven exits 75 with "Raven may be busy or off". See README.md next to this file. """ -import argparse, json, math, os, random, stat, sys, time, urllib.error, urllib.parse, urllib.request +import argparse, json, math, os, random, re, stat, struct, sys, time, urllib.error, urllib.parse, urllib.request, zlib BASE = os.environ.get("RAVEN_URL", "http://10.77.0.4:8190") EX_BUSY = 75 # EX_TEMPFAIL: no answer from Raven (off, busy, a read timeout mid-render, or a lock screen after a reboot); try again later, by hand +EX_DIRTY = 65 # EX_DATAERR: --margin was asked for and the render put subject in the band; the file is written, already cleared +TOL = 12 # per-channel distance from the band's backdrop beyond which a pixel counts as content (VAE noise on a flat fill is a few levels) MAX_REF = 20 * 2 ** 20 # bytes; refs persist in Raven's input folder, so cap what we put there MAGIC = ((b"\x89PNG\r\n\x1a\n", None), (b"\xff\xd8\xff", None), (b"RIFF", b"WEBP")) # png, jpeg, webp (bytes 8-12) @@ -118,6 +120,133 @@ def build(name, text, w, h, seed, enhance, refs=(), vision=False): return g +def margin_px(pct, w, h): + """Band width on each axis in pixels: pct of that axis, rounded up to the 16-px latent grid so the mask edge is exact.""" + return tuple(-(-math.ceil(pct / 100 * d) // 16) * 16 for d in (w, h)) + + +def pin_edges(g, w, h, pct, bg): + """Native pin: sample from a flat --bg latent with a noise mask that is 0 on the border band, so the band is + re-imposed at every step and the model composes the interior around it. Replaces node 6 (the empty latent).""" + mx, my = margin_px(pct, w, h) + g["30"] = {"class_type": "EmptyImage", "inputs": {"width": w, "height": h, "batch_size": 1, "color": bg}} + g["31"] = {"class_type": "VAEEncode", "inputs": {"pixels": ["30", 0], "vae": ["3", 0]}} + g["32"] = {"class_type": "SolidMask", "inputs": {"value": 0.0, "width": w, "height": h}} # 0 = keep, 1 = sample + g["33"] = {"class_type": "SolidMask", "inputs": {"value": 1.0, "width": w - 2 * mx, "height": h - 2 * my}} + g["34"] = {"class_type": "MaskComposite", "inputs": {"destination": ["32", 0], "source": ["33", 0], "x": mx, "y": my, "operation": "add"}} + g["35"] = {"class_type": "SetLatentNoiseMask", "inputs": {"samples": ["31", 0], "mask": ["34", 0]}} + g["7"]["inputs"]["latent_image"] = ["35", 0] + del g["6"] + + +def png_decode(data): + """8-bit non-interlaced RGB/RGBA PNG (what ComfyUI writes) -> (w, h, bpp, rows, ancillary chunks). Stdlib only.""" + if not data.startswith(b"\x89PNG\r\n\x1a\n"): + raise ValueError("not a png") + pos, idat, keep, hdr = 8, [], [], None + while pos < len(data): + n, typ = struct.unpack(">I4s", data[pos:pos + 8]) + body = data[pos + 8:pos + 8 + n] + pos += 12 + n + if typ == b"IHDR": + hdr = struct.unpack(">IIBBBBB", body) + elif typ == b"IDAT": + idat.append(body) + elif typ != b"IEND": + keep.append((typ, body)) + if hdr is None or not idat: + raise ValueError("no IHDR or no image data") + w, h, depth, ctype, _, _, interlace = hdr + if depth != 8 or ctype not in (2, 6) or interlace: + raise ValueError(f"only 8-bit non-interlaced RGB/RGBA is handled (got depth {depth}, colour type {ctype}, interlace {interlace})") + bpp = 3 if ctype == 2 else 4 + stride, rows, prev = w * bpp, [], bytearray(w * bpp) + raw = zlib.decompressobj().decompress(b"".join(idat), h * (stride + 1) + 1) # bounded: a bomb cannot outgrow its header + if len(raw) != h * (stride + 1): + raise ValueError("image data does not match the header size") + for y in range(h): + f, cur = raw[y * (stride + 1)], bytearray(raw[y * (stride + 1) + 1:(y + 1) * (stride + 1)]) + if f == 1: + for i in range(bpp, stride): cur[i] = (cur[i] + cur[i - bpp]) & 255 + elif f == 2: + for i in range(stride): cur[i] = (cur[i] + prev[i]) & 255 + elif f == 3: + for i in range(stride): cur[i] = (cur[i] + (((cur[i - bpp] if i >= bpp else 0) + prev[i]) >> 1)) & 255 + elif f == 4: + for i in range(stride): + a, b, c = (cur[i - bpp] if i >= bpp else 0), prev[i], (prev[i - bpp] if i >= bpp else 0) + pa, pb, pc = abs(b - c), abs(a - c), abs(a + b - 2 * c) + cur[i] = (cur[i] + (a if pa <= pb and pa <= pc else b if pb <= pc else c)) & 255 + elif f != 0: + raise ValueError(f"bad PNG filter {f}") + rows.append(cur); prev = cur + return w, h, bpp, rows, keep + + +def png_encode(w, h, bpp, rows, keep): + def chunk(t, b): return struct.pack(">I", len(b)) + t + b + struct.pack(">I", zlib.crc32(t + b)) + raw = b"".join(b"\0" + bytes(r) for r in rows) + return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2 if bpp == 3 else 6, 0, 0, 0)) + + b"".join(chunk(t, b) for t, b in keep) + chunk(b"IDAT", zlib.compress(raw, 6)) + chunk(b"IEND", b"")) + + +def key_alpha(w, h, bpp, rows, back): + """Make the backdrop transparent: flood fill from every border pixel through pixels within TOL of `back`, so only + backdrop connected to the (cleared) band goes; enclosed holes and same-coloured subject parts stay opaque. + Returns RGBA rows. Hard key, no feathering: antialiased outlines keep a faint backdrop-coloured fringe.""" + out = [] + for r in rows: + o = bytearray(w * 4) + for c in range(3): o[c::4] = r[c::bpp] + o[3::4] = b"\xff" * w + out.append(o) + near = lambda o, x: all(abs(o[x * 4 + c] - back[c]) <= TOL for c in range(3)) + stack = [(x, y) for x in range(w) for y in (0, h - 1)] + [(x, y) for y in range(h) for x in (0, w - 1)] + while stack: + x, y = stack.pop() + if 0 <= x < w and 0 <= y < h and out[y][x * 4 + 3] and near(out[y], x): + out[y][x * 4 + 3] = 0 + stack += ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)) + return out + + +def clear_edges(png, pct, alpha=False): + """Measure the border band of a rendered PNG against its own backdrop (the per-channel median of the band: the VAE + shifts a saturated --bg by tens of levels, so the asked-for colour is not what a clean band decodes to), paint the + whole band with that backdrop, and report what was content. Returns (png bytes, backdrop (r, g, b), clipped pixels per + side {left, top, right, bottom}, band pixel count). With alpha the PNG is RGBA with the backdrop keyed out.""" + w, h, bpp, rows, keep = png_decode(png) + mx, my = margin_px(pct, w, h) + spans = [(y, 0, w) if y < my or y >= h - my else None for y in range(h)] + sample = [[], [], []] + for y in range(0, h, 3): + for x0, x1 in ([(0, w)] if spans[y] else [(0, mx), (w - mx, w)]): + for x in range(x0, x1, 3): + for c in range(3): sample[c].append(rows[y][x * bpp + c]) + back = tuple(sorted(v)[len(v) // 2] for v in sample) + want = bytes(back + ((255,) if bpp == 4 else ())) + clip = dict.fromkeys(("left", "top", "right", "bottom"), 0) + + def dirty(row, x0, x1): + n = 0 + for x in range(x0, x1): + o = x * bpp + if abs(row[o] - back[0]) > TOL or abs(row[o + 1] - back[1]) > TOL or abs(row[o + 2] - back[2]) > TOL: n += 1 + return n + for y in range(h): + row, edge = rows[y], "top" if y < my else "bottom" if y >= h - my else None + if edge: # whole row is band; the corners belong to top/bottom + clip[edge] += dirty(row, 0, w) + else: + clip["left"] += dirty(row, 0, mx); clip["right"] += dirty(row, w - mx, w) + for x0, x1 in ((0, w),) if edge else ((0, mx), (w - mx, w)): + row[x0 * bpp:x1 * bpp] = want * (x1 - x0) + band = w * h - (w - 2 * mx) * (h - 2 * my) + if alpha: + rows, bpp = key_alpha(w, h, bpp, rows, back), 4 + return png_encode(w, h, bpp, rows, keep), back, clip, band + + def render(g, timeout): try: r = json.loads(call("POST", "/prompt", {"prompt": g, "client_id": "raven-image"})) @@ -164,6 +293,12 @@ def main(): ap.add_argument("--vision", action="store_true", help="with --ref: also feed the first 3 refs to the text encoder (TextEncodeQwenImageEditPlus, no vae)") ap.add_argument("--graph", help="graphs/.json (default flux3-klein, or flux3-klein-edit with --ref)") + ap.add_argument("--margin", type=float, metavar="PCT", + help="keep a PCT%% border band of --bg on every side (rounded up to 16 px): the sampler is pinned to it, " + "then the band is measured and repainted; exit 65 if more than --max-clip %% of it had content") + ap.add_argument("--bg", default="ffffff", metavar="RRGGBB", help="backdrop colour for --margin (the prompt must ask for the same one)") + ap.add_argument("--alpha", action="store_true", help="--margin: key the backdrop connected to the band to transparent (RGBA output)") + ap.add_argument("--max-clip", type=float, default=0.5, metavar="PCT", help="--margin: largest %% of the band that may need clearing (default 0.5)") ap.add_argument("--no-enhance", action="store_true", help="send the prompt as the caption, skip the upsampler") ap.add_argument("--timeout", type=int, default=900, help="seconds to wait per image") ap.add_argument("--keep-loaded", action="store_true", help="skip POST /free at the end") @@ -172,24 +307,48 @@ def main(): ap.error("--vision needs at least one --ref") if a.graph and (a.graph == "flux3-klein-edit") != bool(a.ref): ap.error("--ref and graph flux3-klein-edit go together (the edit graph needs refs, other graphs cannot take them)") + if a.margin is not None and not 0 < a.margin <= 40: + ap.error("--margin is a percentage above 0 and at most 40") + if a.alpha and not a.margin: + ap.error("--alpha needs --margin") + if not re.fullmatch(r"#?[0-9a-fA-F]{6}", a.bg): + ap.error("--bg is six hex digits, RRGGBB") + bg = int(a.bg.lstrip("#"), 16) a.graph = a.graph or ("flux3-klein-edit" if a.ref else "flux3-klein") ref_data = [load_ref(r) for r in a.ref] # everything refusable is refused before the first request w, h = (int(v) for v in a.size.lower().split("x")) seed0 = a.seed if a.seed is not None else random.randrange(2 ** 48) root, ext = os.path.splitext(a.out) + dirty = 0 try: call("GET", "/system_stats", timeout=5) refs = [upload(r, d) for r, d in zip(a.ref, ref_data)] for i in range(a.n): seed = seed0 + i t = time.time() - png = render(build(a.graph, a.prompt, w, h, seed, not a.no_enhance, refs, a.vision), a.timeout) + g = build(a.graph, a.prompt, w, h, seed, not a.no_enhance, refs, a.vision) + if a.margin: + pin_edges(g, w, h, a.margin, bg) + png = render(g, a.timeout) + note = "" + if a.margin: + try: + png, back, clip, band = clear_edges(png, a.margin, a.alpha) + except (ValueError, zlib.error, struct.error) as e: + sys.exit(f"raven-image: --margin: cannot read the render as a PNG ({e}); nothing written") + pct = 100 * sum(clip.values()) / band + note = " margin %s%%: backdrop #%02x%02x%02x, cleared %d of %d band px (%.2f%%; %s)" % ( + a.margin, *back, sum(clip.values()), band, pct, " ".join(f"{k} {v}" for k, v in clip.items())) + if pct > a.max_clip: + dirty += 1; note += f" DIRTY: over --max-clip {a.max_clip}%" path = a.out if a.n == 1 else f"{root}-{i + 1}{ext}" with open(path, "wb") as f: f.write(png) - print(f"{path} seed={seed} {w}x{h} {time.time() - t:.0f}s", flush=True) + print(f"{path} seed={seed} {w}x{h} {time.time() - t:.0f}s{note}", flush=True) if not a.keep_loaded: print("freed Raven's VRAM" if free_if_idle() else "queue not empty, left models loaded") + if dirty: + sys.exit(EX_DIRTY) except Busy as e: print(f"raven-image: Raven may be busy or off ({BASE}: {e}). Not retrying; try again later. " "Most likely cause after a Raven reboot: the lock screen (the ComfyUI task needs someone logged in on Raven's desktop). " diff --git a/tests/test-raven-image.py b/tests/test-raven-image.py index 1a8657c..98c7ee3 100644 --- a/tests/test-raven-image.py +++ b/tests/test-raven-image.py @@ -7,7 +7,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer root = sys.argv[1] if len(sys.argv) > 1 else os.path.dirname(os.path.dirname(os.path.abspath(__file__))) tool = os.path.join(root, "client", "raven-image") PNG = b"\x89PNG\r\n\x1a\n" + b"\0" * 32 -seen = {"uploads": [], "prompts": []} +seen = {"uploads": [], "prompts": [], "view": PNG} class H(BaseHTTPRequestHandler): @@ -19,7 +19,7 @@ class H(BaseHTTPRequestHandler): if self.path.startswith("/history/"): self.send(json.dumps({self.path[9:]: {"status": {"completed": True}, "outputs": {"9": {"images": [ {"filename": "x.png", "subfolder": "", "type": "temp"}]}}}}).encode()) - elif self.path.startswith("/view"): self.send(PNG, "image/png") + elif self.path.startswith("/view"): self.send(seen["view"], "image/png") elif self.path == "/queue": self.send(b'{"queue_running":[],"queue_pending":[]}') else: self.send(b"{}") def do_POST(self): @@ -177,6 +177,84 @@ for nm in ('a"b\r\nX-Evil: 1\\c.png', "pass wd;$x.png", "é.png"): p, _ = run("cap", url="http://127.0.0.1:1") check(p.returncode == 75 and "may be busy or off" in p.stderr and "lock screen" in p.stderr, "unreachable: exit 75, hedged message names lock screen") +# --margin: the pin is in the graph, the band is measured and repainted, a dirty band is loud +import importlib.machinery, importlib.util, struct, zlib +mod2 = importlib.util.module_from_spec(importlib.util.spec_from_loader("ri2", importlib.machinery.SourceFileLoader("ri2", tool))) +mod2.__spec__.loader.exec_module(mod2) +def mkpng(w, h, px, bpp=3, filt=0): + """px(x, y) -> tuple of bpp bytes. filt 0..4 applies that PNG filter to every row (the decoder must undo all five).""" + rows = [bytes(v for x in range(w) for v in px(x, y)) for y in range(h)] + raw, prev = b"", bytes(w * bpp) + for r in rows: + f = bytearray() + for i in range(w * bpp): + a, b, c = (r[i - bpp] if i >= bpp else 0), prev[i], (prev[i - bpp] if i >= bpp else 0) + pr = (0, a, b, (a + b) >> 1, (lambda pa, pb, pc: a if pa <= pb and pa <= pc else b if pb <= pc else c)(abs(b - c), abs(a - c), abs(a + b - 2 * c)))[filt] + f.append((r[i] - pr) & 255) + raw += bytes([filt]) + bytes(f); prev = r + ck = lambda t, b: struct.pack(">I", len(b)) + t + b + struct.pack(">I", zlib.crc32(t + b)) + return (b"\x89PNG\r\n\x1a\n" + ck(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2 if bpp == 3 else 6, 0, 0, 0)) + + ck(b"tEXt", b"prompt\0keepme") + ck(b"IDAT", zlib.compress(raw)) + ck(b"IEND", b"")) +BG = (30, 200, 90) +# codec: every filter type round-trips, ancillary chunks survive +for f in range(5): + for bpp in (3, 4): + pic = lambda x, y, bpp=bpp: ((x * 7 + y * 3) & 255, (x * y) & 255, (x ^ y) & 255) + ((200,) if bpp == 4 else ()) + w_, h_, b_, rows_, keep_ = mod2.png_decode(mkpng(37, 23, pic, bpp, f)) + check((w_, h_, b_) == (37, 23, bpp) and all(bytes(rows_[y][x * bpp:(x + 1) * bpp]) == bytes(pic(x, y)) for y in range(23) for x in range(37)) + and keep_ == [(b"tEXt", b"prompt\0keepme")], f"png codec: filter {f}, {bpp} bytes/px") + check(mod2.png_decode(mod2.png_encode(w_, h_, b_, rows_, keep_))[3] == rows_, f"png codec: encode round-trips (filter {f}, {bpp})") +for bad in (b"nope", mkpng(8, 8, lambda x, y: BG)[:40]): + try: mod2.png_decode(bad); check(False, "png_decode refuses garbage") + except (ValueError, zlib.error, struct.error): check(True, "") +# margin_px: pct of each axis, up to the 16 px latent grid +check(mod2.margin_px(6, 1024, 1024) == (64, 64) and mod2.margin_px(5, 1536, 1024) == (80, 64) and mod2.margin_px(0.1, 1024, 1024) == (16, 16), + "margin_px: 16 px steps per axis") +# the pin graph: flat-bg latent, noise mask 0 on the band and 1 inside, sampler starts from it +for extra, base in (([], "flux3-klein.json"), (["--ref", refs[0]], "flux3-klein-edit.json")): + p, g = run(*extra, "--margin", "5", "--bg", "#12fa5f", "--size", "1536x1024", "--no-enhance", "cap") + check(g is not None and "6" not in g and g["7"]["inputs"]["latent_image"] == ["35", 0], f"margin {base}: sampler starts from the masked latent, empty latent gone") + if g: + check(g["30"]["inputs"] == {"width": 1536, "height": 1024, "batch_size": 1, "color": 0x12fa5f}, f"margin {base}: flat bg image at full size") + check(g["31"] == {"class_type": "VAEEncode", "inputs": {"pixels": ["30", 0], "vae": ["3", 0]}}, f"margin {base}: bg encoded with the graph's vae") + check(g["32"]["inputs"] == {"value": 0.0, "width": 1536, "height": 1024} and g["33"]["inputs"] == {"value": 1.0, "width": 1536 - 160, "height": 1024 - 128}, + f"margin {base}: mask 0 outside, 1 inside by 80x64 px") + check(g["34"]["inputs"]["x"] == 80 and g["34"]["inputs"]["y"] == 64 and g["34"]["inputs"]["operation"] == "add" + and g["34"]["inputs"]["destination"] == ["32", 0] and g["34"]["inputs"]["source"] == ["33", 0], f"margin {base}: interior composited at the margin offset") + check(g["35"]["inputs"] == {"samples": ["31", 0], "mask": ["34", 0]} and g["7"]["inputs"]["denoise"] == 1, f"margin {base}: noise mask on the encoded bg, denoise stays 1") +p, g = run("--no-enhance", "cap") +check(g is not None and "6" in g and "35" not in g, "no --margin: graph unchanged") +for args, needle in ((["--margin", "0"], "--margin is"), (["--margin", "41"], "--margin is"), (["--margin", "5", "--bg", "green"], "--bg is"), (["--alpha"], "--alpha needs --margin")): + refused([*args, "cap"], needle, " ".join(args)) +# the render is measured: clean band exits 0 and is exact, content in the band is cleared and exits 65, alpha keys only connected backdrop +W = 160 # 6% -> 16 px band +def scene(x, y, hole=False): + if x < 6 and y < 6 or (hole and 80 <= x < 90 and 80 <= y < 90): return (250, 20, 20) # corner speck in the band; an enclosed hole is separate below + return BG if not (50 <= x < 110 and 50 <= y < 110) else (250, 20, 20) +seen["view"] = mkpng(W, W, lambda x, y: (BG[0] + (x + y) % 3, BG[1], BG[2]) if not (50 <= x < 110 and 50 <= y < 110) else (250, 20, 20)) +p, g = run("--margin", "6", "--size", "160x160", "--no-enhance", "cap") +out = open(os.path.join(tmp, "o.png"), "rb").read() +check(p.returncode == 0 and "cleared 0 of" in p.stdout and "backdrop #1ec85a" in p.stdout, f"clean band: exit 0, nothing cleared, backdrop reported ({p.stdout.strip()[-140:]})") +r_ = mod2.png_decode(out)[3] +check(all(bytes(r_[y][x * 3:x * 3 + 3]) == bytes(BG) for y in range(W) for x in range(W) if x < 16 or y < 16 or x >= W - 16 or y >= W - 16), "clean band: repainted exactly to the backdrop") +check(bytes(r_[80][80 * 3:80 * 3 + 3]) == bytes((250, 20, 20)), "clean band: interior untouched") +seen["view"] = mkpng(W, W, lambda x, y: (250, 20, 20) if (x < 12 and 30 <= y < 130) or (50 <= x < 110 and 50 <= y < 110) else BG) # a stub entering the left band +p, g = run("--margin", "6", "--size", "160x160", "--no-enhance", "cap") +check(p.returncode == 65 and "DIRTY" in p.stdout and "left %d" % (12 * 100) in p.stdout and "top 0" in p.stdout, f"dirty band: exit 65 naming the side ({p.returncode} {p.stdout.strip()[-160:]})") +r_ = mod2.png_decode(open(os.path.join(tmp, "o.png"), "rb").read())[3] +check(all(bytes(r_[y][x * 3:x * 3 + 3]) == bytes(BG) for y in range(W) for x in range(12)), "dirty band: file is written and the stub cleared") +p, g = run("--margin", "6", "--max-clip", "50", "--size", "160x160", "--no-enhance", "cap") +check(p.returncode == 0, "--max-clip raises the tolerance") +holes = lambda x, y: (250, 20, 20) if (50 <= x < 110 and 50 <= y < 110 and not (70 <= x < 90 and 70 <= y < 90)) else BG # ring around an enclosed backdrop hole +seen["view"] = mkpng(W, W, holes) +p, g = run("--margin", "6", "--alpha", "--size", "160x160", "--no-enhance", "cap") +w_, h_, b_, r_, k_ = mod2.png_decode(open(os.path.join(tmp, "o.png"), "rb").read()) +check(p.returncode == 0 and b_ == 4 and r_[0][3] == 0 and r_[30][3 + 4 * 40] == 0 and r_[60][4 * 60 + 3] == 255, "--alpha: RGBA, outside keyed out, subject opaque") +check(r_[80][4 * 80 + 3] == 255, "--alpha: enclosed backdrop hole stays opaque (connected backdrop only)") +seen["view"] = PNG +p, g = run("--margin", "6", "--no-enhance", "cap") +check(p.returncode != 0 and "cannot read the render" in p.stderr and "Traceback" not in p.stderr, "undecodable render: clean error, no traceback") + srv.shutdown() print(f"{count[0] - len(fails)}/{count[0]} checks passed") sys.exit(1 if fails else 0) -- 2.47.3 From 00794e4eee562a5e6e8b9efacd202b6e488a5c1c Mon Sep 17 00:00:00 2001 From: Kiko Date: Wed, 11 Nov 2026 21:51:49 +0000 Subject: [PATCH 2/4] raven-image --margin: Soul pass fixes: bg-referenced backdrop, interior/size validation, -n exit codes, CRC and size caps, tests that fail on mutation Co-Authored-By: Claude Sonnet 5.5 --- client/README.md | 9 +++--- client/raven-image | 29 +++++++++++++++---- tests/test-raven-image.py | 60 ++++++++++++++++++++++++++++++++++++--- 3 files changed, 84 insertions(+), 14 deletions(-) diff --git a/client/README.md b/client/README.md index 92c19d1..eb4704c 100644 --- a/client/README.md +++ b/client/README.md @@ -57,11 +57,12 @@ the prompt sets what the interior tries to match. After the render the client measures the band against its own backdrop (the per-channel median of the band: the VAE shifts a saturated `--bg` by tens of levels, `#12fa5f` came back `#1fef6d`; cream stayed within 2), repaints the whole -band with that colour, and prints it with the per-side count of pixels that were content: +band with that colour (if the median is more than 48 levels from `--bg` on any channel the band is mostly content, and +`--bg` is the reference instead, so a band of subject cannot pass as its own backdrop), and prints it with the per-side count of pixels that were content: `margin 6.0%: backdrop #fef9ec, cleared 802 of 245760 band px (0.33%; left 646 top 0 right 156 bottom 0)`. If more than `--max-clip` percent of the band (default 0.5) was content the file is still written, already cleared, and the client exits **65**: the subject ran into the frame, so it is cut flat at the seam. Re-seed or ask for more room. -`-n` renders all images, then exits 65 if any was dirty. `--alpha` floods from the band through backdrop-coloured pixels +`-n` renders all images, then exits 65 if any was dirty (a later Busy still exits 75 and says how many earlier images were dirty; an unreadable render is reported, skipped, and exits 1 at the end). `--size` must be multiples of 16 and the margin must leave an interior of at least 16 px per axis. `--alpha` floods from the band through backdrop-coloured pixels and makes them transparent (hard key; pure Python, about 1 s). The output is RGBA only with `--alpha`. Measured 2026-11-11 (1024x1024, 6%, one render each, same seed and ref as the unpinned image): @@ -71,7 +72,7 @@ Measured 2026-11-11 (1024x1024, 6%, one render each, same seed and ref as the un | Aqua's facelift seed 2203 (`--ref --vision`, seaweed and bubbles in the corners) | 33% | 0.33% (corners clean; character unchanged) | | Kiko r1a at a desk, seed 11 (green) | 31% (the desk ran off the bottom) | 0.43% (the desk now ends inside the frame) | -Pinned and unpinned images and the comparison: `/opt/commons/handoff/raven-ref-edit-20261111/clear-edge/`. The pin +Pinned and unpinned images, the comparison, and the raw (unrepainted) pinned renders `*-pin6-raw.png` that the pinned numbers are measured on: `/opt/commons/handoff/raven-ref-edit-20261111/clear-edge/`. The images show the result, not the per-step mechanism; that rests on ComfyUI's sampler (its denoise-mask path), and the evidence here is the band: 0 to 0.5% content in nine pinned renders against 31 to 33% unpinned. The pin did not visibly degrade either render. The residue is subject touching the seam (fin, tail, desk corner), not clutter. Limits: a hard key leaves a faint backdrop-coloured fringe on antialiased outlines (visible on saturated green, faint on cream); backdrop enclosed by the subject (the gap between a headband and the head) stays opaque; a saturated key colour @@ -79,7 +80,7 @@ the subject also contains (green in a green-eyed character) is untouched except a rectangle, so the pin cannot ask for a vignette or a round sticker. A subject cannot be asked to stay off the seam: only the exit code tells you it did not. -Offline test (fake ComfyUI on 127.0.0.1, 127 checks): `python3 tests/test-raven-image.py`. +Offline test (fake ComfyUI on 127.0.0.1, 146 checks): `python3 tests/test-raven-image.py`. ## What it does diff --git a/client/raven-image b/client/raven-image index ca392bb..1e4318f 100644 --- a/client/raven-image +++ b/client/raven-image @@ -10,6 +10,7 @@ import argparse, json, math, os, random, re, stat, struct, sys, time, urllib.err BASE = os.environ.get("RAVEN_URL", "http://10.77.0.4:8190") EX_BUSY = 75 # EX_TEMPFAIL: no answer from Raven (off, busy, a read timeout mid-render, or a lock screen after a reboot); try again later, by hand EX_DIRTY = 65 # EX_DATAERR: --margin was asked for and the render put subject in the band; the file is written, already cleared +BG_TOL = 48 # per-channel: how far the band's decoded median may sit from --bg (saturated colours came back 18 levels off) before it is content, not backdrop TOL = 12 # per-channel distance from the band's backdrop beyond which a pixel counts as content (VAE noise on a flat fill is a few levels) MAX_REF = 20 * 2 ** 20 # bytes; refs persist in Raven's input folder, so cap what we put there MAGIC = ((b"\x89PNG\r\n\x1a\n", None), (b"\xff\xd8\xff", None), (b"RIFF", b"WEBP")) # png, jpeg, webp (bytes 8-12) @@ -147,6 +148,8 @@ def png_decode(data): while pos < len(data): n, typ = struct.unpack(">I4s", data[pos:pos + 8]) body = data[pos + 8:pos + 8 + n] + if len(body) != n or struct.unpack(">I", data[pos + 8 + n:pos + 12 + n])[0] != zlib.crc32(typ + body): + raise ValueError("bad chunk length or CRC") pos += 12 + n if typ == b"IHDR": hdr = struct.unpack(">IIBBBBB", body) @@ -157,6 +160,8 @@ def png_decode(data): if hdr is None or not idat: raise ValueError("no IHDR or no image data") w, h, depth, ctype, _, _, interlace = hdr + if not 0 < w * h <= 2 ** 26: + raise ValueError(f"image size {w}x{h} is out of range") if depth != 8 or ctype not in (2, 6) or interlace: raise ValueError(f"only 8-bit non-interlaced RGB/RGBA is handled (got depth {depth}, colour type {ctype}, interlace {interlace})") bpp = 3 if ctype == 2 else 4 @@ -210,7 +215,7 @@ def key_alpha(w, h, bpp, rows, back): return out -def clear_edges(png, pct, alpha=False): +def clear_edges(png, pct, alpha=False, bg=None): """Measure the border band of a rendered PNG against its own backdrop (the per-channel median of the band: the VAE shifts a saturated --bg by tens of levels, so the asked-for colour is not what a clean band decodes to), paint the whole band with that backdrop, and report what was content. Returns (png bytes, backdrop (r, g, b), clipped pixels per @@ -224,6 +229,10 @@ def clear_edges(png, pct, alpha=False): for x in range(x0, x1, 3): for c in range(3): sample[c].append(rows[y][x * bpp + c]) back = tuple(sorted(v)[len(v) // 2] for v in sample) + if bg is not None: # the median is only a backdrop if it is the pinned colour, up to the VAE's shift; else the band is content + want_bg = ((bg >> 16) & 255, (bg >> 8) & 255, bg & 255) + if any(abs(back[c] - want_bg[c]) > BG_TOL for c in range(3)): + back = want_bg want = bytes(back + ((255,) if bpp == 4 else ())) clip = dict.fromkeys(("left", "top", "right", "bottom"), 0) @@ -316,10 +325,14 @@ def main(): bg = int(a.bg.lstrip("#"), 16) a.graph = a.graph or ("flux3-klein-edit" if a.ref else "flux3-klein") ref_data = [load_ref(r) for r in a.ref] # everything refusable is refused before the first request + if not re.fullmatch(r"\d+x\d+", a.size.lower()) or any(int(v) % 16 or not int(v) for v in a.size.lower().split("x")): + ap.error("--size is WxH in multiples of 16, e.g. 1024x1024") w, h = (int(v) for v in a.size.lower().split("x")) + if a.margin and any(d - 2 * m < 16 for d, m in zip((w, h), margin_px(a.margin, w, h))): + ap.error(f"--margin {a.margin}% leaves no room inside {w}x{h} (the interior must keep at least 16 px per axis)") seed0 = a.seed if a.seed is not None else random.randrange(2 ** 48) root, ext = os.path.splitext(a.out) - dirty = 0 + dirty = broken = 0 try: call("GET", "/system_stats", timeout=5) refs = [upload(r, d) for r, d in zip(a.ref, ref_data)] @@ -333,9 +346,11 @@ def main(): note = "" if a.margin: try: - png, back, clip, band = clear_edges(png, a.margin, a.alpha) - except (ValueError, zlib.error, struct.error) as e: - sys.exit(f"raven-image: --margin: cannot read the render as a PNG ({e}); nothing written") + png, back, clip, band = clear_edges(png, a.margin, a.alpha, bg) + except (ValueError, zlib.error, struct.error, MemoryError) as e: + broken += 1 + print(f"raven-image: --margin: image {i + 1} (seed {seed}): cannot read the render as a PNG ({e}); not written", file=sys.stderr) + continue pct = 100 * sum(clip.values()) / band note = " margin %s%%: backdrop #%02x%02x%02x, cleared %d of %d band px (%.2f%%; %s)" % ( a.margin, *back, sum(clip.values()), band, pct, " ".join(f"{k} {v}" for k, v in clip.items())) @@ -347,12 +362,14 @@ def main(): print(f"{path} seed={seed} {w}x{h} {time.time() - t:.0f}s{note}", flush=True) if not a.keep_loaded: print("freed Raven's VRAM" if free_if_idle() else "queue not empty, left models loaded") + if broken: + sys.exit(1) if dirty: sys.exit(EX_DIRTY) except Busy as e: print(f"raven-image: Raven may be busy or off ({BASE}: {e}). Not retrying; try again later. " "Most likely cause after a Raven reboot: the lock screen (the ComfyUI task needs someone logged in on Raven's desktop). " - "A read timeout mid-render also lands here.", file=sys.stderr) + "A read timeout mid-render also lands here." + (f" {dirty} earlier image(s) were DIRTY." if dirty else ""), file=sys.stderr) sys.exit(EX_BUSY) diff --git a/tests/test-raven-image.py b/tests/test-raven-image.py index 98c7ee3..efdf8f4 100644 --- a/tests/test-raven-image.py +++ b/tests/test-raven-image.py @@ -232,22 +232,22 @@ def scene(x, y, hole=False): if x < 6 and y < 6 or (hole and 80 <= x < 90 and 80 <= y < 90): return (250, 20, 20) # corner speck in the band; an enclosed hole is separate below return BG if not (50 <= x < 110 and 50 <= y < 110) else (250, 20, 20) seen["view"] = mkpng(W, W, lambda x, y: (BG[0] + (x + y) % 3, BG[1], BG[2]) if not (50 <= x < 110 and 50 <= y < 110) else (250, 20, 20)) -p, g = run("--margin", "6", "--size", "160x160", "--no-enhance", "cap") +p, g = run("--margin", "6", "--bg", "1ec85a", "--size", "160x160", "--no-enhance", "cap") out = open(os.path.join(tmp, "o.png"), "rb").read() check(p.returncode == 0 and "cleared 0 of" in p.stdout and "backdrop #1ec85a" in p.stdout, f"clean band: exit 0, nothing cleared, backdrop reported ({p.stdout.strip()[-140:]})") r_ = mod2.png_decode(out)[3] check(all(bytes(r_[y][x * 3:x * 3 + 3]) == bytes(BG) for y in range(W) for x in range(W) if x < 16 or y < 16 or x >= W - 16 or y >= W - 16), "clean band: repainted exactly to the backdrop") check(bytes(r_[80][80 * 3:80 * 3 + 3]) == bytes((250, 20, 20)), "clean band: interior untouched") seen["view"] = mkpng(W, W, lambda x, y: (250, 20, 20) if (x < 12 and 30 <= y < 130) or (50 <= x < 110 and 50 <= y < 110) else BG) # a stub entering the left band -p, g = run("--margin", "6", "--size", "160x160", "--no-enhance", "cap") +p, g = run("--margin", "6", "--bg", "1ec85a", "--size", "160x160", "--no-enhance", "cap") check(p.returncode == 65 and "DIRTY" in p.stdout and "left %d" % (12 * 100) in p.stdout and "top 0" in p.stdout, f"dirty band: exit 65 naming the side ({p.returncode} {p.stdout.strip()[-160:]})") r_ = mod2.png_decode(open(os.path.join(tmp, "o.png"), "rb").read())[3] check(all(bytes(r_[y][x * 3:x * 3 + 3]) == bytes(BG) for y in range(W) for x in range(12)), "dirty band: file is written and the stub cleared") -p, g = run("--margin", "6", "--max-clip", "50", "--size", "160x160", "--no-enhance", "cap") +p, g = run("--margin", "6", "--bg", "1ec85a", "--max-clip", "50", "--size", "160x160", "--no-enhance", "cap") check(p.returncode == 0, "--max-clip raises the tolerance") holes = lambda x, y: (250, 20, 20) if (50 <= x < 110 and 50 <= y < 110 and not (70 <= x < 90 and 70 <= y < 90)) else BG # ring around an enclosed backdrop hole seen["view"] = mkpng(W, W, holes) -p, g = run("--margin", "6", "--alpha", "--size", "160x160", "--no-enhance", "cap") +p, g = run("--margin", "6", "--bg", "1ec85a", "--alpha", "--size", "160x160", "--no-enhance", "cap") w_, h_, b_, r_, k_ = mod2.png_decode(open(os.path.join(tmp, "o.png"), "rb").read()) check(p.returncode == 0 and b_ == 4 and r_[0][3] == 0 and r_[30][3 + 4 * 40] == 0 and r_[60][4 * 60 + 3] == 255, "--alpha: RGBA, outside keyed out, subject opaque") check(r_[80][4 * 80 + 3] == 255, "--alpha: enclosed backdrop hole stays opaque (connected backdrop only)") @@ -255,6 +255,58 @@ seen["view"] = PNG p, g = run("--margin", "6", "--no-enhance", "cap") check(p.returncode != 0 and "cannot read the render" in p.stderr and "Traceback" not in p.stderr, "undecodable render: clean error, no traceback") +PIL_FIXTURES = [ # (PNG written by Pillow, optimize=True: its encoder picks filters 1, 2, 3 and 4; sha256 of the raw RGB it was made from) + ("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", + "8b888b5e56272e9532dce8baab527bd9ac1c6351a8db13171036aaa50c785999"), + ("iVBORw0KGgoAAAANSUhEUgAAADAAAAAgCAIAAADbtmxLAAAEwElEQVR42m1WW5LjOAwDCE46nelD78EtcT9kKlB6Uq6ULVEk+AJF4D8IEKiAimIIVIRGSKErlNIIhYQUpZmKVKWYmn80U/ijeIh/VA/xITzEh8aX9ND4Ep7Cl+opPsWn6il8S0/hW/Ol+BZeGi/FS3iJSQQQwCQABDGJIIoQMQj1CgkQkyBRRBE810EUbw0gSExCBM5nnR1E8DBXS3kSE5hAEhcQjaOItPPVEJexIECMFoC9XCZQLSPiIkSQGO3DMEn0O5MEGGCRCYK8yCCDBAmSZJFFBjlIkCInKRJktcwki2SfCrLISSY5Wufg/VsHl54gr7YS5EhGICYiGQMhRjImoxi4gUUxyJgMMWC7V++ihauFL0Yy1J/7BYxo4SWDXly7TMZECDEZhduMY5p9YDBGq0Pv7s8N92IUIxhX785egfkGhhij4S7Nk4FkAEFEMYQYfGMqxmCIEYxiqBWxHZKBW4iXmWVya8jWuR3bCMpSsc7OsA3uuLWlbawsfUvFMI93tMpkdo6q4xftzOwkLltpnxHUpIoKChQpUkVd/bnWgxrUpIISNVtyrQdFalLoJ6iiQGULzzY0KFKgJpXUBrAUIpmAJpTUxI2s7GS1jaVlNKYFOlpX9eIwr7aZDfoD62WIl+czmQld0KQm3mdGi44GJAvA7Gh5kKojPdt29YNzq1oJTwdAMZkTyh4ds0XVL2HhqX4hJdO1w46W3+C2Y9WSNFiOe3ykrKjAvT3N/MrFZfG4WpGbX+/rcwlka7goGYLtpDplRwaSfwoKiBRxVwksF9Wfs8tZZ47KMJWVy/UryzqdubooZcUUyceKECngjts+WW2+qDQc2f+uEdZ9syXDuq8shNlRqW635dJIPgKaUFAX3qGj/S97wxoH1r06TdK6Wmf9bkDTXPWGnRRXhAhNKvFucrTEtD4PS1+aOp4FO63+omMQ5gaNt4Z5vrrsQahWOeO2sStfdgZGNrNLeHYkdmpkbR+nPzwzOI2TtGOW/Crc5QzcG2V8nWc7XHYeXUnzhBhnfw2Lx0Y2LCpb/6Ai+RzNQxduJzaLuLvbJx8F2ZbibOl58uFouNfJsWV0sPIQySegC8q+U//uZBo7O32n8YKMA6PtfXDM+MXsH7W4eOgpI8ZdYo56z8idac8LWlJn59cZAydS2uMH46WZfC5iHLwTtznwg92nMcJuk48oOtw4y9Zxb7oPu1ncvJX8BgSIPe0365cN6tHDOf5F02Gh2oPCyamMtGhJLEv9cmAkX7C2T+tz/SvUYdFyWBto/poPabxXFuAdmKv/k5rJFyGsOY/3ZN1ysM/8Naidcz9GXlln+brTo3duUBdfkXyttk9q4J2Caep8XqaNOY8Zu/s+LnTTbgGD75IYdg8hXxsfki9B1dO+bFLipFf+4g9YJ+qMzR7Jcd4Cdn8UFXzB7jb3/E7+XUw9eMMaJ/HXOaU35VznLWUYGhidemeBEv+WFXVYVVUT409ABYk98zsv1Zmq7rK0mJXZ4znsnPfa+586gwGb9rLLUyV/BgQoerimcS6tCIbdhMJ6hBabkyx+nJ/CiH5rkM049Cz7ITShyTtOe47WWUzOHDhnyC7z5M9uomFzbZMNrOT9nj4pvCSq/gfyN3gTGCrIuQAAAABJRU5ErkJggg==", + "39c1118e88d437671a8d8474eeb749c12fc3bbc7baceaaf3ee7198308ef95a3b"), + ("iVBORw0KGgoAAAANSUhEUgAAADAAAAAgCAIAAADbtmxLAAAN5ElEQVR42gXB544s2UEA4BOr6lSOHWdmZ+69u16QwZaNsddoZcsIIRts4g/eg3dA4g2R94ZJ3dOhcg4n8H3wv//3f6aarAJ1lD/sLu9LvsRrMNBrB0BUDi/115J99KyvddHYsGguc7W1DL7HaqRmb0KCz8tHHDDMd/7rmdyRy0tjP+ze8mPSiDHcdm/2PahlZKVWWjUVar6548fR3/fDD9RKGgITBhjKobBaQ44NdEf8p188UA8eaus9657tyMHNog3GnzdZBuRNyGxqFmQCUzItTdk6KDQXOIDCLCpH6RfQpEA+sHEozsbgO1W3DHszfgIiRDr8Cs9T7D93i3ry/A03xNXeuLAlmYvqrpMu9PzqTXZSSv2zEdpfvErLuIuWfNW+NQ/Fy+UMxAA8ni1nYNzzDdLJ/x27T0/FpA8Vmz1Yu7j2KaUaHE0AkUjxYECBfKTpu+XrecAkxjTprxdqKNLIscmLuWFuFi8P85eWqfH9ixgy3N4+hoox1S3Hl32o28tzENznrwxz7trxGf/otz/T/DsoVch0Xx473dIak/hEdXOpjevN3orTiQ+d41u1L3Rmek2z5CKIa257owh7xIN2DtjBfKOvQQffNKh3IMfhIBqIFtbwqxJYEBjpjwUPIhQfFdnpZSgAGFhvmOagFCGk6Za967wwdKOPWH/1prYi1QsjQrMYq6apzpYMbW/o0l1Sza/5NAEBJVmaQTTM1sbGmTko+agF6qK2aXt+wJrFCmS4rW3ZZL9Lv/HWgdlb+vsJSodZ9jjY92NGyemevp6JoMWsvW+2JxkurZBz391nT00qMvz9L/5VZhkXWs1Wq6OLRthWBjXJ4FV0oIjBBMxPiq5a6oK0qqbA2GZ2rq4RTmZcpsjVzeejtBeIlnIGzAJyjHv07BdLYUED6PxIbr05PYn2Ls6yrCs1e0oK4QHqKgpO+hTf0FPbxtwwbxdfW/D3f/quw64JmYXbNHI09tjOtdmOQL6r4oMzGf2hgREnEUrJgCEvu3goFldrE5uwRNeuLLvvurf3bTOxRTCDxgVVViSn3je1a02r7VhybETpvtOaTcQ4zJxuY+fjhMdxfLDCtis2WjS72rl+naoF/+Tn/7guLDBfChr4l5ZwrV8ikFjVdLoZQwu03FhZVtB1o3EKDRer8HVqHzZK/9Ifyi6kllaDwcmmKOyCBC29lkdYTs9XZ0yKdQeGLXZ6fgAXxQbPdnQLTX4wtGcxrOdARmKsoIxObjeAY6I5VnSPf/OXf2t9oEer0qeGLuy4sdZTr0ZoG003dyN6QH75dgSGVbhwuXjj3fHr1PpoEcsjKI7TorZJ3aqtnZVW2W85KZeyTOZ7rrVQ71PYjm1n8sh4GOtU79yqnD3nTWqT6ZorQZDO8cVX3tlFnWoVnNI3lCANH15vn1Bv3PHbGQvMfNjs3BbsvHUSAykwicwJVkgPkZ9bjdkadqDPjPgmfYSeTW7ZTBblLPIW/bCxl8i774fKNAJJbBpvoUNzLw3+TCZHhK0QV9DdGJOttexFW1LeDQwWhQKNn9ukiPAe//S776v1Fice4mlltmqG5SvYMKuSY1jzo3GuURsb1C7A2V4ZnGO/mymS7tVUFHqs1WkJK2faj3HelUnp6nzMStPH8rQo0y1KpEy9NFIbON7sNMQ2OunNrUFWysJ5XEVzdQw2609x7hO2zjWEXBfu5EvaZb2JQH5DB/PDu07ygfscy2gy730SoMbKFz8ZJ0rdPCcA0LrYH2rjFWL2Nq2M+FrKMBMJuSjMZrK5AzNK92gZxgl1LrS0xZcoIPBC4WhMxfOAOX+q89dhkKSK1xOm73tfVUa9Bmf0klat2Wm5RWrOQ7TH+ZcTORncf61mpmj2UqPmWB+bb7VeNxhNQVVrfT3l9RqMy2O7tMZUBsa2r1o7IzjuX0MO31xHuqeSNokLqXKovvEcIai3I22n7/bSWZtt4ij21VUM2+V6Vo9zq6r8i4NOM/63f/mrYxNanoanilneZb7AxQAdWa1HvcznaNfyOnKwXfsAzZ11JeAmmU1ORA6MOBn4Oh2MfDsBoVCrAlsaIKS060vEVpDgYRoH/2J/iUCyTES4LMxgN1vXtA86KR3ZXkNou7W/yFJ9WJnZ3sK/+/C7sI9nCslci7pEmk2w4poPGqfcaKUavIsWlnSAU416Yd4MYJkyjRkMB1ct12W6GCSkvTxvQ3g+YzdqqqJtxYco5+O2K017dfIP6mTZAqIWzBTzQzJuIw/qGTz6E9OW5cxgbUVDMVSrg4l//Ks/aJE4GfO67K5CF3i0ctIHF24v5hKz/mWJxy6MBqFtLc14ZXBVNVMZa1S2jqGNkykX0JaU3Upp2kvqi9FC+8wAMTARo17anSZ9F/dNtwS29ph1t3Ktuq6Z9Gzu1qYfnBJuF3EYq2Cm3ijO+Lt//4C11nrixxsUdMrcGYazCw9XV7i51yWNoxxnHFFwQbywL06jL1U3SVYbSH8TFVXKRayWjq1lcEBge53JSVR383CMqYvEBU7BCl0LXaoYy94wEtn3iMvxblSTPkna+VAZw/Wqebk9Nqb7Dv/mb/4L5nZtd1uoGUAwMl2HixNhQPzKwEpxyHzzWTSwMWkXjrOVWCqEfgTm02hKVbzjXeoBVnQwdC5ZbbjUaMpiunHGK2pqXCesywT2dPt59k3t4DSEBbO68BItsYGhWeSlq3b2eDDNzfZw5vjXP/mRGWUxhpnp1Vmtl/rMHjhl/eli+kn+vBj5W+cMvXHj3xxmxKtRRCN87BKyjsUIZBaiNZ5HKDwjArySbmiZCZ5eV7s+a+8sf9GZpfk9JA54CiL/S8p8SjgahHvrafUTi6IR8SuD7iEzhulsILb3Tbn7czWxEXFTz1Y8QQiarzB6l4HsJr5MK0lL6SIJPt65UBipOTlLsrwa8nO6FIpf9I7fXXTzU/tqAls0BzEvWKD2KeFWfqqInC0hlf4KK/3xk88sfihnK9hP6Sk3TfalmZtGhp+pFTlCj95J/Ptv/+4NOhsUQpVqXdSHTj8fm0HeLNd59KZh7w6dvPUTA19BBwu/e6gWqFp9drmDEYJ+n1dWc1vcjoG2zMuOtUufwdXdAFqOar3nJXRq/GqaictrrN9JjbejJmx/S9nouaUmkkZW/lXRNRZcy/HDf/yTWYhCn6FpVV5jv0iHKq3XAbiBrZXNPHhHJwTMrpXGyq4VcpoeM1C7Zavi0BwmjdZsXBk117F5bckQnIDYRH6p1SF2Rp/u+RSDLWi7YSGRKKSr+eRUPFLd1chSQCAK0ZESjJg4rkYg/tn9X1sPQTAiOFANLdOO8dJcZLZEoPMhNkez7q+k4mdCK3hZHXq8+2pSF0+St1huNK1uur26g1er9VLsvpPVE7IDq+d2BnsZCwNkha6SbHFqJ7TehsXNyqN7v2fYyNAjp7emDBWdNtaiAC+lxvHPfv/306GnS2VFzWDipBLIpKExiS5iuo4OB7rdDsfW3lEXDm698m0yW4Mri2zmkXBJkGOBxVn1telPL3SJocL524yiftXvl/a5ufdacPEHq6k/C7jZm6RKznnrlLWzqEiRipaWq49Tr+htw3qFf/6rn5sLmLbQMfZuehYxb7WePO37tRxmXgczElmgO5z4pSr7odGbGQT22wl+UMkZfR57Z8a0yfld4nLD1fCCa6uM34KFXJu4AZDkwTDrYj2LCyGJY3c0PMzSN5DQ1OotmZa5tmCo+rHt9VV7CvD3v/oHzVZaV06N1FgMODYasXi1ylSNzi5VQ0nWiZFfjjfKM1HXw2WQurKoLqpo1nPhugH3mN7z9IwNhfWrILuovKSurjppDVvNg8Yzyhz91oUvk9oSAdL2ytQe3rXisZsCEjX5qLHe6XL/XuF//uOPdYNcTU3lltMaJWpzVPGaORtWs9nRbr2UvOq+Nok6bt/qaByMgA+9RB1Dtrc0viu+nHUQpU5jwa435Co9sAIs4YDKu2WFffdQLXIyeg8VrYJiwjBCVmDlvaQW8d1ITl/EfkYVgvQuBxT/+rd/ODwWlra7MS+fkhE2GynlHlTChj0cwjSswHUP9b5wh144oc2Wnq10GrLeGLRPsul0itxmvWwPlNV2PeGGOpgOhoHgzjWuaOAWJW1RrRbdEbix3Epv/OG13eC5BTJbSvMsIdY11dbCqADAv/yLP95YswlOog5GXLB5AN0acO9C7Hdp8ZmJlTOkk+3iyl3PDdfjzq0caLykge1Jaq2MeVw39mlsVjmIIsmPrKmbOA7Etu0yZhS1GcKXKXonC6F5Z803Eq16hSTCobgUrt+AZgMLewNqVxHhdQf83U9/2ePoEk6rwRyshFTnwVaAWyv+ZdiZgNvZW6fPjXkrm6MXVsvhHs4aiUvwEQQTPA6cSEkx9BxHZQfMFRsJUgL0UXZbsKlnJu34aM/wDGG/DcbnhqgQeGX+ce7e6cPZS/iobdhkmr11pszj+Mf/+a3kml4T25pKwWyz95ybmsigxkpAInSuULwVp3ER1sSGuoVC9oB4dFUccxlaE8VRVTVwPhd4FsYDo0vgRJmRkWefWQ67jIptzj2C5mE16DGTlKZ64aiRbkLktvyMqOUbh/JjyNfdTBz0Dd2sMMALPNrmejroA1KnTysMfrhfELQLpzWJPL9+ZY6Ezta88+Rou7AqTSJksA5dAatIOqYBpAr3AZw/vmFQTZf7fM2iGc3nQ1SYWemNcgVWjj8MUzK0t6aM4EPMC/JCQzZqnSYi5TJx1TK6/D8CEJECLItcpAAAAABJRU5ErkJggg==", + "f55679ae730350261f805f3db051d1dc4dec58bac8d5dafc880ee51cc983d9ee"), +] + +import base64, hashlib +for i, (b64, sha) in enumerate(PIL_FIXTURES): + w_, h_, b_, r_, k_ = mod2.png_decode(base64.b64decode(b64)) + check(hashlib.sha256(b"".join(bytes(r) for r in r_)).hexdigest() == sha, f"png codec: Pillow-written fixture {i} decodes to the pixels it was made from (independent encoder, filters 1-4)") +# exact counts on a synthetic band: corners belong to top/bottom, TOL is inclusive, the denominator is the band +def sq(x0, y0, x1, y1, c): return lambda x, y: c if x0 <= x < x1 and y0 <= y < y1 else None +def compose(*shapes, bg=BG): + return lambda x, y: next((v for v in (s_(x, y) for s_ in shapes) if v), bg) +RED = (250, 20, 20); NEAR = (BG[0] + 12, BG[1], BG[2]); FAR = (BG[0] + 13, BG[1], BG[2]) +scene2 = compose(sq(0, 0, 5, 5, RED), sq(2, 60, 5, 70, RED), sq(156, 60, 160, 63, RED), sq(158, 158, 160, 160, RED), + sq(4, 100, 6, 102, NEAR), sq(4, 110, 7, 111, FAR), sq(144, 80, 145, 82, RED), sq(15, 90, 16, 92, RED), sq(16, 90, 17, 92, RED)) +png2, back2, clip2, band2 = mod2.clear_edges(mkpng(160, 160, scene2), 6, False, 0x1ec85a) +check(band2 == 160 * 160 - 128 * 128 and back2 == BG, f"clear_edges: band is {band2}, backdrop {back2}") +check(clip2 == {"left": 30 + 3 + 2, "top": 25, "right": 12 + 2, "bottom": 4}, f"clear_edges: exact per-side counts, corners to top/bottom, distance 12 clean and 13 content ({clip2})") +check(b"tEXt" in png2, "clear_edges: ancillary chunks survive the re-encode") +# a band that is mostly content is not a backdrop: it is dirty against --bg, never promoted +for frac, label in ((1.0, "all content"), (0.55, "55% content")): + pic = lambda x, y, f=frac: RED if (x < 16 or y < 16 or x >= 144 or y >= 144) and (x * 7 + y * 13) % 100 < f * 100 else BG + _, back3, clip3, band3 = mod2.clear_edges(mkpng(160, 160, pic), 6, False, 0x1ec85a) + check(100 * sum(clip3.values()) / band3 > 0.5, f"band that is {label}: reported dirty ({sum(clip3.values())}/{band3})") +# flood fill reaches through 1-px corridors in both axes +pocket_v = compose(sq(80, 20, 81, 80, BG), sq(75, 80, 86, 100, BG), sq(50, 50, 110, 110, RED)) # vertical corridor into a pocket +pocket_h = compose(sq(20, 80, 80, 81, BG), sq(80, 75, 100, 86, BG), sq(50, 50, 110, 110, RED)) # horizontal corridor into a pocket +for nm, sc, probe in (("vertical", pocket_v, (80, 90)), ("horizontal", pocket_h, (90, 80))): + pic = mkpng(160, 160, sc) + _, _, bpp_, rows_, _ = mod2.png_decode(pic) + w2, h2, bpp2, rows2, keep2 = mod2.png_decode(mod2.clear_edges(pic, 6, True, 0x1ec85a)[0]) + check(rows2[probe[1]][probe[0] * 4 + 3] == 0, f"flood fill: backdrop pocket behind a 1-px {nm} corridor is keyed out") +# decoder hardening: a flipped byte breaks the CRC, a huge IHDR is refused before allocation +good = mkpng(8, 8, lambda x, y: BG) +bad = bytearray(good); bad[40] ^= 1 +for blob, what in ((bytes(bad), "flipped byte"), (good[:16] + struct.pack(">II", 60000, 60000) + good[24:], "huge IHDR")): + try: mod2.png_decode(blob); check(False, f"png_decode refuses {what}") + except (ValueError, zlib.error, struct.error): check(True, "") +# size and interior validation, before any request +refused(["--size", "foo", "cap"], "--size is WxH", "--size foo") +refused(["--size", "1000x1024", "cap"], "multiples of 16", "--size not on the grid") +refused(["--margin", "40", "--size", "64x64", "cap"], "leaves no room", "margin eating the interior") +# -n: a dirty image is not forgotten by a later one; an unreadable render is reported, not fatal to the batch +seen["view"] = mkpng(160, 160, lambda x, y: RED if x < 12 else BG) +p, g = run("--margin", "6", "--bg", "1ec85a", "-n", "2", "--size", "160x160", "--no-enhance", "cap") +check(p.returncode == 65 and p.stdout.count("DIRTY") == 2, f"-n 2 both dirty: two reports, exit 65 ({p.returncode})") + srv.shutdown() print(f"{count[0] - len(fails)}/{count[0]} checks passed") sys.exit(1 if fails else 0) -- 2.47.3 From 7b5d8bab1dad886b9980c2f53cc70657f9d5ff6f Mon Sep 17 00:00:00 2001 From: Kiko Date: Wed, 11 Nov 2026 22:24:15 +0000 Subject: [PATCH 3/4] raven-image --margin: pin the BG_TOL boundary, the repaint colour, every decoder refusal; drop two guards no behaviour depends on Co-Authored-By: Claude Sonnet 5.5 --- client/README.md | 4 ++-- client/raven-image | 4 +--- tests/test-raven-image.py | 22 +++++++++++++++++++--- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/client/README.md b/client/README.md index eb4704c..d35fd5e 100644 --- a/client/README.md +++ b/client/README.md @@ -58,7 +58,7 @@ the prompt sets what the interior tries to match. After the render the client measures the band against its own backdrop (the per-channel median of the band: the VAE shifts a saturated `--bg` by tens of levels, `#12fa5f` came back `#1fef6d`; cream stayed within 2), repaints the whole band with that colour (if the median is more than 48 levels from `--bg` on any channel the band is mostly content, and -`--bg` is the reference instead, so a band of subject cannot pass as its own backdrop), and prints it with the per-side count of pixels that were content: +`--bg` is the reference instead, so a band of subject cannot pass as its own backdrop; the guarantee is a flat band within 48 levels of `--bg`, so a band that is uniformly 30 levels off, which the VAE can do to a saturated colour, passes), and prints it with the per-side count of pixels that were content: `margin 6.0%: backdrop #fef9ec, cleared 802 of 245760 band px (0.33%; left 646 top 0 right 156 bottom 0)`. If more than `--max-clip` percent of the band (default 0.5) was content the file is still written, already cleared, and the client exits **65**: the subject ran into the frame, so it is cut flat at the seam. Re-seed or ask for more room. @@ -80,7 +80,7 @@ the subject also contains (green in a green-eyed character) is untouched except a rectangle, so the pin cannot ask for a vignette or a round sticker. A subject cannot be asked to stay off the seam: only the exit code tells you it did not. -Offline test (fake ComfyUI on 127.0.0.1, 146 checks): `python3 tests/test-raven-image.py`. +Offline test (fake ComfyUI on 127.0.0.1, 158 checks): `python3 tests/test-raven-image.py`. ## What it does diff --git a/client/raven-image b/client/raven-image index 1e4318f..425bb43 100644 --- a/client/raven-image +++ b/client/raven-image @@ -148,7 +148,7 @@ def png_decode(data): while pos < len(data): n, typ = struct.unpack(">I4s", data[pos:pos + 8]) body = data[pos + 8:pos + 8 + n] - if len(body) != n or struct.unpack(">I", data[pos + 8 + n:pos + 12 + n])[0] != zlib.crc32(typ + body): + if struct.unpack(">I", data[pos + 8 + n:pos + 12 + n])[0] != zlib.crc32(typ + body): raise ValueError("bad chunk length or CRC") pos += 12 + n if typ == b"IHDR": @@ -160,8 +160,6 @@ def png_decode(data): if hdr is None or not idat: raise ValueError("no IHDR or no image data") w, h, depth, ctype, _, _, interlace = hdr - if not 0 < w * h <= 2 ** 26: - raise ValueError(f"image size {w}x{h} is out of range") if depth != 8 or ctype not in (2, 6) or interlace: raise ValueError(f"only 8-bit non-interlaced RGB/RGBA is handled (got depth {depth}, colour type {ctype}, interlace {interlace})") bpp = 3 if ctype == 2 else 4 diff --git a/tests/test-raven-image.py b/tests/test-raven-image.py index efdf8f4..bc8b57a 100644 --- a/tests/test-raven-image.py +++ b/tests/test-raven-image.py @@ -292,16 +292,32 @@ for nm, sc, probe in (("vertical", pocket_v, (80, 90)), ("horizontal", pocket_h, _, _, bpp_, rows_, _ = mod2.png_decode(pic) w2, h2, bpp2, rows2, keep2 = mod2.png_decode(mod2.clear_edges(pic, 6, True, 0x1ec85a)[0]) check(rows2[probe[1]][probe[0] * 4 + 3] == 0, f"flood fill: backdrop pocket behind a 1-px {nm} corridor is keyed out") -# decoder hardening: a flipped byte breaks the CRC, a huge IHDR is refused before allocation +# BG_TOL: a flat band within 48 levels of --bg is the pinned colour after the VAE's shift (observed up to 30), repainted as the band's own colour; 49 is content +for off, want_clean in ((48, True), (49, False)): + flat = (BG[0] + off, BG[1], BG[2]) + p_, back4, clip4, band4 = mod2.clear_edges(mkpng(160, 160, lambda x, y, f=flat: f), 6, False, 0x1ec85a) + check((sum(clip4.values()) == 0) == want_clean and back4 == (flat if want_clean else BG), f"BG_TOL: flat band {off} levels off --bg {'is' if want_clean else 'is not'} a backdrop ({back4}, {sum(clip4.values())} cleared)") + if want_clean: + r4 = mod2.png_decode(p_)[3] + check(bytes(r4[3][3:6]) == bytes(flat), "BG_TOL: a clean band is repainted with its own colour, so the seam stays invisible") +# decoder hardening: a flipped byte breaks the CRC, and every size check refuses on its own (each blob is otherwise well-formed) +def ck(t, b): return struct.pack(">I", len(b)) + t + b + struct.pack(">I", zlib.crc32(t + b)) +def blob(w, h, depth=8, ctype=2, idat=None, il=0): + return (b"\x89PNG\r\n\x1a\n" + ck(b"IHDR", struct.pack(">IIBBBBB", w, h, depth, ctype, 0, 0, il)) + + ck(b"IDAT", idat if idat is not None else zlib.compress(b"\0" * (h * (w * 3 + 1)) if w * h < 10 ** 7 else b"")) + ck(b"IEND", b"")) good = mkpng(8, 8, lambda x, y: BG) bad = bytearray(good); bad[40] ^= 1 -for blob, what in ((bytes(bad), "flipped byte"), (good[:16] + struct.pack(">II", 60000, 60000) + good[24:], "huge IHDR")): - try: mod2.png_decode(blob); check(False, f"png_decode refuses {what}") +for data, what in ((bytes(bad), "flipped byte (CRC)"), (blob(2 ** 21, 2 ** 21), "a header that promises far more than the data"), (blob(8, 8, idat=zlib.compress(b"\0" * (8 * 25 - 1))), "image data shorter than the header says"), + (blob(8, 8, idat=zlib.compress(b"\0" * (8 * 25 + 500))), "image data longer than the header says"), (blob(8, 8, depth=16), "16-bit"), + (blob(8, 8, ctype=3), "palette"), (blob(8, 8, il=1), "interlaced"), (good[:-20], "truncated"), + (good[:8] + struct.pack(">I", 10 ** 6) + good[12:], "chunk length past the end")): + try: mod2.png_decode(data); check(False, f"png_decode refuses {what}") except (ValueError, zlib.error, struct.error): check(True, "") # size and interior validation, before any request refused(["--size", "foo", "cap"], "--size is WxH", "--size foo") refused(["--size", "1000x1024", "cap"], "multiples of 16", "--size not on the grid") refused(["--margin", "40", "--size", "64x64", "cap"], "leaves no room", "margin eating the interior") +refused(["--margin", "5", "--size", "32x512", "cap"], "leaves no room", "margin eating one axis only") # -n: a dirty image is not forgotten by a later one; an unreadable render is reported, not fatal to the batch seen["view"] = mkpng(160, 160, lambda x, y: RED if x < 12 else BG) p, g = run("--margin", "6", "--bg", "1ec85a", "-n", "2", "--size", "160x160", "--no-enhance", "cap") -- 2.47.3 From dd3d432573913cd5948833cb6574bea85cbdc680 Mon Sep 17 00:00:00 2001 From: Kiko Date: Wed, 11 Nov 2026 22:34:01 +0000 Subject: [PATCH 4/4] raven-image --margin: restore the IHDR size bound before any header-sized allocation, with the 2**32-1 test that pins it Co-Authored-By: Claude Sonnet 5.5 --- client/README.md | 2 +- client/raven-image | 2 ++ tests/test-raven-image.py | 3 ++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/client/README.md b/client/README.md index d35fd5e..809671e 100644 --- a/client/README.md +++ b/client/README.md @@ -80,7 +80,7 @@ the subject also contains (green in a green-eyed character) is untouched except a rectangle, so the pin cannot ask for a vignette or a round sticker. A subject cannot be asked to stay off the seam: only the exit code tells you it did not. -Offline test (fake ComfyUI on 127.0.0.1, 158 checks): `python3 tests/test-raven-image.py`. +Offline test (fake ComfyUI on 127.0.0.1, 159 checks): `python3 tests/test-raven-image.py`. ## What it does diff --git a/client/raven-image b/client/raven-image index 425bb43..ecda0bd 100644 --- a/client/raven-image +++ b/client/raven-image @@ -160,6 +160,8 @@ def png_decode(data): if hdr is None or not idat: raise ValueError("no IHDR or no image data") w, h, depth, ctype, _, _, interlace = hdr + if not 0 < w * h <= 2 ** 26: # before any allocation sized by the header: 2**32-1 squared would ask for 12 GB and overflow ssize_t + raise ValueError(f"image size {w}x{h} is out of range") if depth != 8 or ctype not in (2, 6) or interlace: raise ValueError(f"only 8-bit non-interlaced RGB/RGBA is handled (got depth {depth}, colour type {ctype}, interlace {interlace})") bpp = 3 if ctype == 2 else 4 diff --git a/tests/test-raven-image.py b/tests/test-raven-image.py index bc8b57a..cbf176b 100644 --- a/tests/test-raven-image.py +++ b/tests/test-raven-image.py @@ -307,12 +307,13 @@ def blob(w, h, depth=8, ctype=2, idat=None, il=0): + ck(b"IDAT", idat if idat is not None else zlib.compress(b"\0" * (h * (w * 3 + 1)) if w * h < 10 ** 7 else b"")) + ck(b"IEND", b"")) good = mkpng(8, 8, lambda x, y: BG) bad = bytearray(good); bad[40] ^= 1 -for data, what in ((bytes(bad), "flipped byte (CRC)"), (blob(2 ** 21, 2 ** 21), "a header that promises far more than the data"), (blob(8, 8, idat=zlib.compress(b"\0" * (8 * 25 - 1))), "image data shorter than the header says"), +for data, what in ((bytes(bad), "flipped byte (CRC)"), (blob(2 ** 21, 2 ** 21), "a header that promises far more than the data"), (blob(2 ** 32 - 1, 2 ** 32 - 1), "a 2**32-1 square header (must refuse before allocating)"), (blob(8, 8, idat=zlib.compress(b"\0" * (8 * 25 - 1))), "image data shorter than the header says"), (blob(8, 8, idat=zlib.compress(b"\0" * (8 * 25 + 500))), "image data longer than the header says"), (blob(8, 8, depth=16), "16-bit"), (blob(8, 8, ctype=3), "palette"), (blob(8, 8, il=1), "interlaced"), (good[:-20], "truncated"), (good[:8] + struct.pack(">I", 10 ** 6) + good[12:], "chunk length past the end")): try: mod2.png_decode(data); check(False, f"png_decode refuses {what}") except (ValueError, zlib.error, struct.error): check(True, "") + except BaseException as e: check(False, f"png_decode refuses {what} cleanly, got {type(e).__name__}") # size and interior validation, before any request refused(["--size", "foo", "cap"], "--size is WxH", "--size foo") refused(["--size", "1000x1024", "cap"], "multiples of 16", "--size not on the grid") -- 2.47.3