Compare commits

...

10 Commits

Author SHA1 Message Date
f9d56a4db3 chore: remove internal planning docs from repo
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 17:01:47 +01:00
904f579c28 fix: move DifferentialDiffusion node inside Seam Fix group
Node 24 was at y=60, above Group 5's boundary (y=130). Moved to
y=140 so it's correctly inside the group and gets muted when the
user bypasses the Seam Fix pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 17:00:18 +01:00
06b42a610b refactor: vectorize gradient loop and fix DD node position
Replace per-pixel Python loop with vectorized torch.arange + slice
operations. Fix DifferentialDiffusion node position to avoid visual
overlap with SplitImageToTileList node 14 on the canvas.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:49:17 +01:00
93b0ac22cd docs: document gradient mode and differential diffusion
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:45:50 +01:00
c27bf2e898 feat: add DifferentialDiffusion node to seam fix workflow pass
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:45:01 +01:00
b3cfd507b8 fix: pass mode="binary" explicitly in test_values_are_binary
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:38:30 +01:00
cd00843b2e feat: add gradient mode to GenerateSeamMask for differential diffusion
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:37:17 +01:00
d46192295b test: add gradient mode tests for GenerateSeamMask
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:36:02 +01:00
7533b5a701 docs: add differential diffusion implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:34:20 +01:00
bdf29aafd1 docs: add differential diffusion seam fix design
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:32:46 +01:00
5 changed files with 154 additions and 38 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
__pycache__/ __pycache__/
*.pyc *.pyc
docs/plans/

View File

@@ -29,7 +29,7 @@ This installs one custom node (`Generate Seam Mask`) and provides an example wor
### Generate Seam Mask Node ### Generate Seam Mask Node
A small helper node that creates a binary mask image with white bands at tile seam positions. It replicates `SplitImageToTileList`'s tiling logic to place bands at the exact center of each overlap region. A helper node that creates a mask image with bands at tile seam positions. It replicates `SplitImageToTileList`'s tiling logic to place bands at the exact center of each overlap region. Supports binary (hard) and gradient (linear falloff) modes.
**Inputs:** **Inputs:**
| Parameter | Default | Description | | Parameter | Default | Description |
@@ -40,14 +40,15 @@ A small helper node that creates a binary mask image with white bands at tile se
| tile_height | 1024 | Tile height matching Pass 1 | | tile_height | 1024 | Tile height matching Pass 1 |
| overlap | 128 | Overlap matching Pass 1 | | overlap | 128 | Overlap matching Pass 1 |
| seam_width | 64 | Width of seam bands in pixels | | seam_width | 64 | Width of seam bands in pixels |
| mode | binary | `binary`: hard 0/1 mask. `gradient`: linear falloff for use with Differential Diffusion. |
**Output:** `IMAGE` — a mask with white bands at seam positions, black elsewhere. **Output:** `IMAGE` — a mask with bands at seam positions, black elsewhere.
## How It Works ## How It Works
The workflow chains standard ComfyUI nodes together. `SplitImageToTileList` outputs a list, and ComfyUI's auto-iteration runs all downstream nodes (VAEEncode, KSampler, VAEDecode) once per tile automatically. Scalar inputs (model, conditioning, VAE) are reused across tiles. `ImageMergeTileList` reassembles tiles using sine-weighted blending for smooth overlap transitions. The workflow chains standard ComfyUI nodes together. `SplitImageToTileList` outputs a list, and ComfyUI's auto-iteration runs all downstream nodes (VAEEncode, KSampler, VAEDecode) once per tile automatically. Scalar inputs (model, conditioning, VAE) are reused across tiles. `ImageMergeTileList` reassembles tiles using sine-weighted blending for smooth overlap transitions.
The seam fix pass uses `SetLatentNoiseMask` to restrict denoising to only the masked seam regions, leaving the rest of the image untouched. The seam fix pass uses `SetLatentNoiseMask` to restrict denoising to only the masked seam regions, leaving the rest of the image untouched. The example workflow uses gradient mode with a `DifferentialDiffusion` node so that seam centers receive full denoising while edges blend smoothly into the surrounding image.
## License ## License

View File

@@ -1,6 +1,6 @@
{ {
"last_node_id": 23, "last_node_id": 24,
"last_link_id": 37, "last_link_id": 39,
"nodes": [ "nodes": [
{ {
"id": 1, "id": 1,
@@ -12,7 +12,7 @@
"mode": 0, "mode": 0,
"inputs": [], "inputs": [],
"outputs": [ "outputs": [
{"name": "MODEL", "type": "MODEL", "slot_index": 0, "links": [1, 2]}, {"name": "MODEL", "type": "MODEL", "slot_index": 0, "links": [1, 38]},
{"name": "CLIP", "type": "CLIP", "slot_index": 1, "links": [3, 4]}, {"name": "CLIP", "type": "CLIP", "slot_index": 1, "links": [3, 4]},
{"name": "VAE", "type": "VAE", "slot_index": 2, "links": [5, 6, 7, 8]} {"name": "VAE", "type": "VAE", "slot_index": 2, "links": [5, 6, 7, 8]}
], ],
@@ -223,7 +223,7 @@
"pos": [2040, 350], "pos": [2040, 350],
"size": [300, 300], "size": [300, 300],
"flags": {}, "flags": {},
"order": 22, "order": 23,
"mode": 0, "mode": 0,
"inputs": [ "inputs": [
{"name": "images", "type": "IMAGE", "link": 37} {"name": "images", "type": "IMAGE", "link": 37}
@@ -238,7 +238,7 @@
"pos": [2370, 650], "pos": [2370, 650],
"size": [250, 170], "size": [250, 170],
"flags": {}, "flags": {},
"order": 12, "order": 13,
"mode": 0, "mode": 0,
"inputs": [ "inputs": [
{"name": "image_width", "type": "INT", "link": 18, "widget": {"name": "image_width"}}, {"name": "image_width", "type": "INT", "link": 18, "widget": {"name": "image_width"}},
@@ -248,7 +248,7 @@
{"name": "IMAGE", "type": "IMAGE", "slot_index": 0, "links": [27]} {"name": "IMAGE", "type": "IMAGE", "slot_index": 0, "links": [27]}
], ],
"properties": {"Node name for S&R": "GenerateSeamMask"}, "properties": {"Node name for S&R": "GenerateSeamMask"},
"widgets_values": [2048, 2048, 1024, 1024, 128, 64] "widgets_values": [2048, 2048, 1024, 1024, 128, 64, "gradient"]
}, },
{ {
"id": 14, "id": 14,
@@ -256,7 +256,7 @@
"pos": [2370, 200], "pos": [2370, 200],
"size": [250, 106], "size": [250, 106],
"flags": {}, "flags": {},
"order": 13, "order": 14,
"mode": 0, "mode": 0,
"inputs": [ "inputs": [
{"name": "image", "type": "IMAGE", "link": 25} {"name": "image", "type": "IMAGE", "link": 25}
@@ -273,7 +273,7 @@
"pos": [2370, 500], "pos": [2370, 500],
"size": [250, 106], "size": [250, 106],
"flags": {}, "flags": {},
"order": 14, "order": 15,
"mode": 0, "mode": 0,
"inputs": [ "inputs": [
{"name": "image", "type": "IMAGE", "link": 27} {"name": "image", "type": "IMAGE", "link": 27}
@@ -290,7 +290,7 @@
"pos": [2670, 500], "pos": [2670, 500],
"size": [200, 58], "size": [200, 58],
"flags": {}, "flags": {},
"order": 15, "order": 16,
"mode": 0, "mode": 0,
"inputs": [ "inputs": [
{"name": "image", "type": "IMAGE", "link": 29} {"name": "image", "type": "IMAGE", "link": 29}
@@ -307,7 +307,7 @@
"pos": [2670, 200], "pos": [2670, 200],
"size": [170, 46], "size": [170, 46],
"flags": {}, "flags": {},
"order": 16, "order": 17,
"mode": 0, "mode": 0,
"inputs": [ "inputs": [
{"name": "pixels", "type": "IMAGE", "link": 28}, {"name": "pixels", "type": "IMAGE", "link": 28},
@@ -325,7 +325,7 @@
"pos": [2670, 350], "pos": [2670, 350],
"size": [250, 46], "size": [250, 46],
"flags": {}, "flags": {},
"order": 17, "order": 18,
"mode": 0, "mode": 0,
"inputs": [ "inputs": [
{"name": "samples", "type": "LATENT", "link": 31}, {"name": "samples", "type": "LATENT", "link": 31},
@@ -343,10 +343,10 @@
"pos": [2970, 200], "pos": [2970, 200],
"size": [300, 474], "size": [300, 474],
"flags": {}, "flags": {},
"order": 18, "order": 19,
"mode": 0, "mode": 0,
"inputs": [ "inputs": [
{"name": "model", "type": "MODEL", "link": 2}, {"name": "model", "type": "MODEL", "link": 39},
{"name": "positive", "type": "CONDITIONING", "link": 10}, {"name": "positive", "type": "CONDITIONING", "link": 10},
{"name": "negative", "type": "CONDITIONING", "link": 12}, {"name": "negative", "type": "CONDITIONING", "link": 12},
{"name": "latent_image", "type": "LATENT", "link": 32} {"name": "latent_image", "type": "LATENT", "link": 32}
@@ -363,7 +363,7 @@
"pos": [3320, 200], "pos": [3320, 200],
"size": [170, 46], "size": [170, 46],
"flags": {}, "flags": {},
"order": 19, "order": 20,
"mode": 0, "mode": 0,
"inputs": [ "inputs": [
{"name": "samples", "type": "LATENT", "link": 33}, {"name": "samples", "type": "LATENT", "link": 33},
@@ -381,7 +381,7 @@
"pos": [3540, 200], "pos": [3540, 200],
"size": [250, 106], "size": [250, 106],
"flags": {}, "flags": {},
"order": 20, "order": 21,
"mode": 0, "mode": 0,
"inputs": [ "inputs": [
{"name": "image_list", "type": "IMAGE", "link": 34}, {"name": "image_list", "type": "IMAGE", "link": 34},
@@ -400,7 +400,7 @@
"pos": [3840, 200], "pos": [3840, 200],
"size": [400, 400], "size": [400, 400],
"flags": {}, "flags": {},
"order": 21, "order": 22,
"mode": 0, "mode": 0,
"inputs": [ "inputs": [
{"name": "images", "type": "IMAGE", "link": 26} {"name": "images", "type": "IMAGE", "link": 26}
@@ -408,11 +408,29 @@
"outputs": [], "outputs": [],
"properties": {"Node name for S&R": "SaveImage"}, "properties": {"Node name for S&R": "SaveImage"},
"widgets_values": ["UltimateSG/upscale"] "widgets_values": ["UltimateSG/upscale"]
},
{
"id": 24,
"type": "DifferentialDiffusion",
"pos": [2560, 140],
"size": [250, 46],
"flags": {},
"order": 12,
"mode": 0,
"inputs": [
{"name": "model", "type": "MODEL", "link": 38}
],
"outputs": [
{"name": "MODEL", "type": "MODEL", "slot_index": 0, "links": [39]}
],
"properties": {"Node name for S&R": "DifferentialDiffusion"},
"widgets_values": []
} }
], ],
"links": [ "links": [
[1, 1, 0, 10, 0, "MODEL"], [1, 1, 0, 10, 0, "MODEL"],
[2, 1, 0, 19, 0, "MODEL"], [38, 1, 0, 24, 0, "MODEL"],
[39, 24, 0, 19, 0, "MODEL"],
[3, 1, 1, 2, 0, "CLIP"], [3, 1, 1, 2, 0, "CLIP"],
[4, 1, 1, 3, 0, "CLIP"], [4, 1, 1, 3, 0, "CLIP"],
[5, 1, 2, 9, 1, "VAE"], [5, 1, 2, 9, 1, "VAE"],

View File

@@ -18,6 +18,8 @@ class GenerateSeamMask:
"tooltip": "Overlap used in the main tiled redraw pass."}), "tooltip": "Overlap used in the main tiled redraw pass."}),
"seam_width": ("INT", {"default": 64, "min": 8, "max": 512, "step": 8, "seam_width": ("INT", {"default": 64, "min": 8, "max": 512, "step": 8,
"tooltip": "Width of the seam bands to fix (in pixels)."}), "tooltip": "Width of the seam bands to fix (in pixels)."}),
"mode": (["binary", "gradient"], {"default": "binary",
"tooltip": "binary: hard 0/1 mask. gradient: linear falloff for use with Differential Diffusion."}),
} }
} }
@@ -41,7 +43,7 @@ class GenerateSeamMask:
p += stride p += stride
return positions return positions
def generate(self, image_width, image_height, tile_width, tile_height, overlap, seam_width): def generate(self, image_width, image_height, tile_width, tile_height, overlap, seam_width, mode="binary"):
mask = torch.zeros(1, image_height, image_width, 3) mask = torch.zeros(1, image_height, image_width, 3)
half_w = seam_width // 2 half_w = seam_width // 2
@@ -49,22 +51,45 @@ class GenerateSeamMask:
x_tiles = self._get_tile_positions(image_width, tile_width, overlap) x_tiles = self._get_tile_positions(image_width, tile_width, overlap)
y_tiles = self._get_tile_positions(image_height, tile_height, overlap) y_tiles = self._get_tile_positions(image_height, tile_height, overlap)
# Vertical seam bands (between horizontally adjacent tiles) if mode == "gradient":
for i in range(len(x_tiles) - 1): # Build 1D linear ramps for each seam, then take max across all bands
ovl_start = max(x_tiles[i][0], x_tiles[i + 1][0]) # Vertical seam bands
ovl_end = min(x_tiles[i][1], x_tiles[i + 1][1]) for i in range(len(x_tiles) - 1):
center = (ovl_start + ovl_end) // 2 ovl_start = max(x_tiles[i][0], x_tiles[i + 1][0])
x_start = max(0, center - half_w) ovl_end = min(x_tiles[i][1], x_tiles[i + 1][1])
x_end = min(image_width, center + half_w) center = (ovl_start + ovl_end) // 2
mask[:, :, x_start:x_end, :] = 1.0 x_start = max(0, center - half_w)
x_end = min(image_width, center + half_w)
xs = torch.arange(x_start, x_end, dtype=torch.float32)
vals = (1.0 - (xs - center).abs() / half_w).view(1, 1, -1, 1)
mask[:, :, x_start:x_end, :] = torch.max(mask[:, :, x_start:x_end, :], vals)
# Horizontal seam bands (between vertically adjacent tiles) # Horizontal seam bands
for i in range(len(y_tiles) - 1): for i in range(len(y_tiles) - 1):
ovl_start = max(y_tiles[i][0], y_tiles[i + 1][0]) ovl_start = max(y_tiles[i][0], y_tiles[i + 1][0])
ovl_end = min(y_tiles[i][1], y_tiles[i + 1][1]) ovl_end = min(y_tiles[i][1], y_tiles[i + 1][1])
center = (ovl_start + ovl_end) // 2 center = (ovl_start + ovl_end) // 2
y_start = max(0, center - half_w) y_start = max(0, center - half_w)
y_end = min(image_height, center + half_w) y_end = min(image_height, center + half_w)
mask[:, y_start:y_end, :, :] = 1.0 ys = torch.arange(y_start, y_end, dtype=torch.float32)
vals = (1.0 - (ys - center).abs() / half_w).view(1, -1, 1, 1)
mask[:, y_start:y_end, :, :] = torch.max(mask[:, y_start:y_end, :, :], vals)
else:
# Binary mode (original behavior)
for i in range(len(x_tiles) - 1):
ovl_start = max(x_tiles[i][0], x_tiles[i + 1][0])
ovl_end = min(x_tiles[i][1], x_tiles[i + 1][1])
center = (ovl_start + ovl_end) // 2
x_start = max(0, center - half_w)
x_end = min(image_width, center + half_w)
mask[:, :, x_start:x_end, :] = 1.0
for i in range(len(y_tiles) - 1):
ovl_start = max(y_tiles[i][0], y_tiles[i + 1][0])
ovl_end = min(y_tiles[i][1], y_tiles[i + 1][1])
center = (ovl_start + ovl_end) // 2
y_start = max(0, center - half_w)
y_end = min(image_height, center + half_w)
mask[:, y_start:y_end, :, :] = 1.0
return (mask,) return (mask,)

View File

@@ -89,12 +89,77 @@ def test_values_are_binary():
node = GenerateSeamMask() node = GenerateSeamMask()
result = node.generate(image_width=2048, image_height=2048, result = node.generate(image_width=2048, image_height=2048,
tile_width=1024, tile_height=1024, tile_width=1024, tile_height=1024,
overlap=128, seam_width=64) overlap=128, seam_width=64, mode="binary")
mask = result[0] mask = result[0]
unique = mask.unique() unique = mask.unique()
assert len(unique) <= 2, f"Mask should only contain 0.0 and 1.0, got {unique}" assert len(unique) <= 2, f"Mask should only contain 0.0 and 1.0, got {unique}"
def test_binary_mode_explicit():
"""Existing behavior works when mode='binary' is passed explicitly."""
node = GenerateSeamMask()
result = node.generate(image_width=2048, image_height=2048,
tile_width=1024, tile_height=1024,
overlap=128, seam_width=64, mode="binary")
mask = result[0]
unique = mask.unique()
assert len(unique) <= 2, f"Binary mode should only have 0.0 and 1.0, got {unique}"
assert mask[0, 0, 960, 0].item() == 1.0, "Center should be white"
def test_gradient_center_is_one():
"""In gradient mode, the seam center should be 1.0."""
node = GenerateSeamMask()
result = node.generate(image_width=2048, image_height=1024,
tile_width=1024, tile_height=1024,
overlap=128, seam_width=64, mode="gradient")
mask = result[0]
assert mask[0, 0, 960, 0].item() == 1.0, "Gradient center should be 1.0"
def test_gradient_edge_is_zero():
"""In gradient mode, the band edge should be 0.0."""
node = GenerateSeamMask()
result = node.generate(image_width=2048, image_height=1024,
tile_width=1024, tile_height=1024,
overlap=128, seam_width=64, mode="gradient")
mask = result[0]
assert mask[0, 0, 928, 0].item() == 0.0, "Band edge should be 0.0"
assert mask[0, 0, 927, 0].item() == 0.0, "Outside band should be 0.0"
def test_gradient_midpoint():
"""Halfway between center and edge should be ~0.5."""
node = GenerateSeamMask()
result = node.generate(image_width=2048, image_height=1024,
tile_width=1024, tile_height=1024,
overlap=128, seam_width=64, mode="gradient")
mask = result[0]
val = mask[0, 0, 944, 0].item()
assert abs(val - 0.5) < 0.01, f"Midpoint should be ~0.5, got {val}"
def test_gradient_intersection_uses_max():
"""Where H and V seam bands cross, the value should be the max of both."""
node = GenerateSeamMask()
result = node.generate(image_width=2048, image_height=2048,
tile_width=1024, tile_height=1024,
overlap=128, seam_width=64, mode="gradient")
mask = result[0]
assert mask[0, 960, 960, 0].item() == 1.0, "Intersection of two centers should be 1.0"
assert mask[0, 944, 960, 0].item() == 1.0, "On vertical center line, should be 1.0"
def test_gradient_no_seams_single_tile():
"""Gradient mode with single tile should also produce all zeros."""
node = GenerateSeamMask()
result = node.generate(image_width=512, image_height=512,
tile_width=1024, tile_height=1024,
overlap=128, seam_width=64, mode="gradient")
mask = result[0]
assert mask.sum().item() == 0.0, "Single tile should have no seams in gradient mode"
if __name__ == "__main__": if __name__ == "__main__":
test_output_shape() test_output_shape()
test_seam_positions() test_seam_positions()
@@ -103,4 +168,10 @@ if __name__ == "__main__":
test_no_spurious_bands() test_no_spurious_bands()
test_edge_tile_seam_position() test_edge_tile_seam_position()
test_values_are_binary() test_values_are_binary()
test_binary_mode_explicit()
test_gradient_center_is_one()
test_gradient_edge_is_zero()
test_gradient_midpoint()
test_gradient_intersection_uses_max()
test_gradient_no_seams_single_tile()
print("All tests passed!") print("All tests passed!")