Stop the auto config from blowing the mask hole open (1.37.1)
Two compounding mistakes in 1.37.0's derivation, reported as a hole much larger than the head with the new head generated floating inside it: - the upward slack was scaled by the mask's HEIGHT (hh * delta / tilt). Height swings with how much neck the mask took, and a portrait-shaped identity image (tilt < 1) multiplied it further: a 300x400 head with a 0.6 reference asked for 136 px above. Every slack is now a fraction of the head's WIDTH, the stable dimension, capped at 25% sideways and 30% up. - latent_mask_dilate was derived from mask_grow and forced to at least one cell, which added a whole 32 px on EVERY side -- including down, which the pixel grow deliberately holds at the base -- on top of a grow that already carried the slack. It is 0 now: _mask_to_latent reduces with MAX, so any cell the mask touches is fully editable already. The same head asked for 4 cells, 128 px, before this. Also undo the guider injections in reverse: with several chunks each injection captured the state the previous one left, so undoing forwards restored chunk 1's specs onto the guider instead of clearing them. Tests pin both: growth stays under 45% of the head's width across every reference aspect and headroom, the latent dilation stays 0, and the crop box always holds the grow plus the feather.
This commit is contained in:
@@ -185,9 +185,13 @@ def _auto_config(st, cell, ref_aspect=None, headroom=1.15):
|
||||
tilt = min(1.6, max(0.6, ref / src_aspect))
|
||||
delta = headroom - 1.0
|
||||
|
||||
# every slack is a fraction of the head's WIDTH, capped. Width is the stable
|
||||
# dimension of a head mask -- height swings with how much neck the mask took,
|
||||
# and scaling the upward slack by it once produced holes far larger than the
|
||||
# head, with the new head floating inside the regenerated area.
|
||||
base = 0.06 * hw
|
||||
want_x = base + 0.5 * hw * delta * tilt # half the extra width per side
|
||||
want_up = 2.0 * base + hh * delta / tilt # hair goes up
|
||||
want_x = base + min(0.25 * hw, 0.5 * hw * delta * tilt)
|
||||
want_up = 1.5 * base + min(0.30 * hw, hw * delta / tilt) # hair goes up
|
||||
want_down = base # never eat into the neck
|
||||
|
||||
# ── the box has to be big enough to hold all of that plus a ramp ─────────
|
||||
@@ -215,7 +219,11 @@ def _auto_config(st, cell, ref_aspect=None, headroom=1.15):
|
||||
# floor, never round: a cap that rounds up is a ramp that reaches past the margin
|
||||
feather = int(min(64, max(4, int(0.5 * min(left_x, left_y)))))
|
||||
blur = int(min(48, max(2, round(0.03 * hw)))) # paste-back softness, not denoise
|
||||
cells = int(max(1, round(max(grow_x, grow_up) / max(1, cell))))
|
||||
# NOT derived from the grow: the pixel grow above already carries the slack,
|
||||
# and _mask_to_latent reduces with MAX, so any cell the mask touches is
|
||||
# already fully editable. Dilating here on top of that added a whole 32 px
|
||||
# cell on every side for nothing.
|
||||
cells = 0
|
||||
frames = 1 if st["step"] > 0.15 * hw else 0 # a fast head needs slack along time
|
||||
|
||||
ref_note = (f"reference aspect {ref:.2f} vs mask {src_aspect:.2f} (tilt {tilt:.2f}), "
|
||||
@@ -757,7 +765,10 @@ class BFSHeadSwapMaskedSampler:
|
||||
finally:
|
||||
# hand the guider back the way it was found, whatever happened above:
|
||||
# an exception here is exactly the case that would poison the next run
|
||||
for inj in injections:
|
||||
# reverse order: with several chunks each injection captured the state
|
||||
# the previous one left, so undoing forwards would restore chunk 1's
|
||||
# specs onto the guider instead of clearing them
|
||||
for inj in reversed(injections):
|
||||
inj.undo()
|
||||
|
||||
samples = out_latents[0] if len(out_latents) == 1 else torch.cat(out_latents, dim=2)
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "bfsnodes"
|
||||
description = "Custom ComfyUI nodes for reference-guided video: the BFS Head Swap Sampler (auto-configured crop, mask inpainting and temporal chunking in one node), LTX Multiple Controls and Identity Transfer (IPT2V reference + source-phase RoPE), LTXV Edit Anything, Head-Swap/Anime2Real Bernini conditioning, the AMV Guide Builder, and MiniMax-H3 single-frame VAE loaders with tile control."
|
||||
version = "1.37.0"
|
||||
version = "1.37.1"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"torch",
|
||||
|
||||
@@ -113,19 +113,40 @@ class AutoConfigTest(unittest.TestCase):
|
||||
self.assertLessEqual(down, left + 1)
|
||||
|
||||
def test_a_wider_reference_pushes_the_slack_sideways(self):
|
||||
wide = auto([(900, 300, 120, 160)] * 8, ref_aspect=1.4, headroom=1.5)
|
||||
tall = auto([(900, 300, 120, 160)] * 8, ref_aspect=0.5, headroom=1.5)
|
||||
# below the caps, where the tilt still has room to act
|
||||
wide = auto([(900, 300, 120, 160)] * 8, ref_aspect=1.4, headroom=1.15)
|
||||
tall = auto([(900, 300, 120, 160)] * 8, ref_aspect=0.5, headroom=1.15)
|
||||
self.assertGreater(sides(wide)[2], sides(tall)[2])
|
||||
self.assertGreater(sides(tall)[0], sides(wide)[0])
|
||||
|
||||
def test_the_box_grows_to_hold_what_the_mask_needs(self):
|
||||
tight = auto([(900, 300, 120, 160)] * 8, headroom=1.0)
|
||||
roomy = auto([(900, 300, 120, 160)] * 8, headroom=1.6)
|
||||
self.assertGreater(roomy["crop_scale"], tight["crop_scale"])
|
||||
def test_the_box_always_holds_the_grow_plus_the_ramp(self):
|
||||
# the real invariant: whatever the mask grew by, the crop still has room
|
||||
# for it AND for the feather, or the paste fades the head
|
||||
for hr in (1.0, 1.15, 1.5, 2.0):
|
||||
for ref in (0.5, 1.0, 1.6):
|
||||
a = auto([(900, 300, 120, 160)] * 8, ref_aspect=ref, headroom=hr)
|
||||
up, down, left, _ = sides(a)
|
||||
margin_x = 120 * (a["crop_scale"] - 1.0) / 2.0
|
||||
margin_y = 160 * (a["crop_scale"] - 1.0) / 2.0
|
||||
self.assertLessEqual(left + a["uncrop_feather"], margin_x + 1)
|
||||
self.assertLessEqual(max(up, down) + a["uncrop_feather"], margin_y + 1)
|
||||
|
||||
def test_latent_dilation_is_at_least_one_cell(self):
|
||||
def test_latent_dilation_is_not_added_on_top_of_the_pixel_grow(self):
|
||||
# _mask_to_latent reduces with MAX, so a cell the mask touches is already
|
||||
# editable; dilating here too added a whole 32 px cell on every side and
|
||||
# blew the hole up far past the head
|
||||
for w in (40, 100, 600):
|
||||
self.assertGreaterEqual(auto([(400, 200, w, w)] * 8)["latent_mask_dilate"], 1)
|
||||
self.assertEqual(auto([(400, 200, w, w)] * 8)["latent_mask_dilate"], 0)
|
||||
|
||||
def test_growth_stays_a_sane_fraction_of_the_head(self):
|
||||
# the failure this pins: slack scaled by the mask's HEIGHT produced a
|
||||
# hole much larger than the head, with the new head floating inside it
|
||||
for hw, hh in ((120, 160), (300, 400), (600, 900)):
|
||||
for ref, hr in ((0.5, 1.15), (1.0, 1.15), (0.75, 1.5), (1.4, 2.0)):
|
||||
up, down, left, _ = sides(auto([(300, 100, hw, hh)] * 8,
|
||||
ref_aspect=ref, headroom=hr))
|
||||
self.assertLessEqual(up, 0.45 * hw, f"up runs away at {hw}x{hh} ref {ref} hr {hr}")
|
||||
self.assertLessEqual(left, 0.40 * hw, f"sides run away at {hw}x{hh} ref {ref} hr {hr}")
|
||||
|
||||
def test_a_fast_head_gets_slack_along_time(self):
|
||||
still = auto([(800, 300, 100, 130)] * 20)
|
||||
|
||||
@@ -33,10 +33,10 @@ moves between frames — and sets all of it from those numbers:
|
||||
|---|---|---|
|
||||
| `crop_mode` | `off` when the head is ≥45% of the frame width; `zoomed` when its size changes >35%; `tracked` when it travels more than a quarter of the box; else `combined` | a crop buys resolution the head does not already have, and nothing else |
|
||||
| `crop_scale` | 1.8, clamped so the box still fits in the frame | head plus neck and shoulders — a face-tight crop is a framing the LoRA never saw |
|
||||
| `mask_grow` | 6% of the head's width, plus the headroom slack — **per side**: up ≫ sideways ≫ down | hair overflows upward and sideways; growing down only eats the neck and collar, which stay the guide's |
|
||||
| `mask_grow` | 6% of the head's **width**, plus the capped headroom slack — **per side**: up ≫ sideways ≫ down | hair overflows upward and sideways; growing down only eats the neck and collar, which stay the guide's |
|
||||
| `mask_blur` | 3% of the head's width | paste-back softness (the denoise mask is binarised anyway) |
|
||||
| `uncrop_feather` | half the margin between mask and crop border, floored | **the seam fix**: a ramp wider than that margin fades the head itself |
|
||||
| `latent_mask_dilate` | `mask_grow` expressed in whole latent cells, at least 1 | guarantees the head sits inside editable cells instead of clipping at a boundary |
|
||||
| `latent_mask_dilate` | left at 0 | the pixel grow already carries the slack, and the pixel→latent reduction is a MAX, so any cell the mask touches is editable already — adding cells here stacks a whole 32 px on every side |
|
||||
| `latent_mask_dilate_frames` | 1 when the head moves more than 15% of its width between frames | a fast head needs slack along time too |
|
||||
|
||||
### The mask is the old head, not the new one
|
||||
@@ -59,6 +59,12 @@ visibly larger head, drop it to 1.0 when the two heads match.
|
||||
Growth is never symmetric: down is held at the base 6% no matter what, because
|
||||
the neck and collar below the jaw have to stay the guide's own pixels.
|
||||
|
||||
Every slack is a fraction of the head's **width** and capped there (25% sideways,
|
||||
30% up). Width is the stable dimension of a head mask — height swings with how
|
||||
much neck the mask happened to take — and scaling the upward slack by height,
|
||||
as 1.37.0 did, produced holes far larger than the head, with the new head
|
||||
generated floating inside the regenerated area. Fixed in 1.37.1.
|
||||
|
||||
It needs `subject_mask`; without one there is nothing to measure and the widget
|
||||
values are kept. The widgets it overrides stay visible but are ignored, and the
|
||||
`debug` output prints exactly what it chose:
|
||||
|
||||
Reference in New Issue
Block a user