Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
292 lines
12 KiB
Python
292 lines
12 KiB
Python
"""Shot memory: the starting noise remembers the shots you liked.
|
|
|
|
With the prompt fixed, what a like or dislike reacts to is mostly the SHOT -- where
|
|
things sit, how the frame is split. The model decides that from the coarse part of
|
|
the starting noise, so a rating here lands on the thing that caused it, not on an
|
|
averaged direction in a space thousands of dimensions wide.
|
|
|
|
Each run's coarse noise (one value per 4x4-latent cell per channel, averaged over
|
|
the whole clip) is recorded; the rating that scores the run keeps it or not. The
|
|
next run then either:
|
|
|
|
* reuses a liked shot -- its coarse noise is blended into the fresh noise by
|
|
`amount`, and the fine noise is always fresh, so the details are new; or
|
|
* starts fresh.
|
|
|
|
Both choices learn from ratings (Thompson draws, the path planner's pattern):
|
|
whether reusing beats fresh for this key, which liked shot to reuse, and how
|
|
strongly (`amount`, one number, nudged toward what was liked). A liked child is a
|
|
shot in its own right, so good shots breed.
|
|
|
|
The noise stays exactly unit Gaussian: a block mean of Gaussian noise is
|
|
independent of what is left once it is removed, so swapping one block mean for
|
|
another of the same variance changes nothing the model can tell apart from
|
|
ordinary noise. Zero model calls. Audio noise is never touched.
|
|
"""
|
|
|
|
import math
|
|
import os
|
|
import random
|
|
|
|
import torch
|
|
import torch.nn.functional as F
|
|
|
|
# Latent pixels per coarse cell: 64 image px on H3's 16x VAE.
|
|
# ponytail: fixed; learn it per key if one cell size stops fitting every resolution.
|
|
CELL = 4
|
|
MAX_ROWS = 64
|
|
START_AMOUNT = 0.7
|
|
EXPLORE = 0.15
|
|
LR = 0.5
|
|
AMOUNT_MIN, AMOUNT_MAX = 0.2, 0.95
|
|
MODES = ("off", "learned", "manual")
|
|
|
|
|
|
def _path(refinement_key, mode):
|
|
try:
|
|
from .conditioning import refinement_state_path
|
|
except ImportError:
|
|
from conditioning import refinement_state_path
|
|
return refinement_state_path(refinement_key, mode, prefix="refine_v2", extension="pt")
|
|
|
|
|
|
def _load(path, default):
|
|
try:
|
|
return torch.load(path, map_location="cpu", weights_only=True)
|
|
except FileNotFoundError:
|
|
return default
|
|
except Exception as e: # noqa: BLE001
|
|
print(f"[FunPack Shot memory] could not read {os.path.basename(path)}: {e}")
|
|
return default
|
|
|
|
|
|
def _save(payload, path):
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
tmp = path + ".tmp"
|
|
torch.save(payload, tmp)
|
|
os.replace(tmp, path)
|
|
|
|
|
|
def load_rows(refinement_key):
|
|
"""Rated shots, oldest first: {id, parent, amount, reward, coarse [C,h,w], cond [D]|None}."""
|
|
if not refinement_key:
|
|
return []
|
|
return list(_load(_path(refinement_key, "shot_memory"), {"rows": []}).get("rows") or [])
|
|
|
|
|
|
def _sign(x):
|
|
return (x > 0) - (x < 0)
|
|
|
|
|
|
def coarse_of(video):
|
|
"""[1, C, T, H, W] noise -> its coarse part [C, h, w], unit variance, or None."""
|
|
if video.dim() != 5 or video.shape[0] != 1:
|
|
return None
|
|
_b, c, t, H, W = video.shape
|
|
h, w = H // CELL, W // CELL
|
|
if h < 2 or w < 2: # a layout needs cells to compare; one cell has no spread
|
|
return None
|
|
blocks = video[0, :, :, :h * CELL, :w * CELL].float().reshape(c, t, h, CELL, w, CELL)
|
|
return blocks.mean(dim=(1, 3, 5)) * math.sqrt(t * CELL * CELL)
|
|
|
|
|
|
def with_coarse(video, coarse):
|
|
"""`video` with its coarse part replaced by `coarse` [C, h, w]; the fine part kept."""
|
|
_b, c, t, _H, _W = video.shape
|
|
h, w = coarse.shape[-2:]
|
|
core = video[0, :, :, :h * CELL, :w * CELL].float().reshape(c, t, h, CELL, w, CELL)
|
|
delta = coarse.to(core) / math.sqrt(t * CELL * CELL) - core.mean(dim=(1, 3, 5))
|
|
core = core + delta[:, None, :, None, :, None]
|
|
out = video.clone()
|
|
out[0, :, :, :h * CELL, :w * CELL] = core.reshape(c, t, h * CELL, w * CELL).to(video.dtype)
|
|
return out
|
|
|
|
|
|
def _fit(coarse, h, w):
|
|
"""A stored coarse map on this run's grid. Another resolution is resampled and
|
|
re-standardised, which keeps the layout but is no longer exactly Gaussian."""
|
|
coarse = coarse.float()
|
|
if tuple(coarse.shape[-2:]) == (h, w):
|
|
return coarse
|
|
x = F.interpolate(coarse[None], size=(h, w), mode="bilinear", align_corners=False)[0]
|
|
return (x - x.mean()) / x.std().clamp(min=1e-6)
|
|
|
|
|
|
def pooled(cond):
|
|
if not isinstance(cond, torch.Tensor):
|
|
return None
|
|
p = cond.detach().float()
|
|
while p.dim() > 1:
|
|
p = p.mean(dim=0)
|
|
return p.cpu()
|
|
|
|
|
|
def learned_amount(rows):
|
|
"""How strongly to reuse a liked shot, replayed from the rated reuses in order:
|
|
each liked one pulls the value toward the amount it used, each disliked one
|
|
pushes it away. Recent ratings weigh more because they move it last."""
|
|
m = START_AMOUNT
|
|
for r in rows:
|
|
if int(r.get("parent", -1)) < 0:
|
|
continue
|
|
m += LR * _sign(float(r.get("reward", 0.0))) * (float(r.get("amount", m)) - m)
|
|
m = min(max(m, AMOUNT_MIN), AMOUNT_MAX)
|
|
return m
|
|
|
|
|
|
def choose_parent(rows, cond, channels, rng=random):
|
|
"""-> (liked row to reuse | None, why). Two Thompson draws: reuse-vs-fresh from
|
|
how reuses and fresh shots have been rated, then which liked shot, weighted by
|
|
how alike its prompt was."""
|
|
by_id = {int(r["id"]): r for r in rows}
|
|
reuse_ab, fresh_ab = [1.0, 1.0], [1.0, 1.0]
|
|
children = {}
|
|
for r in rows:
|
|
s = _sign(float(r.get("reward", 0.0)))
|
|
if not s:
|
|
continue
|
|
parent = int(r.get("parent", -1))
|
|
ab = fresh_ab if parent < 0 else reuse_ab
|
|
ab[0 if s > 0 else 1] += 1.0
|
|
if parent in by_id:
|
|
children.setdefault(parent, [1.0, 1.0])[0 if s > 0 else 1] += 1.0
|
|
liked = [r for r in rows if float(r.get("reward", 0.0)) > 0
|
|
and int(r["coarse"].shape[0]) == int(channels)]
|
|
if not liked:
|
|
return None, "fresh: no liked shot yet"
|
|
reuse, fresh = rng.betavariate(*reuse_ab), rng.betavariate(*fresh_ab)
|
|
if fresh >= reuse:
|
|
return None, f"fresh (reuse {reuse:.2f} < fresh {fresh:.2f})"
|
|
best, best_theta = None, -1.0
|
|
for r in liked:
|
|
a, b = children.get(int(r["id"]), [1.0, 1.0])
|
|
theta = rng.betavariate(a + 1.0, b) # +1: its own like
|
|
stored = r.get("cond")
|
|
if cond is not None and isinstance(stored, torch.Tensor) and stored.numel() == cond.numel():
|
|
# ponytail: raw pooled-prompt cosine; sits high for most prompts under one key.
|
|
theta *= float(F.cosine_similarity(cond, stored.float(), dim=0).clamp(0.0, 1.0))
|
|
if theta > best_theta:
|
|
best, best_theta = r, theta
|
|
return best, f"reusing a liked shot of {len(liked)} (reuse {reuse:.2f} > fresh {fresh:.2f})"
|
|
|
|
|
|
class ShotMemory:
|
|
"""One run's decisions: which shot each scene reuses and how strongly, and the
|
|
shots actually used, waiting for the rating."""
|
|
|
|
def __init__(self, refinement_key, mode="learned", manual_amount=START_AMOUNT, rng=random):
|
|
self.key = refinement_key
|
|
self.mode = mode
|
|
self.manual = min(max(float(manual_amount), 0.0), AMOUNT_MAX)
|
|
self.rng = rng
|
|
self.rows = load_rows(refinement_key)
|
|
self.plans = {}
|
|
self.used = []
|
|
self.notes = []
|
|
self.skipped = 0
|
|
|
|
def _plan(self, cond, channels):
|
|
sig = None if cond is None else (cond.numel(), round(float(cond.sum()), 3))
|
|
if (sig, channels) not in self.plans:
|
|
parent, why = choose_parent(self.rows, cond, channels, self.rng)
|
|
amount = 0.0
|
|
if parent is not None:
|
|
if self.mode == "manual":
|
|
amount = self.manual
|
|
else:
|
|
amount = learned_amount(self.rows) + self.rng.gauss(0.0, EXPLORE)
|
|
amount = min(max(amount, AMOUNT_MIN), AMOUNT_MAX)
|
|
why += f", amount {amount:.2f}"
|
|
self.plans[(sig, channels)] = (parent, amount)
|
|
self.notes.append(why)
|
|
return self.plans[(sig, channels)]
|
|
|
|
def shape(self, noise, samples, cond, record=False):
|
|
"""The starting noise for a run from `samples`. Unchanged when the latent is
|
|
not empty (a second pass, a latent anchor, a carried overlap): there the noise
|
|
is not what decides the shot, and the run says so (`skipped`).
|
|
|
|
Only the run's FIRST shot is recorded: one rating is one row, so a
|
|
multi-scene run cannot count its single Like several times over. Later
|
|
scenes still reuse liked shots; they just don't become shots themselves."""
|
|
nested = getattr(noise, "is_nested", False)
|
|
video = noise.unbind()[0] if nested else noise
|
|
base = samples.unbind()[0] if getattr(samples, "is_nested", False) else samples
|
|
if not isinstance(video, torch.Tensor) or video.dim() != 5:
|
|
return noise
|
|
if bool(torch.count_nonzero(base)):
|
|
self.skipped += int(record)
|
|
return noise
|
|
own = coarse_of(video)
|
|
if own is None:
|
|
note = f"frame under {2 * CELL}x{2 * CELL} latent px (two layout cells a side), left alone"
|
|
if note not in self.notes:
|
|
self.notes.append(note)
|
|
return noise
|
|
cond = pooled(cond)
|
|
parent, amount = self._plan(cond, int(own.shape[0]))
|
|
coarse = own
|
|
if parent is not None:
|
|
coarse = amount * _fit(parent["coarse"], *own.shape[-2:]) + math.sqrt(1.0 - amount ** 2) * own
|
|
# Unit variance only holds when the two layouts are unrelated. Rerunning the
|
|
# liked shot's own seed makes them the same pattern, and the blend stacks it
|
|
# (1.41x at amount 0.7): back to a normal-strength layout, per channel. Only ever
|
|
# down: a few-cell grid's measured spread can read low by chance, and dividing by
|
|
# it would make the layout stronger than plain noise.
|
|
coarse = coarse / coarse.std(dim=(1, 2), keepdim=True).clamp(min=1.0)
|
|
video = with_coarse(video, coarse)
|
|
if record and not self.used:
|
|
self.used.append({
|
|
"id": self.rng.getrandbits(62), "parent": int(parent["id"]) if parent else -1,
|
|
"amount": float(amount), "coarse": coarse.half().cpu(),
|
|
"cond": cond.half() if cond is not None else None,
|
|
})
|
|
if not nested:
|
|
return video
|
|
from comfy.nested_tensor import NestedTensor
|
|
return NestedTensor([video, *noise.unbind()[1:]])
|
|
|
|
def save_pending(self):
|
|
"""This run's shots, for the rating that scores it. One run pending per key."""
|
|
if not self.key:
|
|
return
|
|
path = _path(self.key, "shot_memory_pending")
|
|
if self.used:
|
|
_save({"rows": self.used}, path)
|
|
elif os.path.exists(path):
|
|
os.remove(path)
|
|
|
|
|
|
def clear_pending(refinement_key):
|
|
"""A keyed run with shot memory off: its rating must not score an older run's shots."""
|
|
if refinement_key:
|
|
try:
|
|
os.remove(_path(refinement_key, "shot_memory_pending"))
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def commit(refinement_key, reward):
|
|
"""Pair the pending shots with the rating that scores their run. -> rows kept, or
|
|
None when nothing was pending. The pending file is removed either way."""
|
|
if not refinement_key:
|
|
return None
|
|
path = _path(refinement_key, "shot_memory_pending")
|
|
pending = _load(path, None)
|
|
if pending is None:
|
|
return None
|
|
try:
|
|
if not _sign(float(reward)):
|
|
return None
|
|
rows = load_rows(refinement_key)
|
|
for r in pending.get("rows") or []:
|
|
rows.append({**r, "reward": float(_sign(float(reward)))})
|
|
rows = rows[-MAX_ROWS:]
|
|
_save({"rows": rows}, _path(refinement_key, "shot_memory"))
|
|
return len(rows)
|
|
finally:
|
|
try:
|
|
os.remove(path)
|
|
except OSError:
|
|
pass
|