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.
66 lines
2.3 KiB
Python
Executable File
66 lines
2.3 KiB
Python
Executable File
#!/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()
|