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:
co-authored by
Claude Sonnet 5.5
parent
942202c963
commit
a01ffbd154
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user