Files
ausboss-ComfyUI-AusBoss/tests/test_edit_lock_helpers.py
T
ausboss 4dffaf3991 Edit Lock: rain, snow and haze come through whole
The node locked from the first step, where rain or snow over the whole
picture reads as a small local edit, so the effect was held back.

It now watches the first steps, undoing only the model's zoom and shift,
and decides once the picture has formed. When no place has changed
clearly, or the lock would free a wide part of the picture that holds no
clear change, it puts nothing back and only keeps the picture in place.
Local edits lock from that step on and end on the original as before.
2026-11-11 16:00:12 -06:00

374 lines
18 KiB
Python

"""Edit Lock: a local edit keeps the rest of the picture, a whole-picture edit is left alone."""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
import torch
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
if "nodes" in sys.modules and not hasattr(sys.modules["nodes"], "__path__"):
del sys.modules["nodes"]
from nodes._edit_lock_helpers import ( # noqa: E402
WHOLE_PICTURE,
EditLock,
box_blur,
broad_change,
change_core,
describe,
difference,
measure_redraw,
quiet_level,
restore_pixels,
soften,
)
from nodes.node_edit_lock import ( # noqa: E402
NODE_CLASS_MAPPINGS,
NODE_DISPLAY_NAME_MAPPINGS,
AusBossEditLock,
AusBossEditLockRestore,
)
H, W = 96, 80
BOX = (slice(30, 60), slice(20, 50)) # where the planted edit sits
def latents(seed: int = 0, video: bool = True):
gen = torch.Generator().manual_seed(seed)
# a real latent is smooth from cell to cell, so these are too
source, other = (box_blur(torch.randn(1, 24, H, W, generator=gen), 4) for _ in range(2))
source, other = source / source.std(), other / other.std()
redraw = 0.15 * torch.randn(1, 24, H, W, generator=gen) # the model's own redraw noise
if video:
return source[:, :, None], other[:, :, None], redraw[:, :, None]
return source, other, redraw
def local_prediction(source, other, redraw, tint: float = 0.0):
pred = source + redraw + tint
pred[..., BOX[0], BOX[1]] = other[..., BOX[0], BOX[1]]
return pred
def lock_for(source, mode="auto", tolerance=1.0):
lock = EditLock(mode=mode, tolerance=tolerance)
lock.latent = source.clone()
lock.source = torch.zeros(1, H * 16, W * 16, 3)
return lock
def run(lock, pred, sigma=1.0, steps=None):
args = {"denoised": pred, "sigma": torch.tensor([sigma])}
if steps is not None:
# the sampler's own list of noise levels, the way ComfyUI hands it to the hook
args["model_options"] = {"transformer_options": {"sample_sigmas": torch.tensor(steps)}}
return lock.step(args)
SIX = [1.0, 0.97, 0.89, 0.75, 0.53, 0.23, 0.0]
class EditLockTests(unittest.TestCase):
def test_local_edit_keeps_the_rest(self):
for video in (True, False):
source, other, redraw = latents(video=video)
pred = local_prediction(source, other, redraw, tint=0.2)
lock = lock_for(source)
out = run(lock, pred)
self.assertEqual(lock.status, "locked")
self.assertEqual(out.shape, pred.shape)
# far from the edit the original is back exactly, tint and redraw noise gone
self.assertTrue(torch.equal(out[..., :12, :], source[..., :12, :].to(out.dtype)))
self.assertTrue(torch.equal(out[..., 80:, :], source[..., 80:, :].to(out.dtype)))
# inside the edit the model's prediction is untouched
inner = (slice(36, 54), slice(26, 44))
self.assertTrue(torch.equal(out[..., inner[0], inner[1]], pred[..., inner[0], inner[1]]))
kept = lock.kept_share()
self.assertGreater(kept, 0.7)
self.assertLess(kept, 0.92)
self.assertIn("kept", describe(lock))
def test_report_says_what_to_do_when_nearly_all_was_kept(self):
source, other, redraw = latents(video=False)
lock = lock_for(source)
run(lock, local_prediction(source, other, redraw))
self.assertNotIn("set lock to off", describe(lock))
# a faint change everywhere (rain) and one small clear spot: nearly all of it is kept
pred = source + redraw
pred[..., 40:46, 30:36] = other[..., 40:46, 30:36]
lock = lock_for(source)
run(lock, pred)
self.assertGreater(lock.kept_share(), 0.95)
self.assertIn("set lock to off", describe(lock))
def test_it_watches_first_and_locks_when_the_noise_is_down(self):
source, other, redraw = latents(video=False)
lock = lock_for(source)
# the model's own redraw noise fades from step to step, as it does in a real run
for sigma, fade in ((1.0, 1.0), (0.97, 0.8), (0.89, 0.7)):
pred = local_prediction(source, other, redraw * fade)
out = run(lock, pred, sigma, SIX)
self.assertEqual(lock.status, "watching")
self.assertIsNone(lock.pixel_mask(H * 16, W * 16))
# nothing is put back yet, and with no zoom to undo the prediction passes as it is
self.assertTrue(torch.equal(out, pred))
pred = local_prediction(source, other, redraw * 0.65)
out = run(lock, pred, 0.75, SIX)
self.assertEqual(lock.status, "locked")
self.assertGreater(lock.top, 4.0)
self.assertTrue(torch.equal(out[..., :12, :], source[..., :12, :]))
inner = (slice(36, 54), slice(26, 44))
self.assertTrue(torch.equal(out[..., inner[0], inner[1]], pred[..., inner[0], inner[1]]))
def test_a_faint_change_everywhere_is_let_through(self):
source, _, redraw = latents(video=False)
rain = 0.25 * box_blur(torch.randn(1, 24, H, W, generator=torch.Generator().manual_seed(5)), 1)
for mode, status in (("auto", "soft"), ("always", "locked")):
lock = lock_for(source, mode=mode)
# nothing shows at the first look; then the rain comes in everywhere, and no spot changes clearly
for sigma, fade, wet in ((1.0, 1.0, 0.0), (0.97, 0.9, 0.5), (0.89, 0.8, 1.0)):
run(lock, source + redraw * fade + rain * wet, sigma, SIX)
self.assertEqual(lock.status, "watching")
pred = source + redraw * 0.7 + rain * 1.5
out = run(lock, pred, 0.75, SIX)
self.assertEqual(lock.status, status)
self.assertLess(lock.top, 4.0)
if mode == "auto":
self.assertLess(float((out - pred).abs().max()), 1e-4)
self.assertIn("stepped aside", describe(lock))
def test_a_wide_faint_change_with_no_peak_is_not_locked_in_patches(self):
source, _, redraw = latents(video=False)
gen = torch.Generator().manual_seed(9)
snow = torch.zeros(1, 24, H, W)
snow[..., :, :34] = 0.25 * torch.randn(1, 24, H, 34, generator=gen) # faint, over two fifths of the picture
theta = torch.tensor([[[1 / 1.015, 0.0, 0.0], [0.0, 1 / 1.015, 0.0]]])
grid = torch.nn.functional.affine_grid(theta, list(source.shape), align_corners=False)
zoomed = torch.nn.functional.grid_sample(source, grid, mode="bilinear", padding_mode="border", align_corners=False)
for mode, status in (("auto", "soft"), ("always", "locked")):
lock = lock_for(source, mode=mode)
for sigma, fade in ((1.0, 1.0), (0.97, 0.8), (0.89, 0.7)):
run(lock, source + redraw * fade, sigma, SIX)
pred = source + redraw * 0.65 + snow
out = run(lock, pred, 0.75, SIX)
self.assertEqual(lock.status, status)
self.assertGreater(lock.wide, 0.10)
self.assertLess(lock.peak, 4.0)
if mode == "always":
self.assertLess(lock.kept_share(), 0.8)
continue
self.assertIsNone(lock.pixel_mask(H * 16, W * 16))
self.assertLess(float((out - pred).abs().max()), 1e-4)
self.assertIn("held the picture in place", describe(lock))
# for the rest of the run only the zoom is undone
out = run(lock, zoomed, 0.53, SIX)
self.assertEqual(lock.status, "soft")
smooth = lambda x: box_blur(x, 2)[..., 6:-6, 6:-6]
self.assertLess(float((smooth(out) - smooth(source)).abs().mean()),
float((smooth(zoomed) - smooth(source)).abs().mean()) * 0.35)
def test_the_last_step_decides_when_the_noise_never_gets_that_low(self):
source, other, redraw = latents(video=False)
lock = lock_for(source)
two = [1.0, 0.92, 0.0]
run(lock, local_prediction(source, other, redraw), 1.0, two)
self.assertEqual(lock.status, "watching")
run(lock, local_prediction(source, other, redraw * 0.8), 0.92, two)
self.assertEqual(lock.status, "locked")
# a one-step run decides at once
lock = lock_for(source)
run(lock, local_prediction(source, other, redraw), 1.0, [1.0, 0.0])
self.assertEqual(lock.status, "locked")
def test_the_zoom_is_undone_while_it_watches(self):
source, other, redraw = latents(video=False)
theta = torch.tensor([[[1 / 1.015, 0.0, 0.0], [0.0, 1 / 1.015, 0.0]]])
grid = torch.nn.functional.affine_grid(theta, list(source.shape), align_corners=False)
zoomed = torch.nn.functional.grid_sample(source, grid, mode="bilinear", padding_mode="border", align_corners=False)
lock = lock_for(source)
out = run(lock, zoomed, 1.0, SIX)
self.assertEqual(lock.status, "watching")
# read on smoothed copies: this is about where things sit, and resampling costs fine detail either way
smooth = lambda x: box_blur(x, 2)[..., 6:-6, 6:-6]
off_before = float((smooth(zoomed) - smooth(source)).abs().mean())
off_after = float((smooth(out) - smooth(source)).abs().mean())
self.assertLess(off_after, off_before * 0.35)
def test_whole_picture_edit_is_left_alone(self):
source, other, _ = latents()
pred = 0.3 * source + other # a new picture over the old one
lock = lock_for(source)
out = run(lock, pred)
self.assertEqual(lock.status, "whole picture")
self.assertTrue(torch.equal(out, pred))
self.assertIsNone(lock.pixel_mask(H * 16, W * 16))
# and it stays aside for the rest of the run
self.assertTrue(torch.equal(run(lock, pred, 0.8), pred))
self.assertIn("stepped aside", describe(lock))
def test_a_slightly_larger_redraw_is_measured_and_is_not_an_edit(self):
source, other, redraw = latents(video=False)
# drawn 1.5% wider, 1% taller and half a cell to the right
theta = torch.tensor([[[1 / 1.015, 0.0, -1.0 / W], [0.0, 1 / 1.01, 0.0]]])
grid = torch.nn.functional.affine_grid(theta, list(source.shape), align_corners=False)
zoomed = torch.nn.functional.grid_sample(source, grid, mode="bilinear", padding_mode="border", align_corners=False)
move = measure_redraw(local_prediction(zoomed, other, redraw), source)
self.assertAlmostEqual(float(move[0]), 1 / 1.015 - 1, delta=0.002)
self.assertAlmostEqual(float(move[1]), 1 / 1.01 - 1, delta=0.002)
self.assertAlmostEqual(float(move[2]), -0.5, delta=0.15)
self.assertAlmostEqual(float(move[3]), 0.0, delta=0.15)
self.assertLess(float(measure_redraw(source + redraw, source).abs().max()), 0.02)
self.assertLess(float(broad_change(zoomed + redraw, source)), WHOLE_PICTURE / 2)
lock = lock_for(source)
out = run(lock, local_prediction(zoomed, other, redraw))
self.assertEqual(lock.status, "locked")
# the zoom did not read as an edit: only the planted box is free, and the rest is the original, unzoomed
self.assertGreater(lock.kept_share(), 0.7)
self.assertTrue(torch.equal(out[..., :12, :], source[..., :12, :]))
# an even tint is not one either; new light across the picture is
self.assertLess(float(broad_change(source + redraw + 0.6, source)), WHOLE_PICTURE / 2)
self.assertGreater(float(broad_change(source + redraw + 0.6 * other, source)), WHOLE_PICTURE)
def test_always_locks_a_whole_picture_edit(self):
source, other, _ = latents()
lock = lock_for(source, mode="always")
run(lock, 0.3 * source + other)
self.assertEqual(lock.status, "locked")
def test_changed_stays_changed_and_a_new_run_starts_clean(self):
source, other, redraw = latents()
lock = lock_for(source)
run(lock, local_prediction(source, other, redraw), 1.0)
first = lock.mask.clone()
run(lock, source + redraw, 0.8) # a later step that agrees with the original everywhere
self.assertTrue(torch.all(lock.mask >= first))
run(lock, source + redraw, 1.0) # the noise level went up: a new run
self.assertLess(float(lock.mask.max()), 0.5)
def test_given_mask_is_used_as_is(self):
source, other, redraw = latents()
lock = lock_for(source)
mask = torch.zeros(1, H * 16, W * 16)
mask[:, : H * 8] = 1.0 # the top half may change
lock.user_mask = mask
pred = 0.3 * source + other # would count as a whole-picture edit
out = run(lock, pred)
self.assertEqual(lock.status, "locked")
self.assertTrue(torch.equal(out[..., :40, :], pred[..., :40, :]))
self.assertTrue(torch.equal(out[..., 56:, :], source[..., 56:, :]))
self.assertIn("your mask", describe(lock))
def test_other_sizes_video_and_wrong_vae_do_nothing(self):
source, other, redraw = latents()
pred = local_prediction(source, other, redraw)
lock = lock_for(source[..., :48, :40].clone())
self.assertTrue(torch.equal(run(lock, pred), pred))
self.assertEqual(lock.status, "size")
lock = lock_for(source)
clip = pred.expand(-1, -1, 5, -1, -1)
self.assertTrue(torch.equal(run(lock, clip), clip))
self.assertEqual(lock.status, "video")
lock = lock_for(source[:, :16].clone())
self.assertTrue(torch.equal(run(lock, pred), pred))
self.assertEqual(lock.status, "vae")
for status in ("size", "video", "vae"):
lock.status = status
self.assertIn("did nothing", describe(lock))
def test_a_grid_one_cell_off_still_locks(self):
source, other, redraw = latents(video=False)
lock = lock_for(torch.nn.functional.interpolate(source, size=(H + 2, W + 1), mode="bilinear"))
run(lock, local_prediction(source, other, redraw))
self.assertEqual(lock.status, "locked")
self.assertGreater(lock.kept_share(), 0.7)
def test_tolerance_moves_the_line(self):
source, other, redraw = latents(video=False)
pred = source + redraw
pred[..., BOX[0], BOX[1]] += 0.45 * other[..., BOX[0], BOX[1]] # a faint change
magnitude = difference(pred, source)
quiet = quiet_level(magnitude)
loose = change_core(magnitude, quiet, 0.5)
strict = change_core(magnitude, quiet, 2.0)
self.assertGreater(float(loose[..., 40:50, 30:40].mean()), 0.9)
self.assertEqual(float(strict.max()), 0.0)
def test_faint_change_counts_only_beside_a_clear_one(self):
quiet = torch.tensor([1.0])
magnitude = torch.ones(1, 1, 40, 40)
magnitude[..., 5:15, 5:15] = 2.2 # faint, alone: redraw noise
magnitude[..., 22:34, 22:34] = 2.2 # faint ...
magnitude[..., 26:30, 26:30] = 4.0 # ... around a clear change
core = change_core(magnitude, quiet)
self.assertEqual(float(core[..., 5:15, 5:15].max()), 0.0)
self.assertEqual(float(core[..., 22:34, 22:34].min()), 1.0)
self.assertEqual(float(core.sum()), 144.0)
# what an earlier step held stays, and faint change that touches it joins
held = torch.zeros_like(core)
held[..., 8:10, 8:10] = 1.0
again = change_core(torch.ones(1, 1, 40, 40), quiet, held=held)
self.assertEqual(float(again.sum()), 4.0)
joined = change_core(magnitude, quiet, held=held)
self.assertEqual(float(joined[..., 5:15, 5:15].min()), 1.0)
soft = soften(core)
self.assertTrue(torch.all(soft[..., 22:34, 22:34] == 1.0))
self.assertGreater(float(soft[0, 0, 21, 28]), 0.5)
self.assertEqual(float(soft[0, 0, 2, 2]), 0.0)
def test_restore_gives_the_original_pixels_back(self):
gen = torch.Generator().manual_seed(1)
original = torch.rand(1, 128, 160, 3, generator=gen)
result = torch.rand(2, 128, 160, 3, generator=gen)
mask = torch.zeros(1, 128, 160)
mask[:, 40:80, 60:100] = 1.0
out, edit = restore_pixels(result, original, mask, 8)
self.assertEqual(out.shape, result.shape)
self.assertEqual(edit.shape, (2, 128, 160))
self.assertTrue(torch.equal(out[:, :16], original[:, :16].expand(2, -1, -1, -1)))
self.assertTrue(torch.equal(out[:, 40:80, 60:100], result[:, 40:80, 60:100]))
# the fade lies outside the edit
self.assertTrue(torch.all(edit[:, 40:80, 60:100] == 1.0))
self.assertGreater(float(edit[0, 36, 80]), 0.0)
# another size: the original is resized to the result
small = torch.rand(1, 64, 80, 3, generator=gen)
out, _ = restore_pixels(result, small, mask, 0)
self.assertEqual(out.shape, result.shape)
def test_restore_node_passes_through_when_nothing_was_locked(self):
lock = EditLock()
lock.status = "whole picture"
image = torch.rand(1, 32, 48, 3)
out, mask, report = AusBossEditLockRestore().restore(image, lock, 16)
self.assertTrue(torch.equal(out, image))
self.assertEqual(mask.shape, (1, 32, 48))
self.assertTrue(torch.all(mask == 1.0))
self.assertIn("stepped aside", report)
def test_restore_node_after_a_locked_run(self):
source, other, redraw = latents()
lock = lock_for(source)
lock.source = torch.rand(1, H * 4, W * 4, 3)
run(lock, local_prediction(source, other, redraw))
image = torch.rand(1, H * 4, W * 4, 3)
out, mask, report = AusBossEditLockRestore().restore(image, lock, 4)
self.assertTrue(torch.equal(out[:, :24], lock.source[:, :24]))
self.assertTrue(torch.equal(out[:, 150:210, 110:170], image[:, 150:210, 110:170]))
self.assertGreater(float(mask[0, 180, 140]), 0.99)
self.assertIn("kept", report)
def test_mappings(self):
keys = {"AUSBOSS_NODES_EditLock", "AUSBOSS_NODES_EditLockRestore"}
self.assertEqual(set(NODE_CLASS_MAPPINGS), keys)
self.assertEqual(set(NODE_DISPLAY_NAME_MAPPINGS), keys)
self.assertIs(NODE_CLASS_MAPPINGS["AUSBOSS_NODES_EditLock"], AusBossEditLock)
for name in NODE_DISPLAY_NAME_MAPPINGS.values():
self.assertTrue(name.endswith(" 🆎"))
if __name__ == "__main__":
unittest.main()