formats: the resting pose is the hold, not the longest dwell

Element::rest() picked the keyframe with the largest gap to the next keyframe's
time. That reads a keyframe as a value held until the next one; it is the start
of a ramp toward it. A long gap after keyframe k means the screen spends that
time arriving at k+1, so the settled pose is at the far end of the gap.

The title wordmark zooms in over five frames and holds at (184,193) at 100% from
t=251 to t=264. The old rule picked the frame before the long gap: (179,186) at
101%, still mid-zoom.

Measured against the framebuffer capture of the running title screen, which is a
1:1 crop so frame coordinates map directly (confirmed: the copyright line lands
on row 669 in the capture and in both composites). Edge-correlated over the
wordmark box:

    plateau (landed)      best 0.4597 at shift (0,0)
    longest dwell (old)   best 0.1511 at shift (+3,+8), 0.1268 at (0,0)

The old composite scores 3x lower and only peaks after being moved, by about the
(-5,-7) that picking kf4 instead of kf5 predicts.

It also fixes six title elements the old rule rested at alpha 0x00 where the
capture plainly shows them, and pteff00.prm - the full-screen fade quad painted
last - which rested at opaque black. That was the blocker on .prm compositing.

Adds tools/re-capture/align_to_capture.py, which is how this was scored, and
turns the .prm test that deliberately asserted the old defect into a guard on
the fix.

Not settled and now the next item: compose ignores the keyframe fade alpha
entirely (blit modulates by tint only), which is why choosing the wrong keyframe
was invisible until now.
This commit is contained in:
Sylpheed RE agent
2026-08-19 06:41:24 +00:00
parent eb61368d23
commit b5c44b7c2a
6 changed files with 246 additions and 19 deletions

View File

@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Score a composite against a framebuffer capture of the running game.
Edge-correlates the two over a region and reports the best shift. A composite
that is *right* peaks at (0,0); one that is displaced peaks somewhere else and
scores lower, which is how the resting-pose rule was settled
(docs/re/structures/ui-resting-pose.md).
Colour is deliberately discarded — the capture and the composite differ in
palette (undecoded materials, a different animation moment for the background),
so a raw pixel diff is dominated by things the geometry question does not care
about. Gradient magnitude keeps the edges, which is where placement lives.
Both images must share an origin. A capture that is a 1:1 *crop* of the frame
is fine; a scaled one is not, and must be resampled first.
align_to_capture.py CAPTURE COMPOSITE [COMPOSITE...] [--region X0 Y0 X1 Y1]
"""
import argparse
import numpy as np
from PIL import Image
def edges(path):
a = np.asarray(Image.open(path).convert("L"), dtype=float)
gy, gx = np.gradient(a)
return np.hypot(gx, gy)
def norm(a):
return (a - a.mean()) / (a.std() + 1e-9)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("capture")
ap.add_argument("composite", nargs="+")
ap.add_argument("--region", nargs=4, type=int, metavar=("X0", "Y0", "X1", "Y1"),
default=[150, 200, 1150, 400],
help="frame-space box to score over (default: the title wordmark)")
ap.add_argument("--radius", type=int, default=14, help="max shift searched, px")
args = ap.parse_args()
x0, y0, x1, y1 = args.region
ref = norm(edges(args.capture)[y0:y1, x0:x1])
r = args.radius
for path in args.composite:
img = edges(path)
best = (-2.0, 0, 0)
for dy in range(-r, r + 1):
for dx in range(-r, r + 1):
pat = img[y0 + dy:y1 + dy, x0 + dx:x1 + dx]
if pat.shape != ref.shape:
continue
s = float((ref * norm(pat)).mean())
if s > best[0]:
best = (s, dx, dy)
zero = float((ref * norm(img[y0:y1, x0:x1])).mean())
print(f"{path}: best {best[0]:.4f} at ({best[1]:+d},{best[2]:+d}) "
f"at (0,0): {zero:.4f}")
if __name__ == "__main__":
main()