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:
alisson-anjos
2026-09-01 14:29:22 +00:00
parent 5dd563661f
commit a2b499ce3e
4 changed files with 53 additions and 15 deletions
+15 -4
View File
@@ -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
View File
@@ -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",
+29 -8
View File
@@ -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)
+8 -2
View File
@@ -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: