Files
Reithan-negative_rejection_…/NRS/nodes_NRS.py
T
Reithan 950b55df5f Add __version__, patch-time version+pred-type log, platform-agnostic issue template (#39)
## What
Groundwork folded ahead of the held v1.0.0 release (publish-neutral —
**`pyproject.toml` is untouched**, so this does not trigger the registry
auto-publish):

1. **`NRS.__version__`** — added `__version__ = "0.7.4"` (mirrors the
current `pyproject.toml` version) in `NRS/nodes_NRS.py`, re-exported
from a new `NRS/__init__.py` so `NRS.__version__` is importable.
Commented to bump both together at release.
2. **Patch-time log line** — `patch()` now emits, right after
prediction-type detection:
`logging.info(f"NRS v{__version__}: prediction type detected ->
{pred_type.name}")`
So a user's console shows which NRS version ran and which prediction
space it selected (the key diagnostic for the flow-family
reclassification).
3. **GitHub bug-report issue form** —
`.github/ISSUE_TEMPLATE/bug_report.yml` + `config.yml`. Captures the NRS
version + detected prediction type (from the new log line),
model/sampler family, skew/stretch/squash values, expected vs actual,
repro, and console log.
- **Platform-agnostic:** NRS ships for ComfyUI *and* the A1111 family,
so the form has a **Platform / UI** dropdown — `ComfyUI, AUTOMATIC1111,
Forge, reForge, Forge Neo, Stability Matrix, Other` — with a free-text
follow-up and a generic "Platform version" field (no ComfyUI-only
wording).

## Tests
`tests/test_version.py`: asserts `NRS.__version__` is a valid `X.Y.Z`
string, matches `pyproject.toml` (catches future drift), and (via
`caplog`) that `patch()` emits the exact version + pred-type log line.

## Notes
- Adding `NRS/__init__.py` turned `NRS` from an implicit namespace
package into a real package, which newly triggered ruff's `N999` on the
pre-existing mixed-case filename `nodes_NRS.py`. Since `pyproject.toml`
is off-limits here, it's suppressed with an in-file `# ruff: noqa: N999`
pragma (with an explanatory comment) — consistent with the existing
per-file `N802/N804` carve-out for that same file. Renaming the file is
out of scope (breaks imports).

## Validation
- `ruff check .` → clean.
- `pytest --cov=NRS --cov-branch` → **45 passed** (42 + 3 new).
- diff-cover branch gate (mirrors CI) → **100%** on changed code.
- `pyproject.toml` / `uv.lock` untouched; issue-form YAML validated with
`yaml.safe_load`.
2026-08-14 02:24:47 -07:00

343 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ruff: noqa: N999 -- filename predates NRS/__init__.py; mixed-case "nodes_NRS"
# only became checkable once NRS became a regular (non-namespace) package here.
# Renaming it is out of scope (would break existing imports); pyproject.toml's
# per-file-ignores already carve out N802/N804 for this same file.
import logging
import math
from enum import Enum, auto
import torch
try:
import comfy.utils as _comfy_utils
except Exception:
_comfy_utils = None
# Must be bumped together with the `version` field in pyproject.toml at release time.
__version__ = "0.7.4"
def _unpack_latents(combined, latent_shapes):
"""Split a flat packed latent [B, 1, N] back into its per-stream tensors.
Mirrors comfy.utils.unpack_latents: for each shape in latent_shapes, take
math.prod(shape[1:]) elements off the last dim and reshape that [B, 1, n]
slice back to `shape`.
"""
streams = []
offset = 0
for shape in latent_shapes:
n = math.prod(shape[1:])
chunk = combined[:, :, offset : offset + n]
streams.append(chunk.reshape(shape))
offset += n
return streams
def _pack_latents(streams):
"""Pack a list of per-stream tensors [B, C, ...] into a flat [B, 1, N] tensor.
Mirrors comfy.utils.pack_latents: each stream is reshaped to (B, 1, -1)
and concatenated on the last dim.
"""
flat = [s.reshape(s.shape[0], 1, -1) for s in streams]
return torch.cat(flat, dim=-1)
# fmt: off
class PredictionType(Enum):
EPS = auto() # ε-prediction
V = auto() # v-prediction
X0 = auto() # x₀-prediction
FLOW = auto() # flow-matching / velocity — operated natively, no VP conversion
UNKNOWN = auto() # couldn’t detect / new scheduler
_RAW_TO_ENUM = {
"eps": PredictionType.EPS,
"epsilon": PredictionType.EPS,
"flux": PredictionType.FLOW,
"chroma": PredictionType.FLOW,
"flow": PredictionType.FLOW, # FLOW models (WAN, etc.) operated natively
"wan": PredictionType.FLOW, # WAN21 is FLOW-based
"const": PredictionType.FLOW, # CONST prediction class used in FLOW models
"v": PredictionType.V,
"v_prediction": PredictionType.V,
"x0": PredictionType.X0,
"sample": PredictionType.X0,
}
# fmt: on
class NRS:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"model": ("MODEL", {"tooltip": "Input model to apply NRS to"}),
"skew": (
"FLOAT",
{
"default": 2.00,
"min": -30.0,
"max": 30.0,
"step": 0.01,
"tooltip": "Changes the 'direction' of generation, steering away from negative prompt elements. Start with CFG/2.",
},
),
"stretch": (
"FLOAT",
{
"default": 5.00,
"min": -30.0,
"max": 30.0,
"step": 0.01,
"tooltip": "Intensifies positive prompt elements. Start with your normal CFG value.",
},
),
"squash": (
"FLOAT",
{
"default": 0.75,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"tooltip": "Softens Skew/Stretch effects, adding micro-detailing. Keep low initially.",
},
),
}
}
RETURN_TYPES = ("MODEL",)
FUNCTION = "patch"
CATEGORY = "advanced/model"
DESCRIPTION = "Negative Rejection Steering (NRS) replaces CFG with more nuanced guidance. IMPORTANT: Set your KSampler CFG to any value (it will be ignored). Connect your model through this node before sampling."
def _get_pred_type(self, model) -> PredictionType:
"""
In order to support Comfy, Forge, and possibly other models
and various loaders.
Walk common wrappers until we find something that looks like a
prediction-type flag, then map it to the enum.
Defaults to EPS if all else fails.
"""
def _canon(p):
if p is None:
return ""
if isinstance(p, bytes):
p = p.decode(errors="ignore")
if isinstance(p, Enum):
p = p.name
return str(p).strip().lower()
# Breadth-first search through a few well-known wrappers.
queue, seen = [model], set()
while queue:
obj = queue.pop(0)
# 1) direct hit on this object ---------------------------------
for attr in ("model_type", "prediction_type", "parameterization"):
p = _canon(getattr(obj, attr, None))
if p:
pred_type = _RAW_TO_ENUM.get(p, PredictionType.UNKNOWN)
if pred_type != PredictionType.UNKNOWN:
logging.debug(
f"NRS._get_pred_type: Found prediction type '{p}' from attribute '{attr}' -> {pred_type}"
)
return pred_type
# 2) enqueue child containers we care about -------------------
for attr in ("model", "diffusion_model", "config", "scheduler", "inner_model", "model_sampling"):
child = getattr(obj, attr, None)
if child is not None and id(child) not in seen:
seen.add(id(child))
queue.append(child)
# 3) enhanced detection for FLOW models (WAN, Flux, etc.) -------
try:
# Check model_sampling class type for FLOW models
if hasattr(model, "model_sampling") and model.model_sampling is not None:
sampling_class_name = type(model.model_sampling).__name__.lower()
logging.debug(f"NRS._get_pred_type: Found model_sampling class: {sampling_class_name}")
# CONST class is used by FLOW models (WAN21, Flux, etc.)
if "const" in sampling_class_name:
logging.debug("NRS._get_pred_type: Detected FLOW model via CONST sampling class -> FLOW")
return PredictionType.FLOW
elif "v_prediction" in sampling_class_name:
logging.debug("NRS._get_pred_type: Detected V-prediction model via sampling class -> V")
return PredictionType.V
elif "eps" in sampling_class_name:
logging.debug("NRS._get_pred_type: Detected EPS model via sampling class -> EPS")
return PredictionType.EPS
# Check model.model.model_type enum for newer models
if hasattr(model, "model") and hasattr(model.model, "model_type"):
model_type_str = _canon(str(model.model.model_type))
logging.debug(f"NRS._get_pred_type: Found model.model.model_type: {model_type_str}")
if "flow" in model_type_str or "flux" in model_type_str:
logging.debug("NRS._get_pred_type: Detected FLOW/Flux model via model_type -> FLOW")
return PredictionType.FLOW
elif "v_prediction" in model_type_str:
logging.debug("NRS._get_pred_type: Detected V-prediction model via model_type -> V")
return PredictionType.V
elif "eps" in model_type_str:
logging.debug("NRS._get_pred_type: Detected EPS model via model_type -> EPS")
return PredictionType.EPS
except Exception as e:
logging.debug(f"NRS._get_pred_type: Exception during enhanced detection: {e}")
# 4) safe default (matches docstring promise) --------------------
logging.warning("NRS._get_pred_type: Could not determine prediction type for model. Using EPS as fallback.")
logging.debug(
f"NRS._get_pred_type: Model structure: {[attr for attr in dir(model) if not attr.startswith('_')]}"
)
return PredictionType.EPS
def _convert_to_v_space(self, x_orig, sig_root, sigma, cond, uncond, pred_type):
x_div = None
v_cond = cond
v_uncond = uncond
if pred_type in (PredictionType.V, PredictionType.FLOW):
logging.debug("NRS._convert_to_v_space: already in v/flow, no pre-scale needed")
pass # already in v space / flow-matching operated natively
elif pred_type == PredictionType.EPS:
# ε → v conversion
logging.debug("NRS._convert_to_v_space: generating x_div, v_cond, and v_uncond for eps")
x_div = x_orig / (sigma**2 + 1)
factor = sigma / sig_root
v_cond = x_orig - (x_div - cond * factor)
v_uncond = x_orig - (x_div - uncond * factor)
elif pred_type == PredictionType.X0:
raise NotImplementedError("NRS._convert_to_v_space: x0-prediction not supported yet.")
else:
# Fallback: treat UNKNOWN as EPS and convert to V-space
logging.warning(f"NRS._convert_to_v_space: Unknown prediction type {pred_type}, treating as EPS")
logging.debug("NRS._convert_to_v_space: generating x_div, v_cond, and v_uncond for eps (fallback)")
x_div = x_orig / (sigma**2 + 1)
factor = sigma / sig_root
v_cond = x_orig - (x_div - cond * factor)
v_uncond = x_orig - (x_div - uncond * factor)
return x_div, v_cond, v_uncond
def _finalize_from_v_space(self, x_orig, x_div, x_final, sig_root, sigma, pred_type):
nrs_result = x_final
if pred_type in (PredictionType.V, PredictionType.FLOW):
# already in v space / flow-matching operated natively
logging.debug("NRS._finalize_from_v_space: already in v/flow, no post-scale needed")
pass
elif pred_type == PredictionType.EPS:
# v → ε conversion
logging.debug("NRS._finalize_from_v_space: generating cfg_result for eps")
nrs_result = (x_div - (x_orig - x_final)) * (sig_root / sigma)
elif pred_type == PredictionType.X0:
raise NotImplementedError("NRS._finalize_from_v_space: x0-prediction not supported yet.")
else:
# Fallback: treat UNKNOWN as EPS and convert from V-space
logging.warning(f"NRS._finalize_from_v_space: Unknown prediction type {pred_type}, treating as EPS")
logging.debug("NRS._finalize_from_v_space: generating cfg_result for eps (fallback)")
nrs_result = (x_div - (x_orig - x_final)) * (sig_root / sigma)
return nrs_result
def _apply_guidance(self, x_orig, cond, uncond, sigma, skew, stretch, squash, pred_type):
"""Run the NRS geometry pipeline on a single (already-unpacked, channels-first) stream."""
sigma = sigma.view(sigma.shape[:1] + (1,) * (cond.ndim - 1))
sig_root = (sigma**2 + 1).sqrt()
# V and FLOW models are operated natively (identity); EPS models are converted to v-space.
x_div, nrs_cond, nrs_uncond = self._convert_to_v_space(x_orig, sig_root, sigma, cond, uncond, pred_type)
def _dot(a, b):
return (a * b).sum(dim=1, keepdim=True) # [B,C,W,H] => [B,1,W,H]
def _nrm2(v):
return _dot(v, v)
eps = torch.finfo(nrs_cond.dtype).eps
c_dot_c = _nrm2(nrs_cond) + eps # [B,1,W,H]
u_dot_c = _dot(nrs_uncond, nrs_cond) # [B,1,W,H]
u_on_c = (u_dot_c / c_dot_c) * nrs_cond # [B,1,W,H] * [B,C,H,W]
# Amplify Cond based on length compared to projection of uncond
proj_diff = nrs_cond - u_on_c
stretched = nrs_cond + (stretch * proj_diff)
# Skew/Steer Conf based on rejection of uncond on cond
u_rej_c = nrs_uncond - u_on_c
skewed = stretched - (skew * u_rej_c)
# Squash final length back down to original length of cond
cond_len = nrs_cond.norm(dim=1, keepdim=True)
nrs_len = skewed.norm(dim=1, keepdim=True) + eps
squash_scale = (1 - squash) + (squash * (cond_len / nrs_len))
x_final = skewed * squash_scale
return self._finalize_from_v_space(x_orig, x_div, x_final, sig_root, sigma, pred_type)
def patch(self, model, skew, stretch, squash):
pred_type = self._get_pred_type(model)
logging.info(f"NRS v{__version__}: prediction type detected -> {pred_type.name}")
warned = {"done": False}
def nrs(args):
logging.debug(f"NRS.nrs: Skew: {skew}, Stretch: {stretch}, Squash: {squash}")
cond = args["cond"]
uncond = args["uncond"]
x_orig = args["input"]
sigma = args["sigma"]
shapes = getattr(args["model"], "latent_shapes", None)
if shapes and len(shapes) > 1:
if _comfy_utils is not None and hasattr(_comfy_utils, "unpack_latents"):
cond_streams = _comfy_utils.unpack_latents(cond, shapes)
uncond_streams = _comfy_utils.unpack_latents(uncond, shapes)
x_streams = _comfy_utils.unpack_latents(x_orig, shapes)
else:
cond_streams = _unpack_latents(cond, shapes)
uncond_streams = _unpack_latents(uncond, shapes)
x_streams = _unpack_latents(x_orig, shapes)
else:
cond_streams, uncond_streams, x_streams = [cond], [uncond], [x_orig]
if not warned["done"]:
for stream in x_streams:
if stream.shape[1] == 1:
logging.warning(
f"NRS.nrs: routed stream has a singleton reduction axis {tuple(stream.shape)}; "
"NRS geometry (dot/proj/skew) will degenerate to a no-op on this stream."
)
warned["done"] = True
break
results = [
self._apply_guidance(
x_streams[i], cond_streams[i], uncond_streams[i], sigma, skew, stretch, squash, pred_type
)
for i in range(len(cond_streams))
]
if len(results) == 1:
return results[0]
if _comfy_utils is not None and hasattr(_comfy_utils, "pack_latents"):
return _comfy_utils.pack_latents(results)[0]
return _pack_latents(results)
m = model.clone()
m.set_model_sampler_cfg_function(nrs, True)
return (m,)
NODE_CLASS_MAPPINGS = {
"NRS": NRS,
}