Compare commits

..
Author SHA1 Message Date
xmarre 6153443986 Merge pull request #13 from xmarre/codex/fix-detailer-mask-for-slrd
Fix detailer hook to use effective guider semantics
2026-03-24 15:24:39 +01:00
xmarre 9f1870e350 Use effective guider in detailer hook sampling 2026-03-24 15:15:39 +01:00
xmarre 77b238a672 fix: align detailer hook noise and bump version 2026-03-24 14:16:01 +01:00
xmarre bf6c517a4c Bump version to 1.0.31 2026-03-24 13:52:09 +01:00
xmarre 0f076a886f Fix detailer hook mask support propagation 2026-03-24 13:51:36 +01:00
xmarre b166cd8741 Remove implicit detailer mask from SLRD config 2026-03-23 05:09:06 +01:00
xmarre 751b8e771c Bump version to 1.0.29 2026-03-23 04:03:51 +01:00
xmarre 3b5a6b7971 Preserve incoming noise for masked detailer runs 2026-03-23 04:03:25 +01:00
xmarre a8e94be4c4 Align impact masks and bump version 2026-03-23 01:49:14 +01:00
xmarre a2b713a9ea Bump version to 1.0.27 2026-03-23 01:08:07 +01:00
xmarre 0af4e4194a Restore denoise mask for impact sampler 2026-03-23 01:03:36 +01:00
xmarre dae4b0906e Fix detailer mask handling and bump version 2026-03-23 00:19:17 +01:00
xmarre e41982cdab Bump version to 1.0.25 2026-03-22 23:34:13 +01:00
xmarre e04f34897a Isolate detailer guider and preserve planner model_options 2026-03-22 23:33:28 +01:00
xmarre 132609e3d2 Bump version to 1.0.24 2026-03-22 22:40:36 +01:00
xmarre 0d4e8d20e2 Binarize detailer masks for SLRD support 2026-03-22 22:39:20 +01:00
xmarre a31f9be2d0 Bump version to 1.0.23 2026-03-22 21:16:46 +01:00
xmarre 25267e588a Merge pull request #12 from xmarre/codex/align-slrd-mask-with-sampler
Fix detailer SLRD masks to use the runtime latent mask
2026-03-22 21:15:35 +01:00
xmarre 2d4fe5ea47 Use runtime detailer masks for scale-locked sampling 2026-03-22 21:05:37 +01:00
xmarre d015307e1a Merge pull request #10 from xmarre/codex/fix-facedetailer-latent-mask-mixup
Restore proxy sampling path for SLRD Impact detailer
2026-03-15 14:46:30 +01:00
xmarre db6dfb5949 Restore Impact detailer proxy path 2026-03-15 14:43:40 +01:00
xmarre 6667f62b75 Merge branch 'main' into codex/fix-facedetailer-latent-mask-mixup 2026-03-15 14:34:22 +01:00
xmarre 189afe2e45 Merge pull request #11 from xmarre/coderabbitai/docstrings/420c8a4
📝 Add docstrings to `codex/fix-facedetailer-latent-mask-mixup`
2026-03-15 14:33:07 +01:00
xmarre e933378b43 Keep planner on CFG guider 2026-03-15 14:19:04 +01:00
3 changed files with 108 additions and 69 deletions
+6 -5
View File
@@ -55,13 +55,15 @@ One-node version of the modular custom-sampling workflow. Internally it now call
Experimental Impact Pack / FaceDetailer integration path. Experimental Impact Pack / FaceDetailer integration path.
This node returns a `DETAILER_HOOK` object that preserves the FaceDetailer crop mask, records the encoded crop latent as the anchor in `post_encode(...)`, and applies one-shot residual/manifold correction in `pre_decode(...)`. This node returns a `DETAILER_HOOK` object that captures the FaceDetailer crop mask in `post_upscale(...)` and drives the masked residual/manifold correction through the hook's sampler path.
This provider no longer advertises sampler-runtime controls that do not participate in its one-shot hook path. The exposed knobs are the ones that still affect the masked latent correction directly: The live hook path is `pre_ksample(...)` plus the custom sampler/runtime integration, not the inert `post_encode(...)` / `pre_decode(...)` pair.
This provider no longer advertises sampler-runtime controls that do not participate in its live sampler-driven hook path. The exposed knobs are the ones that still affect the masked latent correction directly:
- residual lock strength and cutoffs, - residual lock strength and cutoffs,
- optional manifold companding controls, - optional manifold companding controls,
- optional `lock_mask` / `manifold_mask`, which are intersected with the FaceDetailer mask. - optional `lock_mask` / `manifold_mask`, which are intersected with the FaceDetailer support mask.
Compatibility note: this provider's input signature changed when the inert detailer-only sampler/runtime controls were removed. Older saved workflows that used the previous `Scale-Locked Detailer Hook Provider` input surface will need to be re-wired to the current node inputs. Compatibility note: this provider's input signature changed when the inert detailer-only sampler/runtime controls were removed. Older saved workflows that used the previous `Scale-Locked Detailer Hook Provider` input surface will need to be re-wired to the current node inputs.
@@ -90,9 +92,8 @@ A public guider node alone does not make FaceDetailer use SLRD. `Scale-Locked De
The current hook implementation is duck-typed rather than source-verified against a live Impact Pack checkout: The current hook implementation is duck-typed rather than source-verified against a live Impact Pack checkout:
- FaceDetailer mask capture via `post_upscale(...)`, - FaceDetailer mask capture via `post_upscale(...)`,
- self-anchoring via `post_encode(...)`,
- alias-tolerant request capture via `pre_ksample(...)`, - alias-tolerant request capture via `pre_ksample(...)`,
- masked latent correction via `pre_decode(...)`. - masked latent correction via the custom sampler/runtime path.
Because Impact Pack's internal contracts can move, this integration should be treated as unverified runtime glue until it is exercised against the current Impact Pack source. Because Impact Pack's internal contracts can move, this integration should be treated as unverified runtime glue until it is exercised against the current Impact Pack source.
+101 -63
View File
@@ -9,11 +9,12 @@ import math
import torch import torch
import comfy.samplers import comfy.samplers
from .slrd_core import build_nested_noise, clone_latent, init_scale_lock_state, resize_4d_tensor from .slrd_core import build_nested_noise, clone_latent, init_scale_lock_state, resize_4d_tensor, resize_mask
from .slrd_runtime import ( from .slrd_runtime import (
LOCK_SCHEDULE_OPTIONS, LOCK_SCHEDULE_OPTIONS,
MID_SCHEDULE_OPTIONS, MID_SCHEDULE_OPTIONS,
ScaleLockConfig, ScaleLockConfig,
_resolve_sampler_device,
apply_scale_lock_to_noise_prediction, apply_scale_lock_to_noise_prediction,
build_runtime_context_from_advanced, build_runtime_context_from_advanced,
clean_latent, clean_latent,
@@ -28,6 +29,7 @@ from .slrd_runtime import (
sample_with_runtime, sample_with_runtime,
apply_scale_lock_to_guider, apply_scale_lock_to_guider,
clone_guider_for_scale_lock, clone_guider_for_scale_lock,
restore_original_predict_noise,
) )
@@ -654,6 +656,21 @@ def _combine_masks(*masks: torch.Tensor | None) -> torch.Tensor | None:
return None if combined is None else combined.clamp(0.0, 1.0) return None if combined is None else combined.clamp(0.0, 1.0)
_DETAILER_SUPPORT_MASK_EPS = 1e-3
def _detailer_support_mask_for_like(mask: torch.Tensor | None, like: torch.Tensor) -> torch.Tensor | None:
expanded = _expand_mask_for_like(mask, like)
if expanded is None:
return None
# Impact detailer masks are compositing weights. Preserving their soft latent-cell
# variation turns them into a spatially varying SLRD strength field, which causes
# blotchy low-frequency corrections inside smooth regions. SLRD should see a support
# mask here: on inside the selected region, off outside it.
return expanded.gt(_DETAILER_SUPPORT_MASK_EPS).to(dtype=like.dtype)
def _canonicalize_impact_noise_mask(mask: Any) -> torch.Tensor | None: def _canonicalize_impact_noise_mask(mask: Any) -> torch.Tensor | None:
if not isinstance(mask, torch.Tensor): if not isinstance(mask, torch.Tensor):
return None return None
@@ -673,6 +690,29 @@ def _canonicalize_impact_noise_mask(mask: Any) -> torch.Tensor | None:
return None return None
def _resolve_impact_sampling_mask(
latent_samples: torch.Tensor,
latent_mask: Any,
denoise_mask: Any,
) -> torch.Tensor | None:
target_hw = tuple(latent_samples.shape[-2:])
batch = latent_samples.shape[0]
def _normalize(mask: Any) -> torch.Tensor | None:
canonical = _canonicalize_impact_noise_mask(mask)
if canonical is None:
return None
resized = resize_mask(canonical, target_hw, batch, 1)
if resized is None:
return None
return resized[:, :1, :, :].squeeze(1).contiguous()
sampler_mask = _normalize(denoise_mask)
if sampler_mask is not None:
return sampler_mask
return _normalize(latent_mask)
def _impact_request_tuple_from_kwargs(kwargs: dict[str, Any]) -> tuple[Any, ...]: def _impact_request_tuple_from_kwargs(kwargs: dict[str, Any]) -> tuple[Any, ...]:
if not kwargs: if not kwargs:
return () return ()
@@ -765,32 +805,6 @@ def _resolve_impact_ag_guider_template(request: "_ImpactSampleRequest", model_wr
return candidate return candidate
return None return None
def _resolve_sampler_device(model_or_wrap, fallback: torch.device) -> torch.device:
"""
Determine the device to use for sampling by inspecting the provided model or wrapper.
Parameters:
model_or_wrap: An object (model or wrapper) to inspect for a `load_device` attribute; common wrapper fields (`inner_model`, `model`, `model_patcher`) are checked as well.
fallback (torch.device): Device to return if no `load_device` attribute is found on the inspected objects.
Returns:
torch.device: The first `load_device` found on `model_or_wrap` or its common attributes, otherwise `fallback`.
"""
for obj in (
model_or_wrap,
getattr(model_or_wrap, "inner_model", None),
getattr(model_or_wrap, "model", None),
getattr(model_or_wrap, "model_patcher", None),
):
if obj is None:
continue
device = getattr(obj, "load_device", None)
if device is not None:
return device
return fallback
def _normalize_sampler_extra_args( def _normalize_sampler_extra_args(
extra_args: dict[str, Any] | None, extra_args: dict[str, Any] | None,
sigmas: torch.Tensor, sigmas: torch.Tensor,
@@ -855,7 +869,8 @@ class _ScaleLockedPlannerGuiderProxy:
""" """
del seed del seed
if callback is None: if callback is None:
callback = lambda *args, **kwargs: None def callback(*args, **kwargs):
return None
target_device = _resolve_sampler_device(self._model_wrap, latent_samples.device) target_device = _resolve_sampler_device(self._model_wrap, latent_samples.device)
target_dtype = latent_samples.dtype target_dtype = latent_samples.dtype
@@ -1062,7 +1077,7 @@ class _ScaleLockedImpactSampler:
def sample(self, model_wrap, sigmas, extra_args, callback, noise, latent_image=None, denoise_mask=None, disable_pbar=False): def sample(self, model_wrap, sigmas, extra_args, callback, noise, latent_image=None, denoise_mask=None, disable_pbar=False):
""" """
Dispatches a scale-locked sampling run through the underlying sampler using a guider proxy and device-aware tensors. Dispatches a scale-locked sampling run through the underlying sampler using an effective guider and device-aware tensors.
Parameters: Parameters:
model_wrap: Model wrapper or model object used to resolve device information and guider behavior. model_wrap: Model wrapper or model object used to resolve device information and guider behavior.
@@ -1090,38 +1105,43 @@ class _ScaleLockedImpactSampler:
if latent_image is not None: if latent_image is not None:
planner_latent["samples"] = latent_image planner_latent["samples"] = latent_image
planner_mask = _canonicalize_impact_noise_mask(planner_latent.get("noise_mask", None)) planner_mask = _resolve_impact_sampling_mask(
if planner_mask is None: planner_latent["samples"],
planner_mask = _canonicalize_impact_noise_mask(denoise_mask) planner_latent.get("noise_mask", None),
denoise_mask,
)
if planner_mask is not None: if planner_mask is not None:
planner_latent["noise_mask"] = planner_mask planner_latent["noise_mask"] = planner_mask
else:
planner_latent.pop("noise_mask", None)
base_sampler = comfy.samplers.sampler_object(request.sampler_name) base_sampler = comfy.samplers.sampler_object(request.sampler_name)
effective_guider = _build_impact_effective_guider(request, model_wrap, extra_args)
state = self._hook._prepare_runtime_state_for_sampler( state = self._hook._prepare_runtime_state_for_sampler(
request=request, request=request,
model_wrap=model_wrap, guider=effective_guider,
sampler=base_sampler, sampler=base_sampler,
sigmas=sigmas, sigmas=sigmas,
extra_args=extra_args,
live_latent=planner_latent, live_latent=planner_latent,
runtime_mask=planner_mask,
) )
proxy = _ScaleLockedGuiderProxy(model_wrap, state.runtime, state.config)
sampling_noise = noise
fallback_device = latent_image.device if latent_image is not None else noise.device fallback_device = latent_image.device if latent_image is not None else noise.device
target_device = _resolve_sampler_device(model_wrap, fallback_device) target_device = _resolve_sampler_device(model_wrap, fallback_device)
target_dtype = latent_image.dtype if latent_image is not None else noise.dtype target_dtype = latent_image.dtype if latent_image is not None else noise.dtype
sampling_mask = planner_mask
prepared_noise = state.runtime.highres_noise prepared_noise = state.runtime.highres_noise
if tuple(prepared_noise.shape) == tuple(noise.shape): use_prepared_noise = tuple(prepared_noise.shape) == tuple(noise.shape)
if use_prepared_noise:
sampling_noise = prepared_noise.to( sampling_noise = prepared_noise.to(
device=target_device, device=target_device,
dtype=target_dtype, dtype=target_dtype,
non_blocking=True, non_blocking=True,
).clone() ).clone()
else: else:
sampling_noise = sampling_noise.to( sampling_noise = noise.to(
device=target_device, device=target_device,
dtype=target_dtype, dtype=target_dtype,
non_blocking=True, non_blocking=True,
@@ -1134,22 +1154,24 @@ class _ScaleLockedImpactSampler:
non_blocking=True, non_blocking=True,
) )
sampling_mask = denoise_mask
sigmas = sigmas.to(device=target_device, non_blocking=True) sigmas = sigmas.to(device=target_device, non_blocking=True)
if isinstance(sampling_mask, torch.Tensor): if isinstance(sampling_mask, torch.Tensor):
sampling_mask = sampling_mask.to(device=target_device, non_blocking=True) sampling_mask = sampling_mask.to(device=target_device, non_blocking=True)
sample_extra_args = _normalize_sampler_extra_args(extra_args, sigmas, target_device) apply_scale_lock_to_guider(effective_guider, state.runtime, state.config)
return base_sampler.sample( try:
proxy, return effective_guider.sample(
sigmas, sampling_noise,
sample_extra_args, sampling_latent,
callback, base_sampler,
sampling_noise, sigmas,
latent_image=sampling_latent, denoise_mask=sampling_mask,
denoise_mask=sampling_mask, callback=callback,
disable_pbar=disable_pbar, disable_pbar=disable_pbar,
) seed=state.runtime.noise_seed,
)
finally:
restore_original_predict_noise(effective_guider)
finally: finally:
self._hook._clear_sampler_state() self._hook._clear_sampler_state()
@@ -1187,34 +1209,51 @@ class _ScaleLockedDetailerHook:
stage, stage,
) )
def _effective_config_for_samples(self, samples: torch.Tensor) -> ScaleLockConfig: def _effective_config_for_samples(
self,
samples: torch.Tensor,
runtime_mask: torch.Tensor | None = None,
) -> ScaleLockConfig:
cfg = self._settings.config cfg = self._settings.config
face_mask = _expand_mask_for_like(self._upscale_mask, samples) detailer_support = _detailer_support_mask_for_like(
lock_mask = _combine_masks(face_mask, _expand_mask_for_like(cfg.spatial_mask, samples)) runtime_mask if runtime_mask is not None else self._upscale_mask,
manifold_source = cfg.manifold_spatial_mask if cfg.manifold_spatial_mask is not None else cfg.spatial_mask samples,
manifold_mask = _combine_masks(face_mask, _expand_mask_for_like(manifold_source, samples)) )
# In the detailer hook path, SLRD must not extend beyond the region that the
# detailer actually denoises/composites for this crop. Treat the live sampler mask
# or captured upscale mask as the default support mask, then let explicit user masks
# further restrict that support.
explicit_lock_mask = _expand_mask_for_like(cfg.spatial_mask, samples)
lock_mask = _combine_masks(detailer_support, explicit_lock_mask)
explicit_manifold_source = (
cfg.manifold_spatial_mask if cfg.manifold_spatial_mask is not None else cfg.spatial_mask
)
explicit_manifold_mask = _expand_mask_for_like(explicit_manifold_source, samples)
manifold_mask = _combine_masks(detailer_support, explicit_manifold_mask)
return replace(cfg, spatial_mask=lock_mask, manifold_spatial_mask=manifold_mask) return replace(cfg, spatial_mask=lock_mask, manifold_spatial_mask=manifold_mask)
def _prepare_runtime_state_for_sampler( def _prepare_runtime_state_for_sampler(
self, self,
request: _ImpactSampleRequest, request: _ImpactSampleRequest,
model_wrap, guider,
sampler, sampler,
sigmas, sigmas,
extra_args: dict[str, Any] | None,
live_latent: dict[str, Any] | None = None, live_latent: dict[str, Any] | None = None,
runtime_mask: torch.Tensor | None = None,
) -> _ScaleLockedImpactRuntimeState: ) -> _ScaleLockedImpactRuntimeState:
""" """
Builds and registers a scale-locked impact runtime state for the given impact sampling request. Builds and registers a scale-locked impact runtime state for the given impact sampling request.
Parameters: Parameters:
request (_ImpactSampleRequest): Canonicalized impact sampling request containing model, seed, sampler name, and latent. request (_ImpactSampleRequest): Canonicalized impact sampling request containing model, seed, sampler name, and latent.
model_wrap: Model or model wrapper used to construct the planner guider proxy and to resolve runtime device/dtype. guider: Effective guider used for the planner runtime build.
sampler: Sampler instance to be used for planning and runtime creation. sampler: Sampler instance to be used for planning and runtime creation.
sigmas (torch.Tensor): Noise schedule tensor to use for runtime construction. sigmas (torch.Tensor): Noise schedule tensor to use for runtime construction.
extra_args (dict | None): Optional extra model arguments forwarded into the planner guider proxy (e.g., model_options).
live_latent (dict | None): Optional override latent to use for planning instead of request.latent. live_latent (dict | None): Optional override latent to use for planning instead of request.latent.
runtime_mask (torch.Tensor | None): Optional latent-space mask actually used by the detailer sampler for this cycle.
Returns: Returns:
_ScaleLockedImpactRuntimeState: The created runtime state containing the original request, the prepared runtime context, and the effective ScaleLockConfig computed for the planner latent. _ScaleLockedImpactRuntimeState: The created runtime state containing the original request, the prepared runtime context, and the effective ScaleLockConfig computed for the planner latent.
@@ -1222,10 +1261,9 @@ class _ScaleLockedDetailerHook:
guard_sampler_alignment(request.sampler_name, self._settings.sampler_guard) guard_sampler_alignment(request.sampler_name, self._settings.sampler_guard)
planner_noise = _ScaleLockedPlannerNoise(request.seed, disable_noise=not self._settings.add_noise) planner_noise = _ScaleLockedPlannerNoise(request.seed, disable_noise=not self._settings.add_noise)
planner_latent = request.latent if live_latent is None else live_latent planner_latent = request.latent if live_latent is None else live_latent
planner_guider = _ScaleLockedPlannerGuiderProxy(model_wrap, extra_args)
runtime = build_runtime_context_from_advanced( runtime = build_runtime_context_from_advanced(
noise=planner_noise, noise=planner_noise,
guider=planner_guider, guider=guider,
sampler=sampler, sampler=sampler,
sigmas=sigmas, sigmas=sigmas,
latent_image=planner_latent, latent_image=planner_latent,
@@ -1236,7 +1274,7 @@ class _ScaleLockedDetailerHook:
state = _ScaleLockedImpactRuntimeState( state = _ScaleLockedImpactRuntimeState(
request=request, request=request,
runtime=runtime, runtime=runtime,
config=self._effective_config_for_samples(runtime.highres_latent["samples"]), config=self._effective_config_for_samples(runtime.highres_latent["samples"], runtime_mask=runtime_mask),
) )
self._active_runtime = state self._active_runtime = state
return state return state
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "scale-locked-residual-diffusion" name = "scale-locked-residual-diffusion"
description = "A ComfyUI custom node pack implementing Scale-Locked Residual Diffusion for high-resolution composition and anatomy stability." description = "A ComfyUI custom node pack implementing Scale-Locked Residual Diffusion for high-resolution composition and anatomy stability."
version = "1.0.21" version = "1.0.32"
license = { file = "LICENSE" } license = { file = "LICENSE" }
[project.urls] [project.urls]