Sandpies Claude Opus 5 commited on
Commit
3a25965
·
1 Parent(s): 883a617

texture_probe: read cached latents, normalise for exposure, and a correction

Browse files

Run 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

Files changed (4) hide show
  1. CLAUDE.md +2 -1
  2. docs/DEVLOG.md +74 -0
  3. tools/hopcache.py +20 -0
  4. tools/texture_probe.py +24 -5
CLAUDE.md CHANGED
@@ -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 not injected at the join.** The climb is continuous through each hop with no step at the seams, so the boundary is the ratchet *pawl*, not the cause: `prev_imgs = imgs[-tail_n:]` hands forward the hop's tail, which is its most degraded stretch. It is also independent of exposure -- on the measured clips luma held at 92 -> 90 with anchoring on while mid-band climbed 17-35%. Do not expect `tone_compensate` to touch it, and do not tune it on a 2-hop chain; the effect only separates at 3+.
 
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
docs/DEVLOG.md CHANGED
@@ -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.
tools/hopcache.py CHANGED
@@ -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():
tools/texture_probe.py CHANGED
@@ -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
- bits.append(f"{b} {v:.4f} x{r:.3f}")
 
 
 
 
 
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()) for b in BANDS}
 
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}", {b: vals[b][a:z] for b in BANDS},
 
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} for k in per}
 
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"):