agents: the splash does not animate, and three instruments could not see it
Some checks failed
Some checks failed
A human on a GPU at ~140 fps: "the logos just switch, there is no animation at
all." Measured from a real boot with --film at 0.05 s, then per-frame change:
splash moves 1.30 s of 7.95 s = 16.4 %
publisher splash 0.30 s of motion, then 3.20 s FROZEN
developer splash 0.35 s + 0.25 s, then 2.40 s FROZEN
distinct luma states in 7.95 s 26
A 45-unit build-in cannot be drawn in 26 states, and a fade does not hold one
picture for 3.20 s. The frame counter says 24.8 fps achieved; both are true --
the port is DRAWING 25 times a second and CHANGING almost never.
🔴 Why every check passed, which matters more than the bug:
frozen sweep drives the clock BY HAND -- proves the renderer can draw
pose N, never that the poses are drawn in sequence
settled compare 0.01 % against the capture -- a screen frozen 84 % of the
time matches a settled reference PERFECTLY, that is what
frozen means
achieved fps counts frames DRAWN -- the same pixels 25x/s scores
identically to animating
Every one measured throughput or a pose. None measured CHANGE. Same shape as
InputEventAction bypassing the input map: the instrument sat below the thing
that was broken, so the break could not appear in it.
tools/motion-census closes the class. It measures change and nothing else, and
its --selftest asserts it separates a fade (97.4 % moving) from a switch (2.6 %)
from a frozen film (0.0 %) -- a detector that cannot tell those apart would
report the same green line on all three.
Both briefs: this is the SOLE focus. The port reproduces before changing
anything and gates every fix on a film rather than a still. The decoder maps the
whole pipeline end to end -- disc bytes, the game's per-frame update (does it
interpolate between keyframes or hold?), what is submitted per frame, and what
Canary does to it before a capture records it -- delivered as a SERIES, not a
settled value.
The port should also record the refutation against itself: H2 reads ANSWERED on
the strength of the frozen sweep. The mechanism half stands, the blur is a baked
companion texture. The behaviour half does not.
This commit is contained in:
114
docs/agents/PLAYTEST-2026-09-02.md
Normal file
114
docs/agents/PLAYTEST-2026-09-02.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# Play-test, 2026-09-02 — **the splash does not animate at all**
|
||||||
|
|
||||||
|
**This is the only thing either agent works on until it is fixed.**
|
||||||
|
|
||||||
|
The human, watching the port on real hardware, on a GPU, at ~140 fps:
|
||||||
|
|
||||||
|
> *"The port does no blur animation at all! I cannot discern if there is any
|
||||||
|
> animation at all. The logos just switch without the animation."*
|
||||||
|
|
||||||
|
## Measured, not paraphrased
|
||||||
|
|
||||||
|
Filmed from a **real boot** at 0.05 s (`--film`), then per-frame change measured
|
||||||
|
with [`tools/motion-census`](../../tools/motion-census):
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| splash moves | **1.30 s of 7.95 s — 16.4 %** |
|
||||||
|
| publisher splash | 0.30 s of motion, then **3.20 s frozen** |
|
||||||
|
| developer splash | 0.35 s + 0.25 s, then **2.40 s frozen** |
|
||||||
|
| distinct luma states in 7.95 s | **26** |
|
||||||
|
|
||||||
|
A 45-unit build-in cannot be drawn in 26 states, and a fade does not hold one
|
||||||
|
picture for 3.20 s. **This is a switch with a flicker on either side.**
|
||||||
|
|
||||||
|
The frame counter says 24.8 fps achieved. Both are true: the port is *drawing*
|
||||||
|
25 times a second and *changing* almost never.
|
||||||
|
|
||||||
|
## 🔴 Why three instruments all said it was correct
|
||||||
|
|
||||||
|
This is the part that matters more than the bug, because it is the fourth time
|
||||||
|
this shape has cost a milestone.
|
||||||
|
|
||||||
|
| instrument | what it proved | what it could not see |
|
||||||
|
|---|---|---|
|
||||||
|
| **frozen sweep** (`--time=`, 3 units a step) | the renderer CAN draw pose *N* | whether the poses are ever drawn **in sequence, while running** |
|
||||||
|
| **settled comparison** (0.01 % against the capture) | the resting pose is right | a screen frozen 84 % of the time matches a settled reference **perfectly — that is what frozen means** |
|
||||||
|
| **achieved-fps counter** | frames are being drawn | drawing the **same pixels** 25×/s scores identically to animating |
|
||||||
|
|
||||||
|
> **Every one measured throughput or a pose. Not one measured CHANGE.**
|
||||||
|
|
||||||
|
That is why the port could write *"the companion quads are drawn, verified by a
|
||||||
|
frozen sweep"* and be simultaneously right and useless: the sweep drives the
|
||||||
|
clock by hand. It is the same defect as `InputEventAction` bypassing the input
|
||||||
|
map — **the instrument sat below the thing that was broken**, so the thing that
|
||||||
|
was broken could not appear in it.
|
||||||
|
|
||||||
|
`tools/motion-census` exists to close this class. It measures change and nothing
|
||||||
|
else, and its `--selftest` proves it separates a fade (97.4 % moving) from a
|
||||||
|
switch (2.6 %) from a frozen film (0.0 %) — because a detector that cannot tell
|
||||||
|
those apart would report the same green line on all three.
|
||||||
|
|
||||||
|
## What both agents do now
|
||||||
|
|
||||||
|
**Nothing else.** Not the clock rate, not the plate, not blend, not audio. This
|
||||||
|
first.
|
||||||
|
|
||||||
|
### Port
|
||||||
|
|
||||||
|
1. **Reproduce it** with `--film` + `motion-census` before changing anything, and
|
||||||
|
quote the numbers. If your run does not reproduce 16.4 %, say so — the
|
||||||
|
disagreement is then the finding.
|
||||||
|
2. **Find why the clock does not advance the poses.** Candidates, unranked and
|
||||||
|
none established: the keyframe interpolation returns the same pose for a
|
||||||
|
range of *t*; `rest()`/plateau logic snapping to an endpoint; the group clock
|
||||||
|
not integrating; interpolation between keyframes not happening at all
|
||||||
|
(nearest-keyframe rather than lerp); the screen advancing by *keyframe index*
|
||||||
|
instead of by time.
|
||||||
|
3. **Every fix is gated by a film**, never by a still. A change that improves a
|
||||||
|
settled frame and leaves the film at 16 % has not fixed this.
|
||||||
|
4. `motion-census` goes into `check-all`, so a future regression fails a check
|
||||||
|
instead of waiting for a human.
|
||||||
|
|
||||||
|
### Decoder — **map the whole graphics pipeline, end to end**
|
||||||
|
|
||||||
|
The human's instruction, in their words: *"get the whole graphics pipeline, from
|
||||||
|
the xex/pe + the disc files to the final screen displayed, and take Xenia Canary
|
||||||
|
processing into account too."*
|
||||||
|
|
||||||
|
So: one continuous account, each stage with evidence and each labelled by the
|
||||||
|
instrument that produced it —
|
||||||
|
|
||||||
|
```
|
||||||
|
disc bytes → RATC/T8aD decode → what the GAME CODE does per frame
|
||||||
|
→ the draw calls it submits → Canary's own processing
|
||||||
|
→ the presented frame
|
||||||
|
```
|
||||||
|
|
||||||
|
Specifically, and none of it inferable from a file alone:
|
||||||
|
|
||||||
|
* **The game's per-frame update.** Which code advances a UI group's clock, in
|
||||||
|
what units, and what it does *between* keyframes — does it interpolate, or
|
||||||
|
hold to the next key? That single question decides whether the port should
|
||||||
|
lerp at all. It is in the image; find the function.
|
||||||
|
* **What the game submits per frame during the splash** — the draw list frame by
|
||||||
|
frame, not one settled frame. If the alpha changes, it changes *somewhere*
|
||||||
|
observable: a vertex colour, a PS constant, a blend factor, a texture swap.
|
||||||
|
**Name which, with the per-frame series.**
|
||||||
|
* **What Canary does to it.** Present cadence, any resolve/scaling/gamma between
|
||||||
|
the guest's draw and the pixels a capture records. A capture is evidence about
|
||||||
|
*Canary's output*, and the difference between that and the guest's intent has
|
||||||
|
bitten this corpus before (the `kernel_display_gamma_type` entry).
|
||||||
|
|
||||||
|
⚠️ **Deliver a per-frame SERIES, not a settled value.** Follow
|
||||||
|
[`TEMPORAL-VERIFICATION.md`](TEMPORAL-VERIFICATION.md): film it, align by
|
||||||
|
content, report ordering/counts/durations. The port needs to know what the
|
||||||
|
alpha *trajectory* is, and a single frame cannot carry one.
|
||||||
|
|
||||||
|
## And this is a refutation the port should record against itself
|
||||||
|
|
||||||
|
`BLOCKED.md` H2 currently reads ✅ **ANSWERED**, on the strength of the frozen
|
||||||
|
sweep. The mechanism half stands — the blur is a baked companion texture, that
|
||||||
|
is decoded and correct. **The behaviour half does not**: the port draws those
|
||||||
|
quads and does not animate them, so *"the companions are drawn"* was true and
|
||||||
|
did not mean what the row used it to mean.
|
||||||
@@ -1,7 +1,58 @@
|
|||||||
You are the **Decoder**. Answer the open questions the Godot menu port is
|
You are the **Decoder**. Answer the open questions the Godot menu port is
|
||||||
blocked on, one at a time.
|
blocked on, one at a time.
|
||||||
|
|
||||||
## 🔴 CURRENT FOCUS, set by the human 2026-09-01: THE LOGO SPLASHES, EXACTLY
|
## 🔴🔴 SOLE FOCUS, set by the human 2026-09-02: THE WHOLE GRAPHICS PIPELINE
|
||||||
|
|
||||||
|
**Read [`PLAYTEST-2026-09-02.md`](PLAYTEST-2026-09-02.md) first. Work nothing
|
||||||
|
else until this is answered.**
|
||||||
|
|
||||||
|
A human on real hardware: *"the logos just switch, there is no animation."*
|
||||||
|
Measured from a real boot — **the splash moves 1.30 s of 7.95 s (16.4 %)**, the
|
||||||
|
publisher logo frozen **3.20 s**, and the whole thing takes **26 distinct luma
|
||||||
|
states**. The port draws the right quads in the right places and never moves
|
||||||
|
them.
|
||||||
|
|
||||||
|
Your half is not the port's bug. It is that **nobody can say what the game does
|
||||||
|
between keyframes**, so nobody can say what the port should be doing.
|
||||||
|
|
||||||
|
### The deliverable, in the human's words
|
||||||
|
|
||||||
|
> *"Get the whole graphics pipeline, from the xex/pe + the disc files to the
|
||||||
|
> final screen displayed. Take Xenia Canary processing into account too."*
|
||||||
|
|
||||||
|
One continuous account, each stage carrying its evidence and its `⟨instrument⟩`:
|
||||||
|
|
||||||
|
```
|
||||||
|
disc bytes → RATC/T8aD decode → what the GAME CODE does per frame
|
||||||
|
→ the draw calls it submits → Canary's own processing
|
||||||
|
→ the presented frame
|
||||||
|
```
|
||||||
|
|
||||||
|
Three questions that are load-bearing and none answerable from a file alone:
|
||||||
|
|
||||||
|
1. **The per-frame update.** Which function advances a UI group's clock, in what
|
||||||
|
units, and **what does it do BETWEEN keyframes** — interpolate, or hold to the
|
||||||
|
next key? That single answer decides whether the port should lerp at all. It
|
||||||
|
is in the image. Find it.
|
||||||
|
2. **What is submitted per frame during the splash** — the draw list frame by
|
||||||
|
frame, not one settled frame. If alpha changes it changes *somewhere*
|
||||||
|
observable: a vertex colour, a PS constant, a blend factor, a texture swap.
|
||||||
|
**Name which, and give the per-frame series.**
|
||||||
|
3. **What Canary does to it** — present cadence, and any resolve, scale or gamma
|
||||||
|
between the guest's draw and the pixels a capture records. A capture is
|
||||||
|
evidence about *Canary's output*; the gap between that and the guest's intent
|
||||||
|
has bitten this corpus before (`kernel_display_gamma_type`).
|
||||||
|
|
||||||
|
⚠️ **Deliver a SERIES, not a settled value.** Follow
|
||||||
|
[`TEMPORAL-VERIFICATION.md`](TEMPORAL-VERIFICATION.md): film it, align by
|
||||||
|
content, report ordering and counts and durations. The port needs the alpha
|
||||||
|
*trajectory*; a single frame cannot carry one.
|
||||||
|
[`../../tools/motion-census`](../../tools/motion-census) measures change and
|
||||||
|
nothing else — use it on your own captures too, and note that three of the
|
||||||
|
port's instruments passed a frozen screen because each measured throughput or a
|
||||||
|
pose rather than change.
|
||||||
|
|
||||||
|
## Previous focus, 2026-09-01 (still live, but AFTER the above)
|
||||||
|
|
||||||
A human played the port on real hardware and reported that the splashes are
|
A human played the port on real hardware and reported that the splashes are
|
||||||
**close but not right** — the fade/blur is more pronounced in the game — and that
|
**close but not right** — the fade/blur is more pronounced in the game — and that
|
||||||
|
|||||||
@@ -1,6 +1,53 @@
|
|||||||
You are the **Port**. Build the Godot menu shell, one milestone at a time.
|
You are the **Port**. Build the Godot menu shell, one milestone at a time.
|
||||||
|
|
||||||
## 🔴 CURRENT FOCUS, set by the human 2026-09-01: THE LOGO SPLASHES, EXACTLY
|
## 🔴🔴 SOLE FOCUS, set by the human 2026-09-02: THE SPLASH DOES NOT ANIMATE
|
||||||
|
|
||||||
|
**Read [`../agents/PLAYTEST-2026-09-02.md`](../agents/PLAYTEST-2026-09-02.md)
|
||||||
|
first. Work nothing else until this is fixed** — not the clock rate, not the
|
||||||
|
plate, not blend, not audio.
|
||||||
|
|
||||||
|
> *"The port does no blur animation at all. The logos just switch."*
|
||||||
|
|
||||||
|
Measured from a real boot, not paraphrased: **the splash moves 1.30 s of 7.95 s
|
||||||
|
(16.4 %)**, the publisher logo is **frozen for 3.20 s**, the developer logo for
|
||||||
|
2.40 s, and the whole 7.95 s takes **26 distinct luma states**. A 45-unit
|
||||||
|
build-in cannot be drawn in 26 states.
|
||||||
|
|
||||||
|
🔴 **Your three instruments all passed this, and the reason is the point:**
|
||||||
|
|
||||||
|
* the **frozen sweep** drives the clock by hand — it proves the renderer can
|
||||||
|
draw pose *N*, never that the poses are drawn in sequence while running;
|
||||||
|
* the **settled comparison** scored 0.01 % — a screen frozen 84 % of the time
|
||||||
|
matches a settled reference *perfectly*, because that is what frozen means;
|
||||||
|
* the **achieved-fps counter** counts frames DRAWN — drawing the same pixels
|
||||||
|
25×/s scores exactly like animating.
|
||||||
|
|
||||||
|
**Every one measured throughput or a pose. None measured CHANGE.** Same shape as
|
||||||
|
`InputEventAction` bypassing the input map: the instrument sat below the thing
|
||||||
|
that was broken.
|
||||||
|
|
||||||
|
**Use [`tools/motion-census`](../../tools/motion-census)** — it measures change
|
||||||
|
and nothing else, and its `--selftest` proves it separates a fade from a switch
|
||||||
|
from a frozen film. Order of work:
|
||||||
|
|
||||||
|
1. **Reproduce first**, with `--film` + `motion-census`, and quote the numbers.
|
||||||
|
If you do not get ~16 %, that disagreement is the finding — say so.
|
||||||
|
2. **Find why the poses do not advance.** Unranked, none established:
|
||||||
|
interpolation returning one pose across a range of *t*; `rest()`/plateau
|
||||||
|
snapping to an endpoint; the group clock not integrating; nearest-keyframe
|
||||||
|
instead of lerp; advancing by keyframe *index* rather than by time.
|
||||||
|
3. **Every fix is gated by a FILM, never a still.** A change that improves a
|
||||||
|
settled frame and leaves the film at 16 % has not fixed this.
|
||||||
|
4. Put `motion-census` in `check-all` so the regression fails a check instead of
|
||||||
|
waiting for a human.
|
||||||
|
|
||||||
|
⚠️ **And record the refutation against yourself.** `BLOCKED.md` H2 reads ✅
|
||||||
|
ANSWERED on the strength of the frozen sweep. The *mechanism* half stands — the
|
||||||
|
blur is a baked companion texture, decoded and correct. The *behaviour* half does
|
||||||
|
not: you draw those quads and do not animate them, so "the companions are drawn"
|
||||||
|
was true and did not mean what the row used it to mean.
|
||||||
|
|
||||||
|
## Previous focus, 2026-09-01 (still live, but AFTER the above)
|
||||||
|
|
||||||
A human played this port on a real controller for the first time. Read
|
A human played this port on a real controller for the first time. Read
|
||||||
[`../agents/PLAYTEST-2026-09-01.md`](../agents/PLAYTEST-2026-09-01.md) **before
|
[`../agents/PLAYTEST-2026-09-01.md`](../agents/PLAYTEST-2026-09-01.md) **before
|
||||||
|
|||||||
199
tools/motion-census
Executable file
199
tools/motion-census
Executable file
@@ -0,0 +1,199 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Does it ANIMATE, or does it switch? — compare a film, never two stills.
|
||||||
|
|
||||||
|
tools/motion-census <dir-of-frames> [--interval 0.05] [--rows] [--roi x,y,w,h]
|
||||||
|
tools/motion-census --selftest
|
||||||
|
|
||||||
|
Point it at a directory of PNGs captured in order during a REAL run — the port's
|
||||||
|
`--film`, or a capture harness's frame dump. It reports where the picture moves
|
||||||
|
and where it is frozen, plus how many distinct states it ever took.
|
||||||
|
|
||||||
|
Why this exists
|
||||||
|
---------------
|
||||||
|
🔴 A human played the port and said *"the logos just switch, there is no
|
||||||
|
animation at all."* Every check either agent had said the splash was correct.
|
||||||
|
Three instruments agreed, and all three were blind in the same way:
|
||||||
|
|
||||||
|
* a **frozen sweep** — step the clock by hand, render a still per unit. That
|
||||||
|
proves the renderer CAN draw pose N. It never runs the animation.
|
||||||
|
* a **settled comparison** — correlate the resting pose against a capture. A
|
||||||
|
screen that is frozen 84 % of the time matches a settled reference
|
||||||
|
perfectly; that is what being frozen MEANS.
|
||||||
|
* an **achieved-fps counter** — frames DRAWN per second. Drawing the same
|
||||||
|
pixels 25 times a second scores identically to animating at 25 fps.
|
||||||
|
|
||||||
|
The common defect: **every one measured throughput or a pose, and none measured
|
||||||
|
CHANGE.** So this measures change and nothing else.
|
||||||
|
|
||||||
|
measured 2026-09-02 on the boot splash: moving 1.30 s of 7.95 s (16.4 %),
|
||||||
|
publisher frozen for 3.20 s, developer for 2.40 s, 26 distinct luma values.
|
||||||
|
|
||||||
|
What the numbers mean
|
||||||
|
---------------------
|
||||||
|
A fade is ONE LONG RUN of small non-zero deltas. A switch is isolated one-frame
|
||||||
|
spikes with flat nothing between them. The `distinct states` count is the blunt
|
||||||
|
version of the same question: a 45-unit ramp cannot be drawn in 26 states.
|
||||||
|
|
||||||
|
⚠️ It cannot tell you the animation is CORRECT — only that something moved. A
|
||||||
|
wrong ramp that moves every frame passes here. Pair it with a comparison against
|
||||||
|
the oracle; this is the liveness half, which is the half that was missing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
except ImportError:
|
||||||
|
sys.exit("motion-census: needs Pillow (pip install pillow)")
|
||||||
|
|
||||||
|
# Below this, two frames are the same picture. Chosen as a floor, not tuned: PNG
|
||||||
|
# frames of an unchanged scene differ by exactly 0.000, so anything above noise
|
||||||
|
# works and a bigger number would only hide small fades.
|
||||||
|
MOVED = 0.05
|
||||||
|
|
||||||
|
# Downsample before comparing. A fade moves every pixel a little, so it survives
|
||||||
|
# scaling; scaling also stops one stray cursor pixel reading as motion.
|
||||||
|
GRID = (160, 90)
|
||||||
|
|
||||||
|
|
||||||
|
def load(path: Path, roi=None) -> list[int]:
|
||||||
|
im = Image.open(path).convert("L")
|
||||||
|
if roi:
|
||||||
|
x, y, w, h = roi
|
||||||
|
im = im.crop((x, y, x + w, y + h))
|
||||||
|
return list(im.resize(GRID).getdata())
|
||||||
|
|
||||||
|
|
||||||
|
def census(frames: list[Path], interval: float, roi=None):
|
||||||
|
prev, rows = None, []
|
||||||
|
for i, f in enumerate(frames):
|
||||||
|
px = load(f, roi)
|
||||||
|
lum = sum(px) / len(px)
|
||||||
|
delta = 0.0 if prev is None else sum(abs(a - b) for a, b in zip(px, prev)) / len(px)
|
||||||
|
rows.append((i * interval, delta, lum))
|
||||||
|
prev = px
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def segments(rows):
|
||||||
|
segs, cur, start = [], None, 0.0
|
||||||
|
for t, delta, _ in rows[1:]:
|
||||||
|
state = "MOVING" if delta > MOVED else "static"
|
||||||
|
if state != cur:
|
||||||
|
if cur is not None:
|
||||||
|
segs.append((cur, start, t))
|
||||||
|
cur, start = state, t
|
||||||
|
if cur is not None:
|
||||||
|
segs.append((cur, start, rows[-1][0]))
|
||||||
|
return segs
|
||||||
|
|
||||||
|
|
||||||
|
def report(rows, interval: float, show_rows: bool) -> int:
|
||||||
|
total = rows[-1][0]
|
||||||
|
segs = segments(rows)
|
||||||
|
|
||||||
|
if show_rows:
|
||||||
|
print(f"{'t(s)':>7} {'Δ prev':>8} {'luma':>7} bar")
|
||||||
|
for t, delta, lum in rows:
|
||||||
|
print(f"{t:>7.2f} {delta:>8.3f} {lum:>7.2f} {'#' * min(60, int(delta * 6))}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print(f"{len(rows)} frames @{interval}s = {total:.2f}s\n")
|
||||||
|
print(f"{'state':<8} {'from':>7} {'to':>7} {'dur':>7} luma")
|
||||||
|
for state, a, b in segs:
|
||||||
|
lums = [l for t, _, l in rows if a <= t <= b]
|
||||||
|
flag = " <-- FROZEN" if state == "static" and (b - a) > 0.5 else ""
|
||||||
|
print(f"{state:<8} {a:>7.2f} {b:>7.2f} {b-a:>7.2f} "
|
||||||
|
f"{min(lums):.2f}..{max(lums):.2f}{flag}")
|
||||||
|
|
||||||
|
moving = sum(b - a for s, a, b in segs if s == "MOVING")
|
||||||
|
pct = 100 * moving / total if total else 0.0
|
||||||
|
states = len({round(l, 2) for _, _, l in rows})
|
||||||
|
print(f"\nmoving {moving:.2f}s of {total:.2f}s = {pct:.1f}%")
|
||||||
|
print(f"distinct luma states: {states}")
|
||||||
|
longest = max((b - a for s, a, b in segs if s == "static"), default=0.0)
|
||||||
|
print(f"longest frozen stretch: {longest:.2f}s")
|
||||||
|
print()
|
||||||
|
if pct < 50:
|
||||||
|
print("🔴 This is a SWITCH, not an animation. Most of the run is one still")
|
||||||
|
print(" picture. A frozen sweep and a settled comparison both pass on this.")
|
||||||
|
return 1
|
||||||
|
print("moves for most of its length — liveness only; correctness is a separate")
|
||||||
|
print("question this tool cannot answer.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def selftest() -> int:
|
||||||
|
"""Synthetic controls, executed. A detector that cannot tell a fade from a
|
||||||
|
switch would report the same green line on both, and its verdict on the real
|
||||||
|
film would mean nothing."""
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
ok = 0
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
root = Path(d)
|
||||||
|
|
||||||
|
def write(name, values):
|
||||||
|
sub = root / name
|
||||||
|
sub.mkdir()
|
||||||
|
for i, v in enumerate(values):
|
||||||
|
Image.new("L", (320, 180), int(v)).save(sub / f"f{i:04d}.png")
|
||||||
|
return sorted(sub.glob("*.png"))
|
||||||
|
|
||||||
|
# A fade: every frame differs from the last.
|
||||||
|
fade = write("fade", [10 + i * 4 for i in range(40)])
|
||||||
|
rows = census(fade, 0.05)
|
||||||
|
pct = 100 * sum(b - a for s, a, b in segments(rows) if s == "MOVING") / rows[-1][0]
|
||||||
|
good = pct > 90
|
||||||
|
print(f" {'a real fade reads as MOVING':<40} {pct:5.1f}% {'✅' if good else '🔴'}")
|
||||||
|
ok |= 0 if good else 1
|
||||||
|
|
||||||
|
# A switch: one value, then another, held. This is the shape the port
|
||||||
|
# actually produced, and the case the old instruments could not see.
|
||||||
|
sw = write("switch", [10] * 20 + [200] * 20)
|
||||||
|
rows = census(sw, 0.05)
|
||||||
|
pct = 100 * sum(b - a for s, a, b in segments(rows) if s == "MOVING") / rows[-1][0]
|
||||||
|
good = pct < 20
|
||||||
|
print(f" {'a switch reads as STATIC':<40} {pct:5.1f}% {'✅' if good else '🔴'}")
|
||||||
|
ok |= 0 if good else 1
|
||||||
|
|
||||||
|
# Fully frozen: the degenerate case must not read as motion.
|
||||||
|
fr = write("frozen", [42] * 30)
|
||||||
|
rows = census(fr, 0.05)
|
||||||
|
pct = 100 * sum(b - a for s, a, b in segments(rows) if s == "MOVING") / rows[-1][0]
|
||||||
|
good = pct == 0
|
||||||
|
print(f" {'a frozen film reads as 0% motion':<40} {pct:5.1f}% {'✅' if good else '🔴'}")
|
||||||
|
ok |= 0 if good else 1
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("the census separates a fade from a switch" if not ok
|
||||||
|
else "🔴 the census cannot tell a fade from a switch — it asserts nothing")
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
if "--selftest" in argv:
|
||||||
|
return selftest()
|
||||||
|
args = [a for a in argv if not a.startswith("--")]
|
||||||
|
if not args:
|
||||||
|
sys.exit(__doc__.split("\n\n")[1])
|
||||||
|
interval = 0.05
|
||||||
|
roi = None
|
||||||
|
for i, a in enumerate(argv):
|
||||||
|
if a == "--interval" and i + 1 < len(argv):
|
||||||
|
interval = float(argv[i + 1])
|
||||||
|
if a == "--roi" and i + 1 < len(argv):
|
||||||
|
roi = tuple(int(v) for v in argv[i + 1].split(","))
|
||||||
|
d = Path(args[0])
|
||||||
|
frames = sorted(d.glob("*.png"))
|
||||||
|
if len(frames) < 3:
|
||||||
|
sys.exit(f"motion-census: {d} holds {len(frames)} PNG(s) — nothing to compare.\n"
|
||||||
|
" Exit 2: the harness is broken, not the animation.")
|
||||||
|
return report(census(frames, interval, roi), interval, "--rows" in argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main(sys.argv[1:]))
|
||||||
Reference in New Issue
Block a user