Vendor the crop planner so the node stands alone (1.32.0)

The sampler no longer reaches into a sibling pack at runtime: the crop path
planner and its helpers now live here as bfs_crop_planner.py and
bfs_subject_crop.py, copied from drozbay/MaskVidExperiments (d98cc89, v0.2.0).
Both packs are GPL-3.0, which is what makes the copy legal; attribution is in
NOTICE and in each file's header. Only the ComfyUI node classes were dropped
-- they stay in the original pack, where they belong.

tracked/zoomed therefore work out of the box now, and the per-frame paste-back
is ours rather than a call into their uncrop node.

Also adds the node's help page (docs/BFSHeadSwapMaskedSampler.md, the '?' on
the node): what each crop mode does, why the mask is native inpainting rather
than a LoRA feature, why latent_mask is the output to check first, how to size
the latent to the crop, and the order to switch things on when a run looks
wrong. scipy is now a declared dependency (the planner uses ndimage).
This commit is contained in:
alisson-anjos
2026-08-31 20:34:39 +00:00
parent 5203e8d30c
commit c19d2bb8a1
6 changed files with 989 additions and 54 deletions
+8
View File
@@ -0,0 +1,8 @@
Portions of this pack are vendored from third-party GPL-3.0 projects:
bfs_crop_planner.py, bfs_subject_crop.py
from drozbay/MaskVidExperiments (https://github.com/drozbay/MaskVidExperiments),
commit d98cc89 (v0.2.0), Copyright (c) drozbay, GPL-3.0.
Vendored so the head-swap sampler needs no runtime dependency on that pack;
behaviour unchanged, ComfyUI node classes dropped.
+528
View File
@@ -0,0 +1,528 @@
"""Crop-path planner, vendored from drozbay/MaskVidExperiments.
Source: https://github.com/drozbay/MaskVidExperiments (commit d98cc89, v0.2.0)
Copyright (c) drozbay. Licensed GPL-3.0, the same licence this pack carries,
which is what makes vendoring it legal here.
Vendored rather than imported so the head-swap sampler has no runtime
dependency on that pack being installed. Behaviour is unchanged; only the
ComfyUI node classes were dropped (they stay in the original pack, where they
belong) and the planner import was repointed.
Why this code and not a crop of my own: naive per-frame crops around a mask
jitter in position and size, and a video model reads that jitter as camera
motion. These boxes are planned over the whole clip, so they hold still through
mask noise and occlusion and follow only sustained movement.
"""
"""Subject crop planner: shape as an optimization output.
Standalone numpy/scipy planner solved against binary masks; produces the
per-frame crop boxes only.
Structure per mode:
tracked -- one LP: scalar W, H + per-frame positions. Shape emerges.
zoomed -- outer sweep over constant aspect candidates, inner LP per
candidate: per-frame height (width slaved to aspect) +
positions. Rent is charged against the tightest
aspect-respecting padded box, never per axis.
combined -- closed form: padded union box (also the movement_cost -> inf
limit of the other two).
Dials (p):
crop_scale padding promise as a multiple of the subject
min_padding_allowed fraction of the promise enforced as hard floors
min_padding_allowed_window odd median window defining sustained motion
pad_deficit_tol shortfall above the floor costs 1/this per px-frame
pad_surplus_tol size beyond the padded need costs 1/this per px-frame
resize_cost cost per pixel of box size change (zoomed)
movement_cost cost per pixel of box movement
center_pull tie-break pull toward the subject center
end_tightening fraction of rent charged on the phantoms
end_tightening_window phantom frames held at each end
zoom_step >1 quantizes sizes to geometric levels (zoomed)
max_zoom_rate >1 caps per-frame size ratio change (zoomed), 0/1 off
aspect_ratio >0 fixes the shape and skips the sweep
seamless_loop periodic plan (positions and sizes wrap)
"""
import math
import numpy as np
from scipy import ndimage, sparse
from scipy.optimize import linprog
def _extent_tracks(binary):
"""Per-frame subject extents, NaN gaps interpolated and edge-held."""
n = binary.shape[0]
bx0 = np.full(n, np.nan)
bx1 = np.full(n, np.nan)
by0 = np.full(n, np.nan)
by1 = np.full(n, np.nan)
for i, frame in enumerate(binary):
ys, xs = frame.nonzero()
if len(xs):
bx0[i], bx1[i] = xs.min(), xs.max()
by0[i], by1[i] = ys.min(), ys.max()
known = ~np.isnan(bx0)
idx = np.arange(n)
out = [np.interp(idx, idx[known], t[known]) for t in (bx0, bx1, by0, by1)]
return out, known
def _median_tracks(tracks, window):
g = max(int(window), 1) | 1
return [ndimage.median_filter(t, size=g, mode="nearest") for t in tracks]
def _round_corridor(t, lo, hi):
ilo = np.ceil(lo - 1e-6)
ihi = np.floor(hi + 1e-6)
x = np.clip(np.round(t), ilo, ihi)
return np.where(ilo > ihi, np.round((lo + hi) / 2), x).astype(int)
class _LP:
"""Row-wise builder for sparse linprog problems."""
def __init__(self):
self.blocks = [] # (name, size)
self.off = {}
self.nv = 0
self.ri, self.ci, self.vv, self.rhs = [], [], [], []
self.c = []
self.lo, self.hi = [], []
def var(self, name, size, cost=0.0, lo=0.0, hi=None):
self.off[name] = self.nv
self.nv += size
cost = np.broadcast_to(np.asarray(cost, dtype=float), (size,))
lo = np.broadcast_to(np.asarray(lo, dtype=float), (size,))
self.c.extend(cost)
self.lo.extend(lo)
if hi is None:
self.hi.extend([None] * size)
else:
hi_arr = np.broadcast_to(np.asarray(hi, dtype=float), (size,))
self.hi.extend(hi_arr)
return self.off[name]
def row(self, cols, vals, b):
r = len(self.rhs)
self.ri.extend([r] * len(cols))
self.ci.extend(cols)
self.vv.extend(vals)
self.rhs.append(b)
def solve(self, eq=None):
A = sparse.csr_matrix((self.vv, (self.ri, self.ci)),
shape=(len(self.rhs), self.nv))
bounds = [(l, h) for l, h in zip(self.lo, self.hi)]
kw = {}
if eq is not None:
er, ec, ev, eb = eq
kw["A_eq"] = sparse.csr_matrix((ev, (er, ec)),
shape=(max(er) + 1, self.nv))
kw["b_eq"] = np.asarray(eb, dtype=float)
res = linprog(np.array(self.c), A_ub=A,
b_ub=np.array(self.rhs, dtype=float),
bounds=bounds, method="highs", **kw)
if not res.success:
raise RuntimeError(f"crop planner solve failed: {res.message}")
return res
def _prep(binary, img_w, img_h, p):
"""Shared preprocessing: extent tracks, phantoms, pads, floors."""
n = binary.shape[0]
(bx0, bx1, by0, by1), known = _extent_tracks(binary)
loop = bool(p.get("seamless_loop")) and n > 1
s0 = int(p.get("end_tightening_window", 0)) if not loop else 0
if s0:
hold = lambda a: np.concatenate(
[np.full(s0, a[0]), a, np.full(s0, a[-1])])
bx0, bx1, by0, by1 = hold(bx0), hold(bx1), hold(by0), hold(by1)
m = len(bx0)
scale = p["crop_scale"]
padx = (scale - 1) / 2 * (bx1 - bx0 + 1)
pady = (scale - 1) / 2 * (by1 - by0 + 1)
floor = float(p.get("min_padding_allowed", 0.0))
sx0, sx1, sy0, sy1 = _median_tracks([bx0, bx1, by0, by1],
p.get("min_padding_allowed_window", 16))
pad_fx = floor * (scale - 1) / 2 * (sx1 - sx0 + 1)
pad_fy = floor * (scale - 1) / 2 * (sy1 - sy0 + 1)
# floor datums, clamped to the image (a side pinned at the image edge
# is exempt from its promise)
flo_x = np.minimum(sx1 + 1 + pad_fx, img_w)
fhi_x = np.maximum(sx0 - pad_fx, 0)
flo_y = np.minimum(sy1 + 1 + pad_fy, img_h)
fhi_y = np.maximum(sy0 - pad_fy, 0)
# soft targets: the full promise, clamped to the image
lx = np.maximum(bx0 - padx, 0)
rx = np.minimum(bx1 + 1 + padx, img_w)
ly = np.maximum(by0 - pady, 0)
ry = np.minimum(by1 + 1 + pady, img_h)
rent_scale = np.ones(m)
if s0:
rent_scale[:s0] = p.get("end_tightening", 0.0)
rent_scale[m - s0:] = p.get("end_tightening", 0.0)
return {
"n": n, "m": m, "s0": s0, "loop": loop, "known": known,
"bx0": bx0, "bx1": bx1, "by0": by0, "by1": by1,
"padx": padx, "pady": pady, "floor": floor,
"flo_x": flo_x, "fhi_x": fhi_x, "flo_y": flo_y, "fhi_y": fhi_y,
"lx": lx, "rx": rx, "ly": ly, "ry": ry, "rent_scale": rent_scale,
}
def _tv_rows(lp, var, tv, m, loop):
nt = m if loop else m - 1
for i in range(nt):
a, b = lp.off[var] + i, lp.off[var] + (i + 1) % m
t = lp.off[tv] + i
lp.row([b, a, t], [1, -1, -1], 0)
lp.row([b, a, t], [-1, 1, -1], 0)
def _solve_tracked(pre, img_w, img_h, p):
"""Scalar-size LP: W, H are single variables, shape emerges from the
movement/rent balance. Manual aspect adds one equality."""
m, loop = pre["m"], pre["loop"]
nt = m if loop else m - 1
bx0, bx1, by0, by1 = pre["bx0"], pre["bx1"], pre["by0"], pre["by1"]
padx, pady = pre["padx"], pre["pady"]
soft = 1.0 / max(p.get("pad_deficit_tol", 16), 1e-6)
rent = pre["rent_scale"] / max(p.get("pad_surplus_tol", 16), 1e-6)
mc = p.get("movement_cost", 1.0)
eps = p.get("center_pull", 1e-4)
extw = bx1 - bx0 + 1
exth = by1 - by0 + 1
lp = _LP()
lp.var("W", 1, lo=max(extw.max(), 1), hi=img_w)
lp.var("H", 1, lo=max(exth.max(), 1), hi=img_h)
ub_x = np.maximum(bx0, 0)
ub_y = np.maximum(by0, 0)
if pre["floor"] > 0:
ub_x = np.minimum(ub_x, pre["fhi_x"])
ub_y = np.minimum(ub_y, pre["fhi_y"])
lp.var("x", m, lo=0.0, hi=ub_x)
lp.var("y", m, lo=0.0, hi=ub_y)
lp.var("tx", nt, cost=mc)
lp.var("ty", nt, cost=mc)
lp.var("vx", m, cost=soft)
lp.var("vy", m, cost=soft)
lp.var("rx", m, cost=rent)
lp.var("ry", m, cost=rent)
lp.var("ux", m, cost=eps)
lp.var("uy", m, cost=eps)
_tv_rows(lp, "x", "tx", m, loop)
_tv_rows(lp, "y", "ty", m, loop)
W, H = lp.off["W"], lp.off["H"]
for i in range(m):
x, y = lp.off["x"] + i, lp.off["y"] + i
vx, vy = lp.off["vx"] + i, lp.off["vy"] + i
rx, ry = lp.off["rx"] + i, lp.off["ry"] + i
ux, uy = lp.off["ux"] + i, lp.off["uy"] + i
lp.row([x, W], [-1, -1], -(bx1[i] + 1)) # containment right
lp.row([x, W], [1, 1], img_w) # image right
lp.row([y, H], [-1, -1], -(by1[i] + 1))
lp.row([y, H], [1, 1], img_h)
lp.row([x, vx], [1, -1], pre["lx"][i]) # soft pad, left
lp.row([x, W, vx], [-1, -1, -1], -pre["rx"][i]) # soft pad, right
lp.row([y, vy], [1, -1], pre["ly"][i])
lp.row([y, H, vy], [-1, -1, -1], -pre["ry"][i])
lp.row([W, rx], [1, -1], min(extw[i] + 2 * padx[i], img_w))
lp.row([H, ry], [1, -1], min(exth[i] + 2 * pady[i], img_h))
if pre["floor"] > 0:
lp.row([x, W], [-1, -1], -pre["flo_x"][i])
lp.row([y, H], [-1, -1], -pre["flo_y"][i])
cx = (bx0[i] + bx1[i] + 1) / 2
cy = (by0[i] + by1[i] + 1) / 2
lp.row([x, W, ux], [1, 0.5, -1], cx)
lp.row([x, W, ux], [-1, -0.5, -1], -cx)
lp.row([y, H, uy], [1, 0.5, -1], cy)
lp.row([y, H, uy], [-1, -0.5, -1], -cy)
eq = None
ar = p.get("aspect_ratio", 0.0)
if ar > 0:
eq = ([0, 0], [W, H], [1.0, -ar], [0.0])
res = lp.solve(eq=eq)
o = lp.off
return (res.x[W], res.x[H],
res.x[o["x"]:o["x"] + m], res.x[o["y"]:o["y"] + m])
def _yielded_floors(pre, ar, img_w, img_h):
"""Relax the floor datums per side wherever containment, the image, or
the aspect lock leaves no room."""
bx0, bx1, by0, by1 = pre["bx0"], pre["bx1"], pre["by0"], pre["by1"]
wmax = min(img_w, ar * img_h)
hmax = min(img_h, img_w / ar)
flo_x, fhi_x = pre["flo_x"].copy(), pre["fhi_x"].copy()
flo_y, fhi_y = pre["flo_y"].copy(), pre["fhi_y"].copy()
ex = np.maximum(flo_x - fhi_x - wmax, 0)
flo_x = np.maximum(flo_x - ex / 2, bx1 + 1)
fhi_x = np.minimum(fhi_x + ex / 2, np.maximum(bx0, 0))
flo_x = np.minimum(flo_x, np.maximum(bx0, 0) + wmax)
fhi_x = np.maximum(fhi_x, bx1 + 1 - wmax)
ey = np.maximum(flo_y - fhi_y - hmax, 0)
flo_y = np.maximum(flo_y - ey / 2, by1 + 1)
fhi_y = np.minimum(fhi_y + ey / 2, np.maximum(by0, 0))
flo_y = np.minimum(flo_y, np.maximum(by0, 0) + hmax)
fhi_y = np.maximum(fhi_y, by1 + 1 - hmax)
return flo_x, fhi_x, flo_y, fhi_y
def _solve_zoomed_at(pre, ar, img_w, img_h, p):
"""Inner LP for one aspect candidate. Width is ar*h throughout; rent
is charged on h beyond the tightest aspect-respecting padded need, so
width the ratio forces is never billed."""
m, loop = pre["m"], pre["loop"]
nt = m if loop else m - 1
bx0, bx1, by0, by1 = pre["bx0"], pre["bx1"], pre["by0"], pre["by1"]
padx, pady = pre["padx"], pre["pady"]
extw = bx1 - bx0 + 1
exth = by1 - by0 + 1
soft = 1.0 / max(p.get("pad_deficit_tol", 16), 1e-6)
# need is aspect-aware (width the ratio forces is never billed), but
# the excess is billed at its (1+ar) box magnitude so wide and narrow
# candidates pay the same for the same true slack
rent = (1.0 + ar) * pre["rent_scale"] / max(p.get("pad_surplus_tol", 16), 1e-6)
mc = p.get("movement_cost", 1.0)
sc = p.get("resize_cost", 2.0) * (1.0 + ar)
eps = p.get("center_pull", 1e-4)
hmax = min(img_h, img_w / ar)
hmin = np.maximum(np.maximum(exth, extw / ar), 1.0)
if hmin.max() > hmax + 1e-6:
return None # this aspect cannot contain the subject in-image
need_h = np.minimum(
np.maximum(exth + 2 * pady, (extw + 2 * padx) / ar), hmax)
floors = pre["floor"] > 0
if floors:
flo_x, fhi_x, flo_y, fhi_y = _yielded_floors(pre, ar, img_w, img_h)
lp = _LP()
ub_x = np.maximum(bx0, 0)
ub_y = np.maximum(by0, 0)
if floors:
ub_x = np.minimum(ub_x, fhi_x)
ub_y = np.minimum(ub_y, fhi_y)
lp.var("x", m, lo=0.0, hi=ub_x)
lp.var("y", m, lo=0.0, hi=ub_y)
lp.var("h", m, lo=hmin, hi=hmax)
lp.var("tx", nt, cost=mc)
lp.var("ty", nt, cost=mc)
lp.var("th", nt, cost=sc)
lp.var("vx", m, cost=soft)
lp.var("vy", m, cost=soft)
lp.var("r", m, cost=rent)
lp.var("ux", m, cost=eps)
lp.var("uy", m, cost=eps)
_tv_rows(lp, "x", "tx", m, loop)
_tv_rows(lp, "y", "ty", m, loop)
_tv_rows(lp, "h", "th", m, loop)
rate = p.get("max_zoom_rate", 0.0)
if rate and rate > 1.0:
for i in range(nt):
a, b = lp.off["h"] + i, lp.off["h"] + (i + 1) % m
lp.row([b, a], [1, -rate], 0)
lp.row([a, b], [1, -rate], 0)
for i in range(m):
x, y, h = lp.off["x"] + i, lp.off["y"] + i, lp.off["h"] + i
vx, vy = lp.off["vx"] + i, lp.off["vy"] + i
r = lp.off["r"] + i
ux, uy = lp.off["ux"] + i, lp.off["uy"] + i
lp.row([x, h], [-1, -ar], -(bx1[i] + 1)) # containment right
lp.row([x, h], [1, ar], img_w) # image right
lp.row([y, h], [-1, -1], -(by1[i] + 1))
lp.row([y, h], [1, 1], img_h)
lp.row([x, vx], [1, -1], pre["lx"][i])
lp.row([x, h, vx], [-1, -ar, -1], -pre["rx"][i])
lp.row([y, vy], [1, -1], pre["ly"][i])
lp.row([y, h, vy], [-1, -1, -1], -pre["ry"][i])
lp.row([h, r], [1, -1], need_h[i])
if floors:
lp.row([x, h], [-1, -ar], -flo_x[i])
lp.row([y, h], [-1, -1], -flo_y[i])
cx = (bx0[i] + bx1[i] + 1) / 2
cy = (by0[i] + by1[i] + 1) / 2
lp.row([x, h, ux], [1, 0.5 * ar, -1], cx)
lp.row([x, h, ux], [-1, -0.5 * ar, -1], -cx)
lp.row([y, h, uy], [1, 0.5, -1], cy)
lp.row([y, h, uy], [-1, -0.5, -1], -cy)
res = lp.solve()
o = lp.off
# the objective is the sweep score: floors carry the padding tier as
# hard rows, so candidates compare on how cheaply they serve the
# cell's own priced trade above them
viol = res.x[o["vx"]:o["vx"] + m].sum() + res.x[o["vy"]:o["vy"] + m].sum()
h_t = res.x[o["h"]:o["h"] + m]
# dead width -- width this ratio forces beyond the frame's padded
# horizontal need -- is billed in the score only, at the same rent as
# height slack. Inside the LP that width stays free (billing it there
# collapses manual wide aspects), but across candidates it
# must cost, or the sweep drifts to the widest feasible shape whenever
# the vertical need pins the height. Capped at need_h: excess above
# need_h already carries its width via the (1+ar) rent.
w_need = np.minimum(extw + 2 * padx, img_w)
dead_w = np.maximum(ar * np.minimum(h_t, need_h) - w_need, 0)
dead = ((pre["rent_scale"] * dead_w).sum()
/ max(p.get("pad_surplus_tol", 16), 1e-6))
return {
"ar": ar, "score": res.fun + dead, "viol": viol,
"x": res.x[o["x"]:o["x"] + m], "y": res.x[o["y"]:o["y"] + m],
"h": h_t,
}
def _aspect_candidates(pre, img_w, img_h, n_known):
"""Hull of the per-frame shape quantiles and the padded union aspect:
the winner lives between what the subject looks like and where it
goes."""
bx0, bx1, by0, by1 = pre["bx0"], pre["bx1"], pre["by0"], pre["by1"]
padx, pady = pre["padx"], pre["pady"]
frame_ar = (bx1 - bx0 + 1) / (by1 - by0 + 1)
qs = np.quantile(frame_ar, [0.05, 0.25, 0.5, 0.75, 0.95])
ux0 = (bx0 - padx).min()
ux1 = (bx1 + 1 + padx).max()
uy0 = (by0 - pady).min()
uy1 = (by1 + 1 + pady).max()
union_ar = max(ux1 - ux0, 1.0) / max(uy1 - uy0, 1.0)
lo = min(qs.min(), union_ar)
hi = max(qs.max(), union_ar)
grid = np.geomspace(lo, hi, 7) if hi > lo * 1.02 else np.array([lo])
cands = np.concatenate([grid, qs, [union_ar]])
# feasibility band: the box must contain every frame inside the image
extw = bx1 - bx0 + 1
exth = by1 - by0 + 1
band_lo = extw.max() / img_h
band_hi = img_w / exth.max()
cands = np.clip(cands, band_lo, band_hi)
cands = np.unique(np.round(cands, 3))
keep = [cands[0]]
for a in cands[1:]:
if a > keep[-1] * 1.02:
keep.append(a)
return keep
def _quantize_steps(v, step, upper):
base = v.min()
k = np.ceil(np.log(v / base) / np.log(step) - 1e-9)
return np.minimum(base * step ** k, upper)
def plan(binary, sel, p, divisible_by=16):
"""binary: (n, H, W) bool mask stack. Returns (boxes, info)."""
n, img_h, img_w = binary.shape
if not binary.any():
raise ValueError("all masks are empty, nothing to plan")
scale = p["crop_scale"]
if sel == "combined":
ys, xs = np.nonzero(binary.any(axis=0))
w0 = (xs.max() - xs.min() + 1) * scale
h0 = (ys.max() - ys.min() + 1) * scale
ar = p.get("aspect_ratio", 0.0)
if ar > 0:
if w0 < h0 * ar:
w0 = h0 * ar
else:
h0 = w0 / ar
w = min(math.ceil(w0 / divisible_by) * divisible_by,
(img_w // divisible_by) * divisible_by or img_w)
h = min(math.ceil(h0 / divisible_by) * divisible_by,
(img_h // divisible_by) * divisible_by or img_h)
x = min(max(0, round((xs.min() + xs.max() + 1) / 2 - w / 2)), img_w - w)
y = min(max(0, round((ys.min() + ys.max() + 1) / 2 - h / 2)), img_h - h)
box = {"x": int(x), "y": int(y), "width": int(w), "height": int(h)}
return [box] * n, {"aspect": w / h}
pre = _prep(binary, img_w, img_h, p)
m, s0 = pre["m"], pre["s0"]
bx0, bx1, by0, by1 = pre["bx0"], pre["bx1"], pre["by0"], pre["by1"]
if sel == "tracked":
Wf, Hf, xt, yt = _solve_tracked(pre, img_w, img_h, p)
w = min(math.ceil(Wf / divisible_by) * divisible_by,
(img_w // divisible_by) * divisible_by or img_w)
h = min(math.ceil(Hf / divisible_by) * divisible_by,
(img_h // divisible_by) * divisible_by or img_h)
lo_x = np.maximum(bx1 + 1 - w, 0)
hi_x = np.minimum(np.maximum(bx0, 0), img_w - w)
lo_y = np.maximum(by1 + 1 - h, 0)
hi_y = np.minimum(np.maximum(by0, 0), img_h - h)
if pre["floor"] > 0:
lo_x = np.maximum(lo_x, np.minimum(pre["flo_x"] - w, hi_x))
hi_x = np.minimum(hi_x, np.maximum(pre["fhi_x"], lo_x))
lo_y = np.maximum(lo_y, np.minimum(pre["flo_y"] - h, hi_y))
hi_y = np.minimum(hi_y, np.maximum(pre["fhi_y"], lo_y))
xi = _round_corridor(xt, lo_x, hi_x)[s0:s0 + n]
yi = _round_corridor(yt, lo_y, hi_y)[s0:s0 + n]
boxes = [{"x": int(xi[i]), "y": int(yi[i]),
"width": int(w), "height": int(h)} for i in range(n)]
return boxes, {"aspect": w / h, "W": Wf, "H": Hf}
# zoomed
ar_manual = p.get("aspect_ratio", 0.0)
if ar_manual > 0:
extw = bx1 - bx0 + 1
exth = by1 - by0 + 1
ar = min(max(ar_manual, extw.max() / img_h), img_w / exth.max())
best = _solve_zoomed_at(pre, ar, img_w, img_h, p)
if best is None:
raise RuntimeError("requested aspect_ratio cannot contain the subject")
tried = [best]
else:
tried = []
for ar in _aspect_candidates(pre, img_w, img_h, pre["known"].sum()):
sol = _solve_zoomed_at(pre, ar, img_w, img_h, p)
if sol is not None:
tried.append(sol)
if not tried:
raise RuntimeError("no feasible aspect candidate")
best = min(tried, key=lambda s: s["score"])
for ar in (best["ar"] * 0.93, best["ar"] * 1.075):
sol = _solve_zoomed_at(pre, ar, img_w, img_h, p)
if sol is not None and sol["score"] < best["score"]:
best = sol
ar = best["ar"]
h = best["h"]
if p.get("zoom_step", 1.0) > 1.0:
h = _quantize_steps(h, p["zoom_step"], min(img_h, img_w / ar))
w = np.minimum(h * ar, img_w)
bw = np.minimum(np.ceil(w - 1e-6), img_w).astype(int)
bh = np.minimum(np.ceil(h - 1e-6), img_h).astype(int)
lo_x = np.maximum(bx1 + 1 - bw, 0)
hi_x = np.minimum(np.maximum(bx0, 0), img_w - bw)
lo_y = np.maximum(by1 + 1 - bh, 0)
hi_y = np.minimum(np.maximum(by0, 0), img_h - bh)
if pre["floor"] > 0:
flo_x, fhi_x, flo_y, fhi_y = _yielded_floors(pre, ar, img_w, img_h)
lo_x = np.maximum(lo_x, np.minimum(flo_x - bw, hi_x))
hi_x = np.minimum(hi_x, np.maximum(fhi_x, lo_x))
lo_y = np.maximum(lo_y, np.minimum(flo_y - bh, hi_y))
hi_y = np.minimum(hi_y, np.maximum(fhi_y, lo_y))
xi = _round_corridor(best["x"], lo_x, hi_x)[s0:s0 + n]
yi = _round_corridor(best["y"], lo_y, hi_y)[s0:s0 + n]
bw, bh = bw[s0:s0 + n], bh[s0:s0 + n]
boxes = [{"x": int(min(max(xi[i], 0), img_w - bw[i])),
"y": int(min(max(yi[i], 0), img_h - bh[i])),
"width": int(bw[i]), "height": int(bh[i])} for i in range(n)]
return boxes, {"aspect": ar, "swept": ar_manual <= 0,
"candidates": len(tried)}
+291
View File
@@ -0,0 +1,291 @@
"""Stable subject-crop planning, vendored from drozbay/MaskVidExperiments.
Source: https://github.com/drozbay/MaskVidExperiments (commit d98cc89, v0.2.0)
Copyright (c) drozbay. Licensed GPL-3.0, the same licence this pack carries,
which is what makes vendoring it legal here.
Vendored rather than imported so the head-swap sampler has no runtime
dependency on that pack being installed. Behaviour is unchanged; only the
ComfyUI node classes were dropped (they stay in the original pack, where they
belong) and the planner import was repointed.
Why this code and not a crop of my own: naive per-frame crops around a mask
jitter in position and size, and a video model reads that jitter as camera
motion. These boxes are planned over the whole clip, so they hold still through
mask noise and occlusion and follow only sustained movement.
"""
"""Subject-tracked batch crop/uncrop for video mask workflows. The crop
boxes come from planner.py, which plans position, size, and shape jointly
over the whole clip."""
import logging
import math
import numpy as np
import torch
from scipy import ndimage
import comfy.utils
from . import bfs_crop_planner as planner
CATEGORY = "MaskVidExperiments"
def _clean_mask_stack(masks, value_thresh, min_pixels, min_frames):
"""Binarize a mask batch and drop noise blobs.
Connected components are labelled in 3D (time + space). A blob survives if
its peak single-frame area clears min_pixels and it is either subject-sized
(peak >= 50% of the largest blob, so fast motion that splits the subject
into per-frame components doesn't delete it) or persists min_frames frames.
"""
binary = masks.detach().cpu().numpy() > value_thresh
if not binary.any():
return binary
labels, count = ndimage.label(binary, structure=np.ones((3, 3, 3), dtype=bool))
if count == 0:
return binary
span = np.zeros(count + 1, dtype=np.int64)
for label, location in enumerate(ndimage.find_objects(labels), start=1):
if location is not None:
span[label] = location[0].stop - location[0].start
peak = np.zeros(count + 1, dtype=np.int64)
for frame in labels:
peak = np.maximum(peak, np.bincount(frame.ravel(), minlength=count + 1))
span[0] = 0
peak[0] = 0
subject = peak >= 0.5 * peak.max() if peak.max() > 0 else np.zeros_like(peak, dtype=bool)
keep = (peak >= min_pixels) & (subject | (span >= min_frames))
keep[0] = False
return keep[labels]
def _open_reconstruct(masks, value_thresh, radius, min_frames):
"""Morphological opening by reconstruction on a mask batch.
Erodes each frame spatially so blobs thinner than ~2*radius vanish, then
instead of dilating back, keeps the original shape of every blob with a
surviving core, so the subject's silhouette (thin protrusions included) is
untouched. Blobs are 3D components (time + space): a core surviving in any
frame keeps the whole track. border_value=1 so subjects cut off by the
frame edge are not eroded from that side.
"""
binary = masks.detach().cpu().numpy() > value_thresh
if not binary.any():
return binary
spatial = np.zeros((3, 3, 3), dtype=bool)
spatial[1] = [[0, 1, 0], [1, 1, 1], [0, 1, 0]]
eroded = ndimage.binary_erosion(binary, structure=spatial, iterations=radius, border_value=1)
labels, count = ndimage.label(binary, structure=np.ones((3, 3, 3), dtype=bool))
if count == 0:
return binary
keep = np.zeros(count + 1, dtype=bool)
keep[np.unique(labels[eroded])] = True
if min_frames > 1:
for label, location in enumerate(ndimage.find_objects(labels), start=1):
if location is not None and location[0].stop - location[0].start < min_frames:
keep[label] = False
keep[0] = False
return keep[labels]
def _resize_image(img_hwc, w, h, method="lanczos"):
if img_hwc.shape[0] == h and img_hwc.shape[1] == w:
return img_hwc
return comfy.utils.common_upscale(
img_hwc.movedim(-1, 0).unsqueeze(0), w, h, method, "disabled"
).squeeze(0).movedim(0, -1)
def _resize_mask(mask_hw, w, h, method="bilinear"):
if mask_hw.shape[0] == h and mask_hw.shape[1] == w:
return mask_hw
return comfy.utils.common_upscale(
mask_hw[None, None], w, h, method, "disabled"
)[0, 0]
def _upscale_size(w, h, megapixels, divisible_by):
"""Size scaling a w x h crop to about abs(megapixels) pixels on the
divisible_by grid. A positive value is a floor: None when already at or
above it. A negative value is a target, reached by downscaling too."""
scale = math.sqrt(abs(megapixels) * 1024 * 1024 / (w * h))
if megapixels > 0 and scale <= 1.0:
return None
return (max(1, round(w * scale / divisible_by)) * divisible_by,
max(1, round(h * scale / divisible_by)) * divisible_by)
def _cell_params(mode):
"""Raw planner dials for the standard node's (padding, prefer) cell.
The advanced node feeds the same dials directly. guaranteed floors the
full promise against every raw frame (floor window 1): a true
guarantee, so mask noise must be cleaned upstream. firm floors 70%
against the sustained tracks; flexible has no floor. In tracked mode
the crop never rescales, so prefer differentiates through the oversize
rent instead: stillness holds a bigger box to move less. In zoomed
mode the rent is never scaled by prefer (cheapening it tips whole
clips into one constant maximum size, killing zoom-follow); prefer
differentiates through the resize cost."""
sel = mode["mode"]
pad_level = mode.get("padding", "firm")
prefer = mode.get("prefer", "stillness")
floor = {"guaranteed": 1.0, "firm": 0.7, "flexible": 0.0}[pad_level]
still = prefer == "stillness"
if sel == "tracked":
oversize = 32.0 if still else 8.0
else:
oversize = float(mode.get("pad_surplus_tol", 16))
return {
"crop_scale": mode["crop_scale"],
"min_padding_allowed": floor,
"min_padding_allowed_window": 1 if pad_level == "guaranteed" else 16,
"pad_deficit_tol": 16.0,
"pad_surplus_tol": oversize,
"resize_cost": 2.0 if still else 1.0,
"movement_cost": 1.0,
"center_pull": 1e-4,
"end_tightening": 0.0,
"end_tightening_window": 80 if floor > 0 else 0,
"zoom_step": mode.get("zoom_step", 1.0),
"max_zoom_rate": 0.0,
"aspect_ratio": mode["aspect_ratio"],
"seamless_loop": mode.get("seamless_loop", False),
}
def _debug_summary(binary, boxes, scale, sel, info, output_size):
"""Brief per-run report: what shape the planner chose and how well the
plan delivered the padding promise."""
n = len(boxes)
worst = []
img_h, img_w = binary.shape[1:]
for i in range(n):
ys, xs = binary[i].nonzero()
if len(xs) == 0:
worst.append(np.nan)
continue
b = boxes[i]
bx1 = b["x"] + b["width"] - 1
by1 = b["y"] + b["height"] - 1
wx = (scale - 1) / 2 * (xs.max() - xs.min() + 1)
wy = (scale - 1) / 2 * (ys.max() - ys.min() + 1)
fr = []
if b["x"] > 0: fr.append((xs.min() - b["x"]) / max(wx, 1e-6))
if bx1 < img_w - 1: fr.append((bx1 - xs.max()) / max(wx, 1e-6))
if b["y"] > 0: fr.append((ys.min() - b["y"]) / max(wy, 1e-6))
if by1 < img_h - 1: fr.append((by1 - ys.max()) / max(wy, 1e-6))
worst.append(min(fr) if fr else np.nan)
worst = np.array(worst)
v = worst[~np.isnan(worst)]
cx = np.array([b["x"] + b["width"] / 2 for b in boxes])
cy = np.array([b["y"] + b["height"] / 2 for b in boxes])
travel = np.hypot(np.diff(cx), np.diff(cy)).sum()
ar = info["aspect"]
if info.get("swept"):
shape = f"{ar:.2f} (chosen from {info['candidates']} candidates)"
elif sel == "zoomed":
shape = f"{ar:.2f} (manual)"
else:
shape = f"{ar:.2f}"
ws = [b["width"] for b in boxes]
hs = [b["height"] for b in boxes]
lines = [
f"mode: {sel}",
f"frames: {n} ({img_w}x{img_h} source)",
f"aspect_ratio: {shape}",
f"smallest_box: {min(ws)}x{min(hs)}",
f"largest_box: {max(ws)}x{max(hs)}",
]
if sel == "zoomed":
lines.append(f"zoom_ratio: {max(hs) / max(min(hs), 1):.2f} "
"(largest box / smallest box)")
lines.append(f"crop region resizing: {max(ws)} x {max(hs)} -> "
f"{output_size[0]} x {output_size[1]}")
lines.append(f"movement: {travel:.0f}px "
"(total distance the box center travels)")
if len(v) and scale > 1.0:
lines.append(f"padding_promised: {(scale - 1) / 2:.0%} of the "
f"subject's size on each side (crop_scale {scale:.2f})")
lines.append(f"padding_worst: {max(v.min(), 0):.0%} of promised")
lines.append(f"padding_typical: {max(np.median(v), 0):.0%} of promised")
return "\n".join(lines)
def _plan_and_crop(original_images, masks, sel, p, divisible_by,
mask_threshold, upscale_megapixels=0.0):
if original_images.shape[0] != masks.shape[0]:
raise ValueError(f"original_images ({original_images.shape[0]}) and masks ({masks.shape[0]}) must have the same frame count")
if original_images.shape[1:3] != masks.shape[1:3]:
raise ValueError(f"original_images ({original_images.shape[2]}x{original_images.shape[1]}) and masks ({masks.shape[2]}x{masks.shape[1]}) must have the same dimensions")
if 0.0 < p["crop_scale"] < 1.0:
raise ValueError("crop_scale must be 0 (full frame) or at least 1.0")
full_frame = p["crop_scale"] <= 0.0
binary = masks.detach().cpu().numpy() > mask_threshold
if full_frame:
img_h, img_w = masks.shape[1], masks.shape[2]
boxes = [{"x": 0, "y": 0, "width": img_w, "height": img_h}
for _ in range(masks.shape[0])]
info = {"aspect": img_w / img_h}
else:
if not binary.any():
raise ValueError("all masks are empty, nothing to crop")
boxes, info = planner.plan(binary, sel, p, divisible_by)
bw = max(b["width"] for b in boxes)
bh = max(b["height"] for b in boxes)
size = None
img_method, mask_method = "lanczos", "bilinear"
if full_frame:
gw = math.ceil(bw / divisible_by) * divisible_by
gh = math.ceil(bh / divisible_by) * divisible_by
if (gw, gh) != (bw, bh):
size = (gw, gh)
elif sel == "zoomed":
# Target sized to the largest planned box: that stretch is ~1:1
# and everything else upscales rather than losing detail.
th = math.ceil(bh / divisible_by) * divisible_by
size = (math.ceil(th * info["aspect"] / divisible_by) * divisible_by, th)
if upscale_megapixels != 0:
up = _upscale_size(*(size or (bw, bh)), upscale_megapixels, divisible_by)
if up is not None:
size = up
img_method, mask_method = "bicubic", "nearest-exact"
debug = _debug_summary(binary, boxes, p["crop_scale"],
f"{sel} (full frame)" if full_frame else sel,
info, size or (bw, bh))
if size is not None:
tw, th = size
cropped_images = torch.stack([
_resize_image(original_images[i, b["y"]:b["y"] + b["height"], b["x"]:b["x"] + b["width"], :], tw, th, img_method)
for i, b in enumerate(boxes)
])
cropped_masks = torch.stack([
_resize_mask(masks[i, b["y"]:b["y"] + b["height"], b["x"]:b["x"] + b["width"]], tw, th, mask_method)
for i, b in enumerate(boxes)
])
else:
cropped_images = torch.stack([
original_images[i, b["y"]:b["y"] + b["height"], b["x"]:b["x"] + b["width"], :]
for i, b in enumerate(boxes)
])
cropped_masks = torch.stack([
masks[i, b["y"]:b["y"] + b["height"], b["x"]:b["x"] + b["width"]]
for i, b in enumerate(boxes)
])
return (cropped_images, cropped_masks,
[[b] for b in boxes], debug)
+110
View File
@@ -0,0 +1,110 @@
# BFS Head Swap Sampler (crop · mask · loop)
Runs an LTX head-swap LoRA and adds three things around it, each optional.
Connect only what you need — the node degrades to whatever is wired.
| what you connect | what you get |
|---|---|
| `guide_video` + `identity_image` | plain head swap, one pass |
| `+ subject_mask` | only the masked region is denoised |
| `+ crop_mode` | the swap runs inside a box around the subject, then is pasted back |
| `+ temporal_tile_size` | long clips sampled in chunks with overlap |
The LoRA is never asked to understand a mask or a crop. It keeps doing its one
job: the guide rides on `source_id 1`, the identity on `source_id 2`. Everything
else here is plumbing around it.
## The mask is native inpainting, not a LoRA feature
The mask becomes `latent["noise_mask"]`, which the guider consumes as
`denoise_mask` — ComfyUI's own inpainting path. Pixels outside the mask keep the
guide's own content, so **the original face stays visible to the model**. This is
deliberately different from a masked-hole recipe, where the region is painted out
before the model sees it.
Masks are reduced to the latent grid with **max**, not with ComfyUI's trilinear
resize. Trilinear blurs a mask across frames and lets the original content bleed
through the edit. Max keeps a latent cell that any masked pixel touches fully
editable.
**Watch the `latent_mask` output.** It has one frame per *latent* frame, not per
video frame, and that is the real resolution of the edit. A tight outline around
a face can nearly vanish at that scale — and then nothing changes, no matter how
good the LoRA is. If the mask looks thin there, raise `mask_grow`.
## Why crop, and which mode
At 512×288 a person filling a fifth of the frame leaves a face about 25 px tall.
No LoRA recovers identity from 25 px — it is missing pixels, not missing
training. Cropping the head region and sampling it full-frame gives that same
face 200–300 px, and the result is feathered back into untouched frames.
| `crop_mode` | behaviour |
|---|---|
| `off` | the whole frame is sampled. Use it first, to confirm the swap works at all. |
| `combined` | one static box around the subject's whole travel, held for the clip. No dependency. |
| `tracked` | constant-size box that stays still until the subject would leave it, then moves as little as possible. |
| `zoomed` | the box also follows the subject's size, planned over the whole clip, every crop resampled to one output size. |
`tracked` and `zoomed` use a planner vendored from
[drozbay/MaskVidExperiments](https://github.com/drozbay/MaskVidExperiments)
(GPL-3.0, the same licence this pack carries) — nothing extra to install. Those
boxes hold still through mask noise and occlusion, and **a jittering crop reads
to a video model as camera motion**, which is what a naive per-frame crop around
a mask produces.
`crop_scale` is the box as a multiple of the subject. At 1.5 the subject occupies
two thirds and the rest is margin. Keep neck and shoulders inside: a face-tight
crop is a framing the LoRA never saw in training, and it will show.
## Sizing, which is the one fiddly part
The connected `latent` sets the sampled size. When you crop, the box is smaller
than the frame, so the latent should match the box — the `debug` output prints
the size to use:
```
sample size 416x768 (connect EmptyLTXVLatentVideo at this size to avoid a resize)
```
If they disagree the node resamples the crop on the way back, which costs exactly
the sharpness the crop was there to buy. Run once, read `debug`, set
`EmptyLTXVLatentVideo`, run again.
**The latent must come from the graph.** LTX‑2.5 latents are AV — video and audio
nested together — and the node cannot fabricate that structure. Without one it
builds a plain video latent, warns, and the guide may not reach the model at all.
## Chunking
`temporal_tile_size` at 0 samples the clip in one pass. Set it to the length the
LoRA trained at (73 for the LTX head-swap recipe) for longer clips, and the node
slices the guide, the mask and the overlap together per chunk.
Chunked sampling with an AV latent is refused rather than silently corrupted —
slicing a nested latent in time is not a thing the node does yet.
## Reading the outputs
| output | what it answers |
|---|---|
| `images` | the result, with the crop pasted back |
| `mask_over_source` | is the mask where I think it is? Red over the original frames, green box for the crop |
| `cropped_guide` | exactly what the model was fed. If this is wrong, nothing downstream can be right |
| `crop_mask` | the mask after grow/blur, in the crop's pixel space |
| `latent_mask` | the mask as the sampler sees it — where a thin mask disappears |
| `latent` | the sampled latent |
| `debug` | crop mode and box, mask ops, tiling, the size to build the latent at |
## Suggested order when something looks wrong
1. `crop_mode: off`, no mask, `temporal_tile_size: 0`. This is the plain swap; if
it fails, the problem is the recipe or the LoRA, not this node.
2. Add the mask. Check `latent_mask` before judging the result.
3. Add the crop. Check `mask_over_source` for the box, and set the latent to the
size `debug` reports.
4. Only then raise the frame count and turn on chunking.
Sampling settings follow the LoRA's recipe: CFG 3–5 with a real negative, and no
LightX2V (CFG 1.0 means no amplification, and the reference stops being
amplified).
+50 -53
View File
@@ -21,11 +21,11 @@ about 25 px tall. No LoRA recovers identity from that. Cropping the head region
and sampling it full-frame gives the same face 200-300 px, then the result is
feathered back into the untouched frames.
The crop planner comes from drozbay's MaskVidExperiments when that pack is
installed (https://github.com/drozbay/MaskVidExperiments) — its boxes hold still
through mask noise and occlusion, which naive per-frame crops do not, and a
jittering crop reads to a video model as camera motion. Without the pack the node
falls back to one static box around the subject's whole travel.
The crop planner is vendored from drozbay's MaskVidExperiments (GPL-3.0, same as
this pack) in `bfs_subject_crop.py`, so nothing here depends on that pack being
installed. Its boxes hold still through mask noise and occlusion, which naive
per-frame crops do not, and a jittering crop reads to a video model as camera
motion.
"""
import logging
@@ -41,31 +41,32 @@ CATEGORY = "BFS/video"
# ─────────────────────────────────────────────────────────────────────────────
# optional dependency: drozbay/MaskVidExperiments crop planner
# crop planning (vendored from drozbay/MaskVidExperiments, GPL-3.0)
# ─────────────────────────────────────────────────────────────────────────────
def _mvex_planner():
"""`_plan_and_crop` from MaskVidExperiments, or None when it is not installed."""
import importlib
import os
import sys
def _planner():
"""The vendored crop planner (bfs_subject_crop), or None if it fails to import."""
try:
from .bfs_subject_crop import _plan_and_crop
return _plan_and_crop
except Exception as exc: # pragma: no cover
log.warning("vendored crop planner unavailable (%s); using the static box", exc)
return None
import folder_paths
for root in folder_paths.get_folder_paths("custom_nodes"):
path = os.path.join(root, "MaskVidExperiments", "nodes_subject_crop.py")
if not os.path.isfile(path):
continue
try:
spec = importlib.util.spec_from_file_location("_bfs_mvex_crop", path)
mod = importlib.util.module_from_spec(spec)
sys.modules["_bfs_mvex_crop"] = mod
spec.loader.exec_module(mod)
return mod._plan_and_crop
except Exception as exc: # pragma: no cover - depends on their internals
log.warning("MaskVidExperiments found but not usable (%s); using the static box", exc)
return None
return None
def _feather_ramp(h, w, feather, device):
"""Blend weights that fade in from the crop border, as (1,h,w,1)."""
if feather <= 0:
return torch.ones(1, h, w, 1, device=device)
ramp = torch.ones(h, w, device=device)
f = min(int(feather), h // 2, w // 2)
if f > 0:
edge = torch.linspace(0, 1, f, device=device)
ramp[:f, :] *= edge[:, None]
ramp[-f:, :] *= edge.flip(0)[:, None]
ramp[:, :f] *= edge[None, :]
ramp[:, -f:] *= edge.flip(0)[None, :]
return ramp[None, :, :, None]
def _static_box(masks, images, crop_scale, divisible_by, thresh=0.1):
@@ -109,7 +110,7 @@ def _mask_to_latent(masks, vae, latent_t, latent_h, latent_w):
ComfyUI would trilinearly resize the pixel mask instead, which blurs it
across frames and lets the original content bleed through the edit -- the
failure MaskVidExperiments' Mask To Latent Space node was written to fix.
failure drozbay's Mask To Latent Space node was written to fix.
Max keeps a latent cell that any masked pixel touches fully editable.
"""
m = masks.unsqueeze(1).float() # (N,1,H,W)
@@ -195,8 +196,8 @@ class BFSHeadSwapMaskedSampler:
"crop_mode": (["off", "combined", "tracked", "zoomed"], {"default": "off", "tooltip":
"Sample inside a box around the subject instead of the whole frame -- the fix for faces "
"that are too small to carry identity. combined: one static box for the clip. "
"tracked/zoomed: MaskVidExperiments' planner (installed separately); without it these "
"fall back to combined."}),
"tracked: a constant-size box that stays still until the subject would leave it. "
"zoomed: the box follows the subject's size too, planned over the whole clip."}),
"crop_scale": ("FLOAT", {"default": 1.5, "min": 1.0, "max": 4.0, "step": 0.05, "tooltip":
"Box size as a multiple of the subject. 1.5 leaves a third as margin. Keep neck and "
"shoulders in: a face-tight crop is a framing the LoRA never saw in training."}),
@@ -251,7 +252,7 @@ class BFSHeadSwapMaskedSampler:
if mode == "off" or masks is None:
return guide, masks, None, "crop: off"
planner = _mvex_planner() if mode in ("tracked", "zoomed") else None
planner = _planner() if mode in ("tracked", "zoomed") else None
if planner is not None:
try:
p = {"crop_scale": scale, "aspect_ratio": 0.0, "padding": "firm",
@@ -259,9 +260,9 @@ class BFSHeadSwapMaskedSampler:
"pad_surplus_tol": 16, "zoom_step": 1.0}
out = planner(guide, masks, mode, p, div, 0.1, 0.0)
cropped, cropped_masks, bboxes = out[0], out[1], out[2]
return cropped, cropped_masks, ("mvex", bboxes), f"crop: mvex/{mode}"
return cropped, cropped_masks, ("planned", bboxes), f"crop: planned/{mode}"
except Exception as exc:
log.warning("MaskVidExperiments planner failed (%s); using the static box", exc)
log.warning("crop planner failed (%s); using the static box", exc)
x0, y0, w, h = _static_box(masks, guide, scale, div)
cropped = guide[:, y0:y0 + h, x0:x0 + w, :]
@@ -272,37 +273,33 @@ class BFSHeadSwapMaskedSampler:
if ctx is None:
return result
kind, box = ctx
if kind == "mvex":
try:
import importlib
mod = importlib.import_module("_bfs_mvex_crop")
return mod.MVEx_SubjectUncropNode.execute(result, original, box, feather).result[0]
except Exception as exc:
log.warning("MaskVidExperiments uncrop failed (%s); pasting the static way", exc)
return result
if kind == "planned":
# one box per frame: paste each crop into its own box
out = original.clone()
n = min(result.shape[0], out.shape[0], len(box))
for i in range(n):
b = box[i][0] if isinstance(box[i], list) else box[i]
x0, y0, w, h = (int(b[0]), int(b[1]), int(b[2]), int(b[3]))
patch = result[i:i + 1]
if patch.shape[1] != h or patch.shape[2] != w:
patch = comfy.utils.common_upscale(
patch.movedim(-1, 1), w, h, "lanczos", "disabled").movedim(1, -1)
a = _feather_ramp(h, w, feather, patch.device)
out[i:i + 1, y0:y0 + h, x0:x0 + w, :] = (
a * patch + (1 - a) * out[i:i + 1, y0:y0 + h, x0:x0 + w, :])
return out
x0, y0, w, h = box
out = original.clone()[: result.shape[0]]
patch = result[: out.shape[0]]
# The sampled crop does not have to match the box: the connected latent
# sets the sampled size, and MaskVidExperiments' zoomed mode rescales
# sets the sampled size, and the zoomed crop mode rescales
# crops by design. Bring it back to the box before blending.
if patch.shape[1] != h or patch.shape[2] != w:
log.info("paste-back: resizing the sampled crop %dx%d -> box %dx%d",
patch.shape[2], patch.shape[1], w, h)
patch = comfy.utils.common_upscale(
patch.movedim(-1, 1), w, h, "lanczos", "disabled").movedim(1, -1)
if feather > 0:
ramp = torch.ones(h, w, device=patch.device)
f = min(feather, h // 2, w // 2)
if f > 0:
edge = torch.linspace(0, 1, f, device=patch.device)
ramp[:f, :] *= edge[:, None]
ramp[-f:, :] *= edge.flip(0)[:, None]
ramp[:, :f] *= edge[None, :]
ramp[:, -f:] *= edge.flip(0)[None, :]
a = ramp[None, :, :, None]
else:
a = 1.0
a = _feather_ramp(h, w, feather, patch.device)
out[:, y0:y0 + h, x0:x0 + w, :] = (
a * patch + (1 - a) * out[:, y0:y0 + h, x0:x0 + w, :]
)
+2 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "bfsnodes"
description = "Custom ComfyUI nodes including LTXV Edit Anything, Head-Swap/Anime2Real Bernini conditioning, the AMV Guide Builder, LTX Identity Transfer (IPT2V reference + source-phase RoPE), and MiniMax-H3 single-frame VAE loaders with tile control."
version = "1.31.1"
version = "1.32.0"
license = {file = "LICENSE"}
dependencies = [
"torch",
@@ -13,6 +13,7 @@ dependencies = [
"insightface==0.7.3",
"onnxruntime",
"transformers>=4.55.0",
"scipy",
]
# classifiers = [
# # For OS-independent nodes (works on all operating systems)