From d0596fdc11cb31b74aeccc4cc26353e5059bfa79 Mon Sep 17 00:00:00 2001 From: Kiko Date: Wed, 11 Nov 2026 21:11:34 +0000 Subject: [PATCH] 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)