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 <noreply@anthropic.com>
This commit is contained in:
parent
d0596fdc11
commit
00794e4eee
3 changed files with 84 additions and 14 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
("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",
|
||||
"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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue