texture_probe: read cached latents, normalise for exposure, and a correction
Browse filesRun against a real 3x243f chain, which found two defects and reversed a
finding.
Cached latents never loaded. torch.load has to import
comfy.nested_tensor to rebuild the object, and without the ComfyUI root
on sys.path store._get_latent caught the ModuleNotFoundError and the
probe printed "none cached for this hop" -- reporting a path problem as
an absent latent, which is the difference between a render to redo and
a one-line fix. enable_latent_reads() appends the root and imports
nothing; the module pulls in only torch when pickle reaches for it.
Band energy was not exposure-normalised. The docstring claimed a
band-pass "does not care about the local mean", which is true of an
offset and false of a scale -- a 5% brighter picture measures ~5% more
band energy with texture unchanged. This chain's luma rose 4.7%, so
whole-frame mid read x1.084 when the texture part was n1.035. Both
columns are printed now.
The correction: the ratchet is a staircase, not a ramp. Within each
hop, head mid last/first is 0.996 / 1.003 / 1.018 -- flat -- and each
join steps x1.042, twice, identically. The earlier "continuous climb"
came from binning someone else's master without knowing where their
joins were, which is an under-determined reading I should have flagged
as one. Re-read with boundaries in hand, their bins are a staircase
too. The hand-off is the cause, not the carrier, which means it can be
removed rather than damped.
And the measurement this was all for: latent sigma FALLS 1.2% across
the chain while the pixel mid band climbs 8%; the high-band fraction
rises 1.6%, monotone. pin_renorm=on would scale this pin up x1.012 on
a latent whose high band is already too hot -- the shipped lever pushes
the wrong way. Phase 2a is now evidenced rather than assumed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015MGjcAV8bDy93qJfmLi9kw
- CLAUDE.md +2 -1
- docs/DEVLOG.md +74 -0
- tools/hopcache.py +20 -0
- tools/texture_probe.py +24 -5
|
@@ -363,7 +363,8 @@ hop N-1 ends. Hop N opens by holding what it was handed.
|
|
| 363 |
- `ref_image_size="max"` (2048 short-edge) is slower per step than `"match"` — an explicit tradeoff, not a default to silently upgrade.
|
| 364 |
- Fewer than 3 wired reference stills triggers a warning log; unconnected `Load Image` nodes do not count as wired.
|
| 365 |
- **Never judge texture drift with mean `|Laplacian|`, or with any whole-frame scalar.** Measured on two 44 s H3 chains whose faces visibly come apart, it reads **0.961 and 0.973** -- one of them says the clip improved. It is an area average (a face is ~6% of a portrait frame and loses to the background sixteen to one) and it pools every spatial frequency, so band-to-band movement cancels. On those clips global contrast FELL while mid-band energy ROSE. Use `tools/texture_probe.py`: three Gaussian-difference bands (fine <1px / **mid 1-2.5px, the one that moves** / coarse 2.5-6px), a subject box against a background control, and the within-hop slope. `--video` runs it on any file, including other people's rigs. See DEVLOG 26.
|
| 366 |
-
- **The texture ratchet is
|
|
|
|
| 367 |
- Soundtrack is official H3, not a pack dialect: `(S1)` + `<d>…</d>` for lines, `overall_soundscape` for ambience/physical (or `N/A` for requested silence). Do not invent beat-keywords. “No speech” / “no dialogue” remains negation/gibberish.
|
| 368 |
|
| 369 |
## The engineering log
|
|
|
|
| 363 |
- `ref_image_size="max"` (2048 short-edge) is slower per step than `"match"` — an explicit tradeoff, not a default to silently upgrade.
|
| 364 |
- Fewer than 3 wired reference stills triggers a warning log; unconnected `Load Image` nodes do not count as wired.
|
| 365 |
- **Never judge texture drift with mean `|Laplacian|`, or with any whole-frame scalar.** Measured on two 44 s H3 chains whose faces visibly come apart, it reads **0.961 and 0.973** -- one of them says the clip improved. It is an area average (a face is ~6% of a portrait frame and loses to the background sixteen to one) and it pools every spatial frequency, so band-to-band movement cancels. On those clips global contrast FELL while mid-band energy ROSE. Use `tools/texture_probe.py`: three Gaussian-difference bands (fine <1px / **mid 1-2.5px, the one that moves** / coarse 2.5-6px), a subject box against a background control, and the within-hop slope. `--video` runs it on any file, including other people's rigs. See DEVLOG 26.
|
| 366 |
+
- **The texture ratchet is injected AT the join, and nowhere else.** Measured off the hop cache on a 3x243f chain, head box, mid band: within each hop last/first is `0.996 / 1.003 / 1.018` -- flat -- and each join steps `x1.042`, twice, identically. It is a staircase, not a ramp. So the hand-off is the cause and not merely the carrier, which is the good news: a hand-off-side lever can remove this rather than damp it. `prev_imgs = imgs[-tail_n:]` and the pin latent are conditioning-only copies (`.clone()`, and `_condition_pin_latent` rebuilds rather than mutates), so they can be corrected without touching a single visible pixel.
|
| 367 |
+
- **Texture drift is independent of exposure, but the metric is not.** A band-pass removes the local mean, so it ignores a brightness *offset* -- it is not scale-free, and a 5% brighter picture measures ~5% more band energy with texture unchanged. `texture_probe` prints both (`x` raw, `n` exposure-normalised); where they disagree the difference is the scene brightening. On the 3-hop chain whole-frame mid read `x1.084` raw but `n1.035` normalised. Do not expect `tone_compensate` to touch the ratchet, and do not tune on a 2-hop chain; the effect only separates at 3+.
|
| 368 |
- Soundtrack is official H3, not a pack dialect: `(S1)` + `<d>…</d>` for lines, `overall_soundscape` for ambience/physical (or `N/A` for requested silence). Do not invent beat-keywords. “No speech” / “no dialogue” remains negation/gibberish.
|
| 369 |
|
| 370 |
## The engineering log
|
|
@@ -1137,3 +1137,77 @@ re-encodes decoded pixels, which is the decode/re-encode round trip the reporter
|
|
| 1137 |
measured at 1.530, "much worse", on their own rig. A chain that quietly fell
|
| 1138 |
back has both levers dead and the worse hand-off. The log says which pin ran;
|
| 1139 |
it is worth reading before trusting any A/B.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1137 |
measured at 1.530, "much worse", on their own rig. A chain that quietly fell
|
| 1138 |
back has both levers dead and the worse hand-off. The log says which pin ran;
|
| 1139 |
it is worth reading before trusting any A/B.
|
| 1140 |
+
|
| 1141 |
+
### Correction, 2026-09-01: it is a staircase, not a ramp
|
| 1142 |
+
|
| 1143 |
+
The section above says the climb is continuous with no step at the joins. That
|
| 1144 |
+
was wrong, and it was wrong in the way that matters most -- it is the claim
|
| 1145 |
+
that decides where a correction belongs.
|
| 1146 |
+
|
| 1147 |
+
It came from binning the reporter's master at 60 frames **without knowing where
|
| 1148 |
+
their joins were**. A step function sampled that way, with content noise on
|
| 1149 |
+
top, reads as a ramp if you want it to. The inference was under-determined and
|
| 1150 |
+
I did not say so.
|
| 1151 |
+
|
| 1152 |
+
The hop cache settles it, because there the boundaries are known. A 3 x 243f
|
| 1153 |
+
chain, 736x1280, overlap 22, Motion-Context pin, `pin_renorm off`, head box,
|
| 1154 |
+
mid band, with the regenerated overlap frames excluded:
|
| 1155 |
+
|
| 1156 |
+
hop 1 0.00985 0.00976 0.00963 0.00992 0.00981 last/first 0.996
|
| 1157 |
+
hop 2 0.01050 0.01025 0.01029 0.01033 0.01053 last/first 1.003
|
| 1158 |
+
hop 3 0.01058 0.01025 0.01044 0.01058 0.01076 last/first 1.018
|
| 1159 |
+
|
| 1160 |
+
join 1 -> 2 tail 0.01006 -> body start 0.01048 x1.042
|
| 1161 |
+
join 2 -> 3 tail 0.01068 -> body start 0.01113 x1.042
|
| 1162 |
+
|
| 1163 |
+
Flat inside every hop. **The same +4.2% at both joins.** Those two frames are
|
| 1164 |
+
adjacent in scene time -- hop N+1's frame 22 continues from hop N's last -- so
|
| 1165 |
+
it is a genuine discontinuity and not a gap the scene moved through.
|
| 1166 |
+
|
| 1167 |
+
Re-reading the reporter's bins with this in hand, theirs is a staircase too:
|
| 1168 |
+
1.57 1.55 1.56 1.56 1.57 | 1.60 1.68 1.66 1.61 1.64 | 1.71 1.71 1.72 1.77 |
|
| 1169 |
+
1.85 1.85 1.93 -- four plateaus at ~1.56, ~1.64, ~1.73, ~1.88, stepping +5%,
|
| 1170 |
+
+6%, +9%, on a chain they told us was four hops. Both rigs agree. I had the
|
| 1171 |
+
right data and read it wrong.
|
| 1172 |
+
|
| 1173 |
+
This is better news than the original reading. "Self-conditioning drift inside
|
| 1174 |
+
the generation" could only ever be damped; a step injected at the hand-off can
|
| 1175 |
+
be removed at the hand-off, and the hand-off copies are conditioning-only.
|
| 1176 |
+
|
| 1177 |
+
### And the latent measurement, which was the point
|
| 1178 |
+
|
| 1179 |
+
From the same cache, per hop: component [0] sigma `1.0414 -> 1.0376 -> 1.0289`,
|
| 1180 |
+
its high band `0.3794 -> 0.3811 -> 0.3809`.
|
| 1181 |
+
|
| 1182 |
+
Sigma **falls 1.2%** while the pixel mid band climbs 8%. The high-band
|
| 1183 |
+
*fraction* -- hi/sigma -- goes `0.3643 -> 0.3673 -> 0.3702`, up 1.6% and
|
| 1184 |
+
monotone. So the latent does carry the tilt, and total sigma does not see it.
|
| 1185 |
+
|
| 1186 |
+
`pin_renorm=on` would have multiplied this pin by `1.0414/1.0289 = x1.012`,
|
| 1187 |
+
scaling every band up uniformly, on a latent whose high band was already 1.6%
|
| 1188 |
+
too hot. **On this chain the shipped lever pushes the wrong way.** That is not
|
| 1189 |
+
a small correction to it; it is the wrong statistic, and Phase 2a's band-matched
|
| 1190 |
+
rescale is now evidenced rather than assumed.
|
| 1191 |
+
|
| 1192 |
+
One caveat kept in view: 1.6% in the latent against 8% in pixels. The VAE
|
| 1193 |
+
decode is nonlinear, so the two are not expected to be proportional, but the
|
| 1194 |
+
gap is large enough that the lever's gain will have to be fitted against
|
| 1195 |
+
measured output rather than derived from the latent ratio.
|
| 1196 |
+
|
| 1197 |
+
### Two probe defects the real data exposed
|
| 1198 |
+
|
| 1199 |
+
**Cached latents did not load at all.** `torch.load` has to import
|
| 1200 |
+
`comfy.nested_tensor` to rebuild the object; without the ComfyUI root on
|
| 1201 |
+
`sys.path`, `store._get_latent` caught the ModuleNotFoundError and the probe
|
| 1202 |
+
printed "none cached for this hop" -- reporting a path problem as an absent
|
| 1203 |
+
latent. `hopcache.enable_latent_reads()` appends the root and nothing else;
|
| 1204 |
+
the module imports only torch when pickle reaches for it, so it is safe to run
|
| 1205 |
+
beside a queued render.
|
| 1206 |
+
|
| 1207 |
+
**Band energy was not exposure-normalised.** The probe's own docstring claimed
|
| 1208 |
+
band-pass output "does not care about the local mean", which is true of an
|
| 1209 |
+
offset and false of a scale: brighten a frame 5% and every band grows with it.
|
| 1210 |
+
The 3-hop chain's luma rose 4.7%, so whole-frame mid read `x1.084` when the
|
| 1211 |
+
texture part was `n1.035`. Both columns are printed now. The head box was
|
| 1212 |
+
unaffected either way -- the brightening was in the background -- which is
|
| 1213 |
+
exactly the kind of thing a single whole-frame number cannot tell you.
|
|
@@ -24,6 +24,26 @@ import types
|
|
| 24 |
_COMFY_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
|
| 25 |
os.path.dirname(os.path.abspath(__file__)))))
|
| 26 |
DEFAULT_ROOT = os.path.join(_COMFY_ROOT, "temp", "h3_ref_chain_hops")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
|
| 29 |
def load_store():
|
|
|
|
| 24 |
_COMFY_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
|
| 25 |
os.path.dirname(os.path.abspath(__file__)))))
|
| 26 |
DEFAULT_ROOT = os.path.join(_COMFY_ROOT, "temp", "h3_ref_chain_hops")
|
| 27 |
+
COMFY_ROOT = _COMFY_ROOT
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def enable_latent_reads():
|
| 31 |
+
"""Put the ComfyUI root on sys.path so a cached latent will unpickle.
|
| 32 |
+
|
| 33 |
+
A hop's `.latent.pt` holds a `comfy.nested_tensor.NestedTensor`, and
|
| 34 |
+
`torch.load` has to import that class to rebuild it. Without this the load
|
| 35 |
+
raises ModuleNotFoundError, `store._get_latent` swallows it and prints
|
| 36 |
+
"cached latent unreadable ... falling back to the pixel pin", and a probe
|
| 37 |
+
reports "no latent cached" for every hop -- which reads as "the render did
|
| 38 |
+
not store one" rather than "this process cannot open it". The two need
|
| 39 |
+
telling apart: one is a render to redo, the other is a path entry.
|
| 40 |
+
|
| 41 |
+
Only a path entry. Nothing is imported here, and `comfy/nested_tensor.py`
|
| 42 |
+
imports nothing but torch when pickle does reach for it -- no CUDA, no
|
| 43 |
+
model management. Safe to call while a render is queued.
|
| 44 |
+
"""
|
| 45 |
+
if _COMFY_ROOT not in sys.path:
|
| 46 |
+
sys.path.append(_COMFY_ROOT)
|
| 47 |
|
| 48 |
|
| 49 |
def load_store():
|
|
@@ -100,6 +100,13 @@ def bands(gray, torch):
|
|
| 100 |
"fine": (gray - b1).abs().mean(dim=(1, 2)),
|
| 101 |
"mid": (b1 - b2).abs().mean(dim=(1, 2)),
|
| 102 |
"coarse": (b2 - b3).abs().mean(dim=(1, 2)),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
}
|
| 104 |
|
| 105 |
|
|
@@ -183,13 +190,19 @@ def measure(gray, torch, boxes):
|
|
| 183 |
|
| 184 |
def _row(label, per_band, ref, slopes):
|
| 185 |
bits = []
|
|
|
|
| 186 |
for b in BANDS:
|
| 187 |
v = float(per_band[b].mean())
|
| 188 |
if ref is None:
|
| 189 |
-
bits.append(f"{b} {v:.4f}
|
| 190 |
else:
|
| 191 |
r = v / ref[b] if ref[b] else float("nan")
|
| 192 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
tail = ""
|
| 194 |
if slopes is not None:
|
| 195 |
tail = " slope/100f " + " ".join(
|
|
@@ -283,13 +296,15 @@ def run_video(args, torch, stride):
|
|
| 283 |
|
| 284 |
for name in ("head", "bg", "whole"):
|
| 285 |
vals = per[name]
|
| 286 |
-
ref = {b: float(vals[b][edges[0]:edges[1]].mean())
|
|
|
|
| 287 |
print(f" [{name}]")
|
| 288 |
for i in range(segs):
|
| 289 |
a, z = edges[i], edges[i + 1]
|
| 290 |
if z - a < 2:
|
| 291 |
continue
|
| 292 |
-
print(_row(f"seg {i + 1}",
|
|
|
|
| 293 |
None if i == 0 else ref, None))
|
| 294 |
print()
|
| 295 |
|
|
@@ -306,6 +321,9 @@ def run_video(args, torch, stride):
|
|
| 306 |
|
| 307 |
|
| 308 |
def run_cache(args, torch, stride):
|
|
|
|
|
|
|
|
|
|
| 309 |
store, hops, report = hopcache.select(args.root, args.chain)
|
| 310 |
if report:
|
| 311 |
print(report)
|
|
@@ -332,7 +350,8 @@ def run_cache(args, torch, stride):
|
|
| 332 |
g = luma(imgs, torch)
|
| 333 |
per = measure(g, torch, boxes)
|
| 334 |
if ref is None:
|
| 335 |
-
ref = {k: {b: float(per[k][b].mean()) for b in BANDS
|
|
|
|
| 336 |
print(f"hop {hop} ({key[:8]}): {imgs.shape[0]} sampled frames of "
|
| 337 |
f"{w}x{h} luma {float(g.mean()):.4f}")
|
| 338 |
for name in ("head", "bg", "whole"):
|
|
|
|
| 100 |
"fine": (gray - b1).abs().mean(dim=(1, 2)),
|
| 101 |
"mid": (b1 - b2).abs().mean(dim=(1, 2)),
|
| 102 |
"coarse": (b2 - b3).abs().mean(dim=(1, 2)),
|
| 103 |
+
# This box's own mean level, carried so the row can normalise by it.
|
| 104 |
+
# A band-pass removes the LOCAL mean, which is why it ignores a
|
| 105 |
+
# brightness offset -- but it is not scale-free: brighten a picture by
|
| 106 |
+
# 5% and every band amplitude grows about 5% with it, texture
|
| 107 |
+
# unchanged. On a chain whose exposure also drifts, the raw ratio and
|
| 108 |
+
# the normalised one answer different questions and both are wanted.
|
| 109 |
+
"_luma": gray.mean(dim=(1, 2)),
|
| 110 |
}
|
| 111 |
|
| 112 |
|
|
|
|
| 190 |
|
| 191 |
def _row(label, per_band, ref, slopes):
|
| 192 |
bits = []
|
| 193 |
+
lum = float(per_band["_luma"].mean())
|
| 194 |
for b in BANDS:
|
| 195 |
v = float(per_band[b].mean())
|
| 196 |
if ref is None:
|
| 197 |
+
bits.append(f"{b} {v:.4f} ")
|
| 198 |
else:
|
| 199 |
r = v / ref[b] if ref[b] else float("nan")
|
| 200 |
+
# n = the same ratio with exposure divided out. Where n and x
|
| 201 |
+
# disagree, the difference is the scene getting brighter, not
|
| 202 |
+
# rougher -- and only n is the texture ratchet.
|
| 203 |
+
rn = ((v / lum) / (ref[b] / ref["_luma"])
|
| 204 |
+
if ref[b] and lum and ref["_luma"] else float("nan"))
|
| 205 |
+
bits.append(f"{b} {v:.4f} x{r:.3f} n{rn:.3f}")
|
| 206 |
tail = ""
|
| 207 |
if slopes is not None:
|
| 208 |
tail = " slope/100f " + " ".join(
|
|
|
|
| 296 |
|
| 297 |
for name in ("head", "bg", "whole"):
|
| 298 |
vals = per[name]
|
| 299 |
+
ref = {b: float(vals[b][edges[0]:edges[1]].mean())
|
| 300 |
+
for b in tuple(BANDS) + ("_luma",)}
|
| 301 |
print(f" [{name}]")
|
| 302 |
for i in range(segs):
|
| 303 |
a, z = edges[i], edges[i + 1]
|
| 304 |
if z - a < 2:
|
| 305 |
continue
|
| 306 |
+
print(_row(f"seg {i + 1}",
|
| 307 |
+
{b: vals[b][a:z] for b in tuple(BANDS) + ("_luma",)},
|
| 308 |
None if i == 0 else ref, None))
|
| 309 |
print()
|
| 310 |
|
|
|
|
| 321 |
|
| 322 |
|
| 323 |
def run_cache(args, torch, stride):
|
| 324 |
+
# Before select(): the store reads latents during get(), and an
|
| 325 |
+
# unimportable NestedTensor is reported as an absent one.
|
| 326 |
+
hopcache.enable_latent_reads()
|
| 327 |
store, hops, report = hopcache.select(args.root, args.chain)
|
| 328 |
if report:
|
| 329 |
print(report)
|
|
|
|
| 350 |
g = luma(imgs, torch)
|
| 351 |
per = measure(g, torch, boxes)
|
| 352 |
if ref is None:
|
| 353 |
+
ref = {k: {b: float(per[k][b].mean()) for b in tuple(BANDS) + ("_luma",)}
|
| 354 |
+
for k in per}
|
| 355 |
print(f"hop {hop} ({key[:8]}): {imgs.shape[0]} sampled frames of "
|
| 356 |
f"{w}x{h} luma {float(g.mean()):.4f}")
|
| 357 |
for name in ("head", "bg", "whole"):
|