Files
Ivor StrelarandClaude Opus 5.5 bf95ebef42 feat: add SF WaveSpeed Outpaint Resizer
Turns one image into up to 8 resolutions without redrawing the artwork. Each
size, set in an on-node list editor, is auto (crop when that cuts away at
most auto_crop_limit, default 15%, else outpaint), crop or outpaint; sizes of
the image's own shape are just resized. Outpainting uses FLUX Fill (with an
optional Bria GenFill pass over the bottom corners, where FLUX tends to sign),
Bria GenFill or Bria Expand on WaveSpeed to paint only the new strips, pastes
the original back with a feathered seam, and scales each size from that; sizes
sharing a shape share one generation.

An optional edge mask (e.g. a torn-paper right edge) is scaled to the full
length of the chosen side of every output and cut out as transparency, after
the masked area of the input is filled in so no old edge pixels leak into the
outpainting. The whole prompt is visible and editable, with nothing appended.

The size list is stored as JSON in the hidden resolutions widget and drawn by
a DOM editor that is not serialized, so saved widget values keep their order;
older plain-text lists are converted on load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:08:32 +01:00

296 lines
12 KiB
Python

# ABOUTME: Geometry for keep-the-original outpainting: canvas, mask, paste-back, Bria Expand placement
# ABOUTME: Pure Pillow + numpy, ported from the Art Pipeline Hub resolution-variant-generator (tested there)
"""Geometry for "keep the original" reframing. Pure Pillow and numpy.
Nothing here talks to a network or imports the runner library, on purpose:
every function takes pictures and numbers and returns pictures and numbers, so
the tests can check the part that decides whether the artist's original
survives (where it sits, what the mask covers, what the paste-back may touch)
without any model in the loop.
THE IDEA. A whole-image edit model (GPT Image, Nano Banana) redraws the
picture and moves the subject. A fill model paints only what it is told to
paint. So:
1. build a canvas at the TARGET shape that holds the source at its own pixels
(geometry, canvas_and_mask);
2. mask only the new strips, reaching a little into the original so the model
blends the seam (OVERLAP);
3. after the model answers, paste the original back over the result with a
soft edge on the grown sides only (paste_back);
4. scale the whole canvas to the target together, so old and new areas share
one level of detail.
Ported from the 2026-11-09 bake-off prototype; the prototype's detail-matching
blur is gone because the winning results did not need it.
"""
from __future__ import annotations
import math
import numpy as np
from PIL import Image, ImageFilter
# Same-shape tolerance: a target within 0.5% of the source's aspect ratio is a
# plain resize, not a generation.
SAME_SHAPE_TOLERANCE = 0.005
MODEL_MIN_EDGE = 512
MODEL_MAX_EDGE = 1536
MODEL_SHORT_MIN = 256 # flux-fill-dev refuses anything under 256 on a side
MODEL_STEP = 16
def same_shape(sw: int, sh: int, tw: int, th: int, tolerance: float = SAME_SHAPE_TOLERANCE) -> bool:
return abs(math.log(tw / th) - math.log(sw / sh)) <= math.log(1 + tolerance)
def overlap_feather(canvas_longest: int) -> tuple[int, int]:
"""OVERLAP 24 / FEATHER 16 at ~800 px, scaled linearly, clamped to 8..64."""
scale = canvas_longest / 800
return (
int(min(64, max(8, round(24 * scale)))),
int(min(64, max(8, round(16 * scale)))),
)
def geometry(sw: int, sh: int, tw: int, th: int) -> tuple[int, int, int, int]:
"""Smallest canvas at the target's shape that holds the source at its own
pixels. Returns (canvas_w, canvas_h, offset_x, offset_y), the source
centred on the axis that grew."""
if tw / th >= sw / sh:
cw, ch = round(sh * tw / th), sh
else:
cw, ch = sw, round(sw * th / tw)
cw, ch = max(cw, sw), max(ch, sh)
return cw, ch, (cw - sw) // 2, (ch - sh) // 2
def fit_source_to_target(src: Image.Image, largest_tw: int, largest_th: int) -> Image.Image:
"""The downscale rule: if the canvas would be bigger than the largest
target in the group needs, scale the source down first so the model works
at the size that will be delivered. Never scales UP — the original's own
pixels are the point."""
cw, ch, _, _ = geometry(src.width, src.height, largest_tw, largest_th)
scale = min(1.0, largest_tw / cw, largest_th / ch)
if scale >= 1.0:
return src
size = (max(1, round(src.width * scale)), max(1, round(src.height * scale)))
return src.resize(size, Image.LANCZOS)
def _grown(cw: int, ch: int, ox: int, oy: int, sw: int, sh: int) -> tuple[bool, bool, bool, bool]:
"""Which sides of the source have new canvas beside them: left, top, right, bottom."""
return ox > 0, oy > 0, cw - ox - sw > 0, ch - oy - sh > 0
def canvas_and_mask(
src: Image.Image, cw: int, ch: int, ox: int, oy: int
) -> tuple[Image.Image, Image.Image]:
"""The canvas the model is given and the mask over it (255 = paint here).
The new area is prefilled with the source stretched and blurred so the
model starts from the right colours. The mask is 255 over everything new
and 0 over the original, EXCEPT an OVERLAP band reaching into the original
on the sides that grew — a side that did not grow keeps its 0 right to the
edge."""
sw, sh = src.size
overlap, _ = overlap_feather(max(cw, ch))
overlap = max(1, min(overlap, sw // 4, sh // 4))
blur = min(80, max(8, round(40 * max(cw, ch) / 800)))
canvas = src.resize((cw, ch), Image.BICUBIC).filter(ImageFilter.GaussianBlur(blur))
canvas.paste(src, (ox, oy))
left, top, right, bottom = _grown(cw, ch, ox, oy, sw, sh)
mask = Image.new("L", (cw, ch), 255)
inner = (
ox + (overlap if left else 0),
oy + (overlap if top else 0),
ox + sw - (overlap if right else 0),
oy + sh - (overlap if bottom else 0),
)
mask.paste(0, inner)
return canvas, mask
def model_size(cw: int, ch: int) -> tuple[int, int]:
"""Both sides multiples of 16; longest edge clamp(canvas longest, 512,
1536), raised only as far as needed to keep the short side at 256+ (a
very wide canvas would otherwise fall under the model's minimum)."""
longest = min(MODEL_MAX_EDGE, max(MODEL_MIN_EDGE, max(cw, ch)))
shortest = min(cw, ch)
longest = min(MODEL_MAX_EDGE, max(longest, math.ceil(MODEL_SHORT_MIN * max(cw, ch) / shortest)))
scale = longest / max(cw, ch)
step = MODEL_STEP
return (
max(MODEL_SHORT_MIN, round(cw * scale / step) * step),
max(MODEL_SHORT_MIN, round(ch * scale / step) * step),
)
def paste_back(gen: Image.Image, src: Image.Image, ox: int, oy: int) -> Image.Image:
"""The original's own pixels win everywhere except a soft band at the seam
on the sides that grew. On a side that did not grow the original is kept
to the very edge."""
sw, sh = src.size
_, feather = overlap_feather(max(gen.width, gen.height))
feather = max(1, min(feather, sw // 4, sh // 4))
left, top, right, bottom = _grown(gen.width, gen.height, ox, oy, sw, sh)
# Built on a padded sheet and cropped, so the blur cannot fade the edges
# of a side that did not grow: there the 255 runs on into the padding.
pad = feather * 4
sheet = Image.new("L", (sw + 2 * pad, sh + 2 * pad), 0)
sheet.paste(255, (
pad + (feather if left else -pad),
pad + (feather if top else -pad),
pad + sw - (feather if right else -pad),
pad + sh - (feather if bottom else -pad),
))
alpha = sheet.filter(ImageFilter.GaussianBlur(feather / 2)).crop((pad, pad, pad + sw, pad + sh))
out = gen.copy()
out.paste(src, (ox, oy), alpha)
return out
def new_boxes(cw: int, ch: int, ox: int, oy: int, sw: int, sh: int) -> list[tuple[int, int, int, int]]:
"""The strips of new canvas, one box per side that grew."""
left, top, right, bottom = _grown(cw, ch, ox, oy, sw, sh)
boxes = []
if left:
boxes.append((0, 0, ox, ch))
if right:
boxes.append((ox + sw, 0, cw, ch))
if top:
boxes.append((0, 0, cw, oy))
if bottom:
boxes.append((0, oy + sh, cw, ch))
return boxes
# ---- Bria Expand: it takes only a ratio from a short list ----
def _ratio(label: str) -> float:
left, _, right = str(label).partition(":")
return float(left) / float(right)
def pick_expand_ratio(
sw: int, sh: int, tw: int, th: int, enum_labels: list[str] | tuple[str, ...]
) -> str | None:
"""The enum ratio whose expanded canvas still contains the target's
minimal canvas, nearest to the target. None when no ratio can.
The expanded canvas holds the source at its own scale, centred. A wide
target (wider than the source) is contained by a canvas at least as wide;
a narrow one by a canvas at least as narrow; a canvas on the other side of
the source's own shape never contains it. So a 16:9 source cannot be made
wider than 16:9 here."""
target = tw / th
source = sw / sh
slack = 1 + SAME_SHAPE_TOLERANCE
best: tuple[float, str] | None = None
for label in enum_labels:
r = _ratio(label)
if target >= source:
ok = r >= target / slack and r >= source / slack
distance = r - target
else:
ok = r <= target * slack and r <= source * slack
distance = target - r
if ok and (best is None or abs(distance) < best[0]):
best = (abs(distance), label)
return best[1] if best else None
def locate_original(
out: Image.Image,
src: Image.Image,
max_mean_diff: float = 20.0,
) -> tuple[float, int, int] | None:
"""Where did the model put the original? Returns (scale, x, y) with the
source scaled by `scale` landing at (x, y) in `out`, or None when nothing
in `out` looks like the source — a refusal beats a guess, because a guess
would paste the original over the wrong place.
The scale starts from the axis that filled (the one that did not grow) and
is tried at 0.97-1.03 of that. A coarse search on a small grayscale copy
finds the neighbourhood; a finer one at full resolution settles the pixel."""
sw, sh = src.size
base = min(out.width / sw, out.height / sh)
candidates = [round(base * f, 4) for f in np.linspace(0.97, 1.03, 7)]
out_gray = np.asarray(out.convert("L"), dtype=np.float32)
src_img = src.convert("L")
coarse_f = min(1.0, 128 / max(out.size))
out_small = np.asarray(out.convert("L").resize(
(max(1, round(out.width * coarse_f)), max(1, round(out.height * coarse_f))), Image.BILINEAR
), dtype=np.float32)
per_scale: list[tuple[float, float, int, int]] = [] # coarse diff, scale, x, y (full res)
for scale in candidates:
w, h = round(sw * scale), round(sh * scale)
if w < 8 or h < 8 or w > out.width or h > out.height:
continue
sw_s, sh_s = max(1, round(w * coarse_f)), max(1, round(h * coarse_f))
if sw_s > out_small.shape[1] or sh_s > out_small.shape[0]:
continue
template = np.asarray(src_img.resize((sw_s, sh_s), Image.BILINEAR), dtype=np.float32)
here: tuple[float, int, int] | None = None
for y in range(out_small.shape[0] - sh_s + 1):
for x in range(out_small.shape[1] - sw_s + 1):
diff = float(np.abs(out_small[y:y + sh_s, x:x + sw_s] - template).mean())
if here is None or diff < here[0]:
here = (diff, x, y)
if here is not None:
per_scale.append((here[0], float(scale), round(here[1] / coarse_f), round(here[2] / coarse_f)))
if not per_scale:
return None
# The coarse pass cannot tell 0.99 from 1.00 on smooth pictures, so the
# few best scales are all settled at full resolution and the best of those wins.
reach = int(math.ceil(1 / coarse_f)) + 4
best: tuple[float, float, int, int] | None = None # diff, scale, x, y
for _, scale, cx, cy in sorted(per_scale)[:3]:
w, h = round(sw * scale), round(sh * scale)
template = np.asarray(src_img.resize((w, h), Image.LANCZOS), dtype=np.float32)[::4, ::4]
for y in range(max(0, cy - reach), min(out.height - h, cy + reach) + 1):
for x in range(max(0, cx - reach), min(out.width - w, cx + reach) + 1):
window = out_gray[y:y + h:4, x:x + w:4][:template.shape[0], :template.shape[1]]
diff = float(np.abs(window - template).mean())
if best is None or diff < best[0]:
best = (diff, scale, x, y)
if best is None or best[0] > max_mean_diff:
return None
return best[1], best[2], best[3]
def crop_box_around_original(
image_size: tuple[int, int], box: tuple[int, int, int, int], tw: int, th: int
) -> tuple[int, int, int, int]:
"""The smallest window at the target's ratio that contains `box`, centred
on it and slid (never shrunk) to stay inside the image. Only generated
margin is ever cut away."""
iw, ih = image_size
x0, y0, x1, y1 = box
bw, bh = x1 - x0, y1 - y0
target = tw / th
if target >= bw / bh:
w, h = math.ceil(bh * target), bh
else:
w, h = bw, math.ceil(bw / target)
w, h = min(w, iw), min(h, ih)
cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
left = int(round(min(max(cx - w / 2, 0), iw - w)))
top = int(round(min(max(cy - h / 2, 0), ih - h)))
return left, top, left + w, top + h
def crop_around_original(
img: Image.Image, box: tuple[int, int, int, int], tw: int, th: int
) -> Image.Image:
return img.crop(crop_box_around_original(img.size, box, tw, th))