feat: image-vs-composition dislikes and effect-weighted ratings for the rated dials

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-09-30 16:18:30 +03:00
co-authored by Claude Sonnet 5.5
parent 942202c963
commit a01ffbd154
6 changed files with 118 additions and 9 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Added
- H3 rating picker: "Disliked — bad image" and "Disliked — bad composition". Everything else reads them as Disliked; shot memory, decisiveness, late-branch and STAS learn only from composition blame, and DynaShift discards the latent when composition was the fault.
- Late-branch guidance now measures how hard it actually pushed; a rating counts up to 2x (down to 0.25x) when that run was unusually strong (weak) for the key, after 3 runs of history.
### Added
- **Camera move** (experimental, H3, Engine ▸ Guidance): a pan (any direction) or zoom (in/out,
+17 -2
View File
@@ -3561,6 +3561,8 @@ MOVIE_EDITOR_FRESH_PROMPT_RATING = "__funpack_fresh_prompt__"
_BASE_RATING_LABELS = [
"-Just forget it-",
"Disliked: bad image",
"Disliked: bad composition",
"Perfect",
"Nailed it",
"Missing details",
@@ -3577,7 +3579,8 @@ _BASE_RATING_LABELS = [
"Awful",
]
_NO_LOVED_LABELS = {
"-Just forget it-", "Perfect", "Awful", "Missing quality",
"-Just forget it-", "Perfect", "Awful", "Missing quality", "Disliked: bad image",
"Disliked: bad composition",
"Missing details + quality", "Missing action + quality", "Wrong action + quality",
MOVIE_EDITOR_CONTINUE_RATING,
MOVIE_EDITOR_FRESH_PROMPT_RATING,
@@ -3633,6 +3636,12 @@ V2_RATING_PROFILES = {
"Missing details + quality": {"key": "missing_details_quality", "reward": -0.40, "level": 2, "missing_axes": ["details", "quality"]},
"Missing action + quality": {"key": "missing_action_quality", "reward": -0.55, "level": 1, "missing_axes": ["action", "quality"]},
"Awful": {"key": "awful", "reward": -0.90, "level": 0, "missing_axes": ["details", "action", "quality"]},
# H3 follow-ups to Disliked: same key/reward as Awful, so every other consumer treats them
# as Awful; `axis` only tells the rated-dial learners which half was at fault.
"Disliked: bad image": {"key": "awful", "reward": -0.90, "level": 0, "axis": "image",
"missing_axes": ["details", "action", "quality"]},
"Disliked: bad composition": {"key": "awful", "reward": -0.90, "level": 0, "axis": "composition",
"missing_axes": ["details", "action", "quality"]},
}
V2_FEEDBACK_AXES = ("details", "action", "quality")
@@ -10482,7 +10491,9 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
except ImportError:
from negative_memory import consume_pending, is_negative_profile, \
is_positive_profile
if is_negative_profile(learning_profile):
if learning_profile.get("axis") == "composition":
consume_pending(refinement_key, False) # picture was fine: not a bad latent
elif is_negative_profile(learning_profile):
n = consume_pending(refinement_key, True,
rating_key=learning_profile.get("key"),
bank="negative")
@@ -10505,6 +10516,10 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
if has_previous_run and refinement_key and not learning_profile.get("skip_learning"):
_sm_reward = (0.0 if learning_profile.get("skip_value_function")
else float(learning_profile.get("reward", 0.0) or 0.0))
# A dislike blamed on the picture alone says nothing about the shot plan
# these four learners choose (composition); neutral for them.
if learning_profile.get("axis") == "image":
_sm_reward = 0.0
try:
try:
from . import shot_memory as _sm, tsr as _tsr_mod, late_guidance as _lg, stas as _st
+3 -1
View File
@@ -57,6 +57,8 @@
const H3_SCALE = [
{ label: "10", display: "Liked", reward: "+", hint: "This generation was good." },
{ label: "1", display: "Disliked", reward: "−", hint: "This generation was not." },
{ label: "Disliked: bad image", display: "Disliked — bad image", reward: "−", hint: "Composition was fine; the picture itself was ruined." },
{ label: "Disliked: bad composition", display: "Disliked — bad composition", reward: "−", hint: "Well drawn, but the shots/layout/movement were wrong." },
];
// canonical label -> display name, for rendering a stored value (scene button, chips).
@@ -66,7 +68,7 @@
const displayName = (label) => DISPLAY_NAMES[label] || label;
const NO_LOVED = new Set([
"Perfect", "Nailed it", "Awful", FORGET_LABEL,
"Perfect", "Nailed it", "Awful", FORGET_LABEL, "Disliked: bad image", "Disliked: bad composition",
"Missing quality", "Missing details + quality", "Missing action + quality", "Wrong action + quality",
// H3 scale (1-10): a bare number never reaches the label branch that applies the loved
// boost (normalize_refiner_v2_rating), so the heart would silently do nothing. 10 already
+25 -4
View File
@@ -14,6 +14,18 @@ import random
MODES = ("off", "learned", "manual")
MIN_EFFECTS = 3 # runs of history before an effect is compared with the key's own
def unusualness(effect, past, lo=0.25, hi=2.0):
"""Weight of one rating: this run's effect over the key's median past effect, clamped.
1.0 until enough runs exist to know what usual is."""
if not effect or len(past) < MIN_EFFECTS:
return 1.0
med = sorted(past)[len(past) // 2]
return min(max(effect / med, lo), hi) if med > 0 else 1.0
class Dial:
def __init__(self, name, start, lo, hi, explore, lr=0.5):
self.name, self.start, self.lo, self.hi = name, start, lo, hi
@@ -47,7 +59,7 @@ class Dial:
m = self.start
for h in history:
s = (h["reward"] > 0) - (h["reward"] < 0)
m += self.lr * s * (float(h["v"]) - m)
m += self.lr * s * h.get("w", 1.0) * (float(h["v"]) - m)
m = min(max(m, self.lo), self.hi)
return m
@@ -62,10 +74,12 @@ class Dial:
v = min(max(centre + rng.gauss(0.0, self.explore), self.lo), self.hi)
return v, f"learned {centre:.2f} from {len(history)} rating(s), trying {v:.2f}"
def save_pending(self, refinement_key, v):
def save_pending(self, refinement_key, v, effect=None):
"""`effect`: how hard the run's steering actually hit (None = not measured)."""
if refinement_key:
data = self.read(refinement_key)
data["pending"] = float(v)
data["effect"] = float(effect) if effect else None
self._write(refinement_key, data)
def clear_pending(self, refinement_key):
@@ -73,18 +87,25 @@ class Dial:
if refinement_key and "pending" in self.read(refinement_key):
data = self.read(refinement_key)
data.pop("pending", None)
data.pop("effect", None)
self._write(refinement_key, data)
def commit(self, refinement_key, reward):
"""Pair the pending value with its run's rating. -> ratings held, or None."""
"""Pair the pending value with its run's rating. -> ratings held, or None.
The rating counts more when the run's measured effect was unusually strong for this
key (and less when unusually weak): a verdict on a run that barely felt the feature
says little about it."""
if not refinement_key:
return None
data = self.read(refinement_key)
v = data.pop("pending", None)
effect = data.pop("effect", None)
if v is None:
return None
weight = unusualness(effect, [h["e"] for h in data.get("history") or [] if h.get("e")])
if reward:
data["history"] = ((data.get("history") or [])
+ [{"v": float(v), "reward": float(reward)}])[-200:]
+ [{"v": float(v), "reward": float(reward),
"w": weight, "e": effect}])[-200:]
self._write(refinement_key, data)
return len(data["history"]) if reward else None
+18 -2
View File
@@ -904,6 +904,11 @@ class _InputSteer:
# "cur": (step, push)}. A push made during step i is only usable from step i+1 on,
# so a second call inside step i (a split batch, a hook group) never gets it.
self._held = {}
self._hit, self._hits = 0.0, 0 # edit size / answer size, summed over steered calls
def effect(self):
"""Mean relative size of the edits made, or None when none were."""
return self._hit / self._hits if self._hits else None
def _where(self, args):
c = args.get("c") if isinstance(args.get("c"), dict) else {}
@@ -971,6 +976,11 @@ class _InputSteer:
def keep(self, args, denoised, steered):
"""Hold `steered - denoised` for the next step and return the model's own answer."""
try:
self._hit += float((steered - denoised).norm() / denoised.norm().clamp(min=1e-8))
self._hits += 1
except Exception: # noqa: BLE001 # a measurement must never break sampling
pass
if not self.enabled:
return steered
where = self._where(args)
@@ -4363,6 +4373,8 @@ class FunPackLTXAVSceneChainSampler:
print(f"[FunPackSceneChain] STAS: {'Active' if _n else 'Inactive'} | {_line}")
if _late_stats is not None:
self._late_ran = getattr(self, "_late_ran", 0) + _late_stats["guided"]
_st_steer = _late_stats.get("steer")
self._late_effect = _st_steer.effect() if _st_steer is not None else None
print("[FunPackSceneChain] late-branch guidance: " + (
f"Active | guided {_late_stats['guided']} step call(s)" if _late_stats["guided"]
else "Inactive | " + (_late_stats["why"] or "no step call reached it, nothing guided")))
@@ -8891,7 +8903,7 @@ class FunPackLTXAVSceneChainSampler:
old_wrapper = patched.model_options.get("model_function_wrapper")
_w = float(w)
stats = {"guided": 0, "why": None}
stats = {"guided": 0, "why": None, "steer": None}
# The wrapper lives in patched.model_options, so it must not hold `patched`: that
# cycle is only freed by a full gc, which clears ComfyUI's weak link to the
# parent copy first and leaves it tracking a dead model ("memory leak with model").
@@ -8900,6 +8912,7 @@ class FunPackLTXAVSceneChainSampler:
# On H3 the push rides into the next step's input; the last step, whose answer
# is the output, runs no weak copy at all.
steer = _InputSteer(getattr(self, "_is_h3", False), "late-branch guidance")
stats["steer"] = steer
def _guided(apply_fn, cond_only, args, final):
def _apply(x, t, **c):
@@ -9878,6 +9891,7 @@ class FunPackLTXAVSceneChainSampler:
self._late_w, self._late_block = 0.0, int(late_guidance_block)
self._stas_alpha, self._stas_block = None, int(stas_block)
self._late_ran, self._stas_ran = 0, 0 # calls the features actually changed
self._late_effect = None # ponytail: last scene chunk's effect stands for the run
self._tsr_acts = True # False: too weak on this schedule to be rated
self._camera_move = None
if camera_move != "off":
@@ -11211,7 +11225,9 @@ class FunPackLTXAVSceneChainSampler:
"late-branch guidance"),
(stas == "learned", self._stas_ran, _stas.DIAL, self._stas_alpha, "STAS")):
if _on and _ran and _v is not None:
_dial.save_pending(refinement_key_input, _v)
_dial.save_pending(refinement_key_input, _v,
getattr(self, "_late_effect", None) if _dial is _late.DIAL
else None)
continue
_dial.clear_pending(refinement_key_input)
if _on and self._is_h3:
+51
View File
@@ -0,0 +1,51 @@
"""Two-axis dislikes route to the learners that own that axis; a rating counts more when the
run's measured effect was unusually strong for the key."""
import sys
from pathlib import Path
import torch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import conditioning # noqa: E402
import rated_dial # noqa: E402
import samplers # noqa: E402
def test_axis_dislikes_are_awful_to_everyone_else_and_are_valid_choices():
for label, axis in (("Disliked: bad image", "image"), ("Disliked: bad composition", "composition")):
p = conditioning.normalize_refiner_v2_rating(label)
assert p["key"] == "awful" and p["reward"] == -0.90 and p["axis"] == axis
assert label in conditioning.V2_RATING_LABELS # the graph builder keeps it
assert label + "|loved" not in conditioning.V2_RATING_LABELS
assert "axis" not in conditioning.normalize_refiner_v2_rating("Awful")
def test_unusualness_needs_history_and_is_clamped():
assert rated_dial.unusualness(0.5, [0.1, 0.1]) == 1.0 # too little history
assert rated_dial.unusualness(None, [0.1] * 5) == 1.0 # not measured
assert rated_dial.unusualness(0.4, [0.1] * 5) == 2.0 # clamped high
assert rated_dial.unusualness(0.01, [0.1] * 5) == 0.25 # clamped low
assert abs(rated_dial.unusualness(0.15, [0.1] * 5) - 1.5) < 1e-9
def test_dial_weights_a_strong_run_more(tmp_path, monkeypatch):
d = rated_dial.Dial("t", 0.5, 0.0, 1.0, 0.1)
store = {}
monkeypatch.setattr(d, "read", lambda k: dict(store.get(k, {})))
monkeypatch.setattr(d, "_write", lambda k, data: store.__setitem__(k, data))
for _ in range(3):
d.save_pending("k", 0.8, 0.1)
d.commit("k", 1.0)
d.save_pending("k", 0.8, 0.2)
d.commit("k", 1.0)
assert store["k"]["history"][-1]["w"] == 2.0
assert store["k"]["history"][0]["w"] == 1.0
def test_input_steer_records_how_hard_it_pushed():
s = samplers._InputSteer(False, "x")
assert s.effect() is None
d = torch.ones(1, 2, 2, 2)
s.keep({}, d, d * 1.5)
assert abs(s.effect() - 0.5) < 1e-6