diff --git a/headswap_ltx_masked_sampler.py b/headswap_ltx_masked_sampler.py index bb38c17..3123f4a 100644 --- a/headswap_ltx_masked_sampler.py +++ b/headswap_ltx_masked_sampler.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index b76c45c..848760b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/tests/test_headswap_auto_config.py b/tests/test_headswap_auto_config.py index efdb372..6310758 100644 --- a/tests/test_headswap_auto_config.py +++ b/tests/test_headswap_auto_config.py @@ -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) diff --git a/web/docs/BFSHeadSwapMaskedSampler.md b/web/docs/BFSHeadSwapMaskedSampler.md index 71026ae..479aa4e 100644 --- a/web/docs/BFSHeadSwapMaskedSampler.md +++ b/web/docs/BFSHeadSwapMaskedSampler.md @@ -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: