Put the frame in a container with no decoder, and find the palette is not free

ROADMAP K2. DLXP1: a 49,664 B record that is 97 sectors exactly, no index and
no length word, because a packed record's length is geometry rather than
content. 582.0 KB/s, which is what FINDINGS 61.9 predicted to the tenth, and it
encodes in 3.3 s because there is no k-means in it.

px68k's own x68k/gvram.c renders the container's bytes index-exact with the
harness computing no interleave -- the only test that can catch an encoder whose
byte order is wrong, since a container round-trips against its own inverse
either way. Both negative controls fail as they must.

The picture is re-derived against this project's builder rather than PIL's
(34.05 dB against 61.9's 34.08) and the GGGGGRRRRRBBBBBI word is charged for the
first time in this tree: 0.53 dB, on every row, so it moves no comparison.

What the control found is the finding. A packed container on a SCENE palette
lands exactly on the codec's ceiling, so the whole +2.31 dB is the per-frame
palette and nothing else -- and 231 of 256 entries change every frame, which
makes a mismatched paint 12.8 dB worse than the correct pairing, on screen for
roughly half of every frame slot if buffer mode does not blank. So B2 now
decides which packed CONTAINER ships, not only which player. The fallback is
already a flag: --scene-palette --no-palette is 30.79 dB, zero churn, 576.0 KB/s
and still +2.07 dB on the shipping codec.

62.5 is priced and is a wash: palette first 20.32 dB, palette last 20.33.

Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
This commit is contained in:
prosolis
2026-08-25 07:31:05 -07:00
parent 07f36c2af9
commit f1007a0dbc
15 changed files with 1189 additions and 16 deletions
+1
View File
@@ -11,6 +11,7 @@ roms/
__pycache__/ __pycache__/
*.pyc *.pyc
*.dlx *.dlx
*.dlxp
a.out a.out
tmp/ tmp/
tools/bench/c68k/c68k_bench tools/bench/c68k/c68k_bench
+56
View File
@@ -256,6 +256,42 @@ run bounds the model and not the hardware, and "does a real palette register
take a byte write" joins the hardware list as B4. A negative answer costs 0.28% take a byte write" joins the hardware list as B4. A negative answer costs 0.28%
of a frame and nothing else (FINDINGS 62). of a frame and nothing else (FINDINGS 62).
**The packed container exists, and the palette that makes it better than the
codec is not free after all.** `tools/encoder/dlxp.py` is DLXP1 and `pack.py`
writes it: a **49,664 byte record that is 97 sectors exactly**, no record index
and no length word — a packed record's length is geometry, so record *i* is at
`off + i*rec` and a seek is arithmetic — at **582.0 KB/s**, which is what
FINDINGS 61.9 predicted to the tenth, encoded in **3.3 seconds** because there is
no k-means in it. **px68k's own `gvram.c` renders the container's bytes
index-exact with the harness computing no interleave**, which is the only test
that can catch an encoder whose byte order is wrong: a container round-trips
against its own inverse either way. And the picture is re-derived against this
project's own quantiser rather than PIL's — **34.05 dB against 61.9's 34.08**
with the X68000's `GGGGGRRRRRBBBBBI` word charged for the first time in this
tree, 0.53 dB, on every row, so it moves no comparison.
**What the control found is the finding.** A packed container built on a *scene*
palette lands **exactly on the codec's ceiling, 30.79 dB**, so the whole +2.31 dB
the packed branch has over that ceiling is **the per-frame palette and nothing
else**. And a per-frame palette is not a small delta: **231 of 256 entries change
every frame**, and a picture under the neighbouring frame's palette is **12.8 dB
worse** than the correct pairing — a wipe on screen for roughly half of every
frame slot, forever, *if* buffer mode does not blank the layer. Correct render,
the same frame under the next frame's palette, and the 24-bit source; the frame
is the one whose mismatch is closest to the mean, so it is not an outlier picked
to make the point:
![The palette mismatch: correct, mismatched, source](docs/img/palette-mismatch.png)
It is chroma speckle and a shifted ground rather than a scramble — two
median-cut palettes of adjacent frames occupy a similar gamut — which is milder
than 12.8 dB sounds and worse than a still can show, because a still does not
show it arriving and leaving twelve times a second. So B2 stopped
being a question about headroom and became one about **which packed container
ships**. The fallback is already a flag: `--scene-palette --no-palette` is
30.79 dB, zero churn, **576.0 KB/s**, and still +2.07 dB on the shipping codec as
the display renders both (FINDINGS 63).
**The scene graph is in, and the worst gap between two decision points is **The scene graph is in, and the worst gap between two decision points is
zero.** `tools/import/scenegraph.py` imports the arcade scene graph — 40 scenes, zero.** `tools/import/scenegraph.py` imports the arcade scene graph — 40 scenes,
516 sequences, 906 input windows — and 5.4% of the game's 612 branch transitions 516 sequences, 906 input windows — and 5.4% of the game's 612 branch transitions
@@ -469,6 +505,15 @@ tools/bench/c68k/ headless px68k C68K harness, a SECOND emulator for every
which MAME cannot report. The Makefile's -no-pie and the which MAME cannot report. The Makefile's -no-pie and the
harness's MAP_32BIT arena are load-bearing: C68K truncates harness's MAP_32BIT arena are load-bearing: C68K truncates
host pointers to 32 bits. host pointers to 32 bits.
tools/bench/gvpack/ the same second-emulator argument for the DISPLAY: it links
px68k's real x68k/gvram.c, so the address decode, the R20
bit-11 write path, the page-byte selection, the scroll wrap and
the index-0 transparency test are px68k's own code.
verify_gvpack.py checks the LAYOUT (the harness computes the
interleave); verify_dlxp.py checks the CONTAINER, writing a
DLXP1 record's bytes into GVRAM verbatim with no interleave
computed anywhere, which is the only way to catch an encoder
whose byte order is wrong.
25 imports nothing itself: it reads the DLXSCENE1 scene 25 imports nothing itself: it reads the DLXSCENE1 scene
table and reports the worst gap between two decision points, table and reports the worst gap between two decision points,
what the input layer has to survive, and what both cost in what the input layer has to survive, and what both cost in
@@ -479,6 +524,11 @@ tools/import/ the ONLY code in this tree coupled to somebody else's source.
project's own DLXSCENE1 schema, with the sources' licences and project's own DLXSCENE1 schema, with the sources' licences and
attribution inside it. Nothing is vendored and the output is attribution inside it. Nothing is vendored and the output is
gitignored derived data. gitignored derived data.
30 is the PACKED container's gate: the format's invariants
(round-trip, sectors, the transparency key, the palette word),
and the quality re-derivation 61.9 asked for, in both the RGB888
domain every encoder PSNR here is quoted in and the GRB555 one a
player actually displays.
tools/media/ builds docs/img/ from a paced recording run tools/media/ builds docs/img/ from a paced recording run
tools/vasm/ vasm m68k assembler, binary plus source tarball tools/vasm/ vasm m68k assembler, binary plus source tarball
tools/encoder/ hybrid VQ encoder and DLX3 container writer. tools/encoder/ hybrid VQ encoder and DLX3 container writer.
@@ -492,6 +542,12 @@ tools/encoder/ hybrid VQ encoder and DLX3 container writer.
record as whole sectors straight into the ring with no window record as whole sectors straight into the ring with no window
and no bounce copy; dlx.record_lengths() is the one place that and no bounce copy; dlx.record_lengths() is the one place that
rule is applied. rule is applied.
dlxp.py and pack.py are the OTHER container -- DLXP1, the
decoder-free packed one (FINDINGS 63). Nothing is shared with
the codec's writer on purpose: a packed record is a palette and
a picture, both geometry, and dlxp.py is the one place the
interleave and the 97-sector record are stated. There is no
rate control in pack.py because there is no rate lever.
dlx.py is the reference DECODER, ground truth for the 68000. dlx.py is the reference DECODER, ground truth for the 68000.
24 models the ring with the 68000 owning it: the request 24 models the ring with the 68000 owning it: the request
queue, the poll-only-when-not-decoding rule and 54.4's frame queue, the poll-only-when-not-decoding rule and 54.4's frame
+158
View File
@@ -6432,3 +6432,161 @@ encoder K2 has not been written yet, and the whole question is **moot if buffer
mode blanks the layer** (47.4/B2) because nothing is displayed during the paint mode blanks the layer** (47.4/B2) because nothing is displayed during the paint
at all. **Filed, not answered.** It is named here so that the choice in the at all. **Filed, not answered.** It is named here so that the choice in the
final player is a decision rather than an accident of which run happened first. final player is a decision rather than an accident of which run happened first.
## 63. The packed container, and the palette that buys 2.31 dB has a price nobody had counted (session 31)
**Emulated, and one stage of it is not emulated at all.** The quality numbers
are host arithmetic over the Blu-ray's own frames. The rendering check is
**px68k's real `x68k/gvram.c`**, linked headless the way `tools/bench/c68k`
links its CPU core. No MAME run was needed for any of this and no real hardware
ran. 47.4 — does buffer mode blank the layer — is still the board question, and
this session made it **bigger**.
ROADMAP **K2 is done**. `tools/encoder/dlxp.py` is the format,
`tools/encoder/pack.py` is the encoder, `tools/analysis/30_packed_container.py`
is the gate and the re-derivation, `tools/bench/gvpack/verify_dlxp.py` renders
the container's own bytes on the second emulator, and `check.sh` runs all four.
### 63.1 DLXP1: the container whose correctness is that nothing parses it
`dlx.py` exists because a 68000 has to **parse** the codec's container and can
get it wrong. `dlxp.py` exists for the opposite reason: a DMA channel must not
have to parse anything, and the format is what makes that true.
| | |
|---|---|
| record | **49,664 B = 97 sectors EXACTLY** — 512 B of palette, 49,152 B of picture |
| picture | 192 rows x 128 words, word `i` = `(pix[y][i+128] << 8) \| pix[y][i]` — the layout 47.2 rendered pixel-exactly on both emulators |
| index | **none, and none is possible to need**: a record's length is geometry, so record `i` is at `off_frm + i * rec_bytes` and a seek is arithmetic |
| wire | **582.0 KB/s, fixed** — 61.9 predicted 582.0 and the container is 582.0 |
| encode | **3.3 s for 120 frames**, against ~55 s for the codec, 95% of which is k-means |
DLX4's record index was invented (49.3) because a codec record's length is
content-dependent and a producer streaming off a disc cannot learn it by walking
bytes it has not fetched. **A packed record has no such problem to solve**, and
that is the same reason the packed player has no ring: there is nothing
variable-length to keep contiguous.
**Sector alignment, which cost session 28 a whole re-encode (60.1), is free
here.** 49,664 is 97 sectors because a 256x192 picture and a 256-entry palette
happen to be. It is still *checked* rather than assumed, because the thing being
protected is not tidiness: the channel copies bytes and has no opinion about
them, so a container whose geometry is a byte wrong does not fail, it paints.
### 63.2 The container's OWN BYTES, through px68k's GVRAM code, with no interleave computed
`verify_gvpack.py` (47.2) checks the **layout**: it hands the harness a picture
and the harness computes the interleave. That cannot catch an encoder whose byte
order is wrong, because a container round-trips against its own inverse either
way. `verify_dlxp.py` writes a **DLXP1 record's bytes into GVRAM verbatim** and
asks px68k what they display as.
**256x192 index-exact, letterbox on the reserved black, index 0 never on
screen** — frames 0, 60 and 119. Two negative controls, both mechanisms this
container depends on rather than decoration: **R20 bit 11 clear loses 24,576 px**
(exactly the 128 columns page 1 carries) and **page 1 unscrolled loses 48,958**.
### 63.3 The picture, RE-DERIVED against this project's own builder, and charged the hardware word
This is what the session 30 handoff asked for. 61.9's 34.08 dB was PIL's free
256-colour MEDIANCUT and was filed as "a direction, not the player's number". It
had **two** debts, not one, and the second had gone unnamed: every PSNR this
project has ever quoted — 29.19, 31.33, 34.08 — is measured in the **RGB888
palette domain**, upstream of the X68000's `GGGGGRRRRRBBBBBI` word (23.3). A
packed record carries that word and nothing else, so a player's number has to
come from the other side of it.
PSNR vs the 24-bit source, mean over the same 120-frame window, on the gate
container's own bytes:
| | RGB888 | **GRB555** |
|---|---:|---:|
| CODEC, the gate container (440.4 KB/s) | 29.07 | **28.72** |
| 256c scene palette — the codec's CEILING | 31.32 | **30.79** |
| PACKED, 254c SCENE palette *(the control)* | 31.32 | **30.79** |
| **PACKED CONTAINER, 254c PER-FRAME** | **34.05** | **33.10** |
**61.9's direction survives the real builder: 34.05 against its 34.08.** The two
entries the packed layout reserves — index 0 for the transparency key, 255 for
black — cost **+0.0003 dB**, which is to say nothing at all, and marginally the
right way. 60.3 measured one reserved entry at 0.04 dB; two is not twice that,
it is noise.
**The GRB555 word costs 0.53 dB** and it costs every row of the table, so no
comparison in this project moves because of it. It is stated because a player's
number should be a player's number.
**And the CONTROL is the finding under the headline.** A packed container with
one scene palette lands **exactly on the codec's ceiling, 30.79 dB** — as it
must, because at that point the only difference left between them is VQ. So the
whole of the packed branch's picture advantage over the codec's *ceiling*, +2.31
dB, is **the per-frame palette and nothing else**. Not the packing, not the
literal frames, not 254 colours. One mechanism, and 61.9 named it correctly.
### 63.4 62.5 is priced, and the answer is that the ORDER does not matter and the MISMATCH does
62.5 filed palette-first-or-193rd as a free choice whose severity "depends on how
much the palette moves between consecutive frames, which is a property of the
encoder K2 has not been written yet". It is written now.
| | |
|---|---:|
| palette entries that CHANGE frame to frame | **231.1 of 256 (90%)** |
| palette FIRST — old rows under the new palette | **20.32 dB** (12.79) |
| palette LAST — new rows under the old palette | **20.33 dB** (12.78) |
**The two orders are indistinguishable — 0.01 dB apart — so 62.5's choice is a
wash and can be made on other grounds.** The container makes it a flag
(`--palette-last`, flags bit 1) rather than an assumption, so K3 can run both.
**What the same measurement found is not a wash.** A per-frame palette is not a
small delta: 90% of the entries move every frame, and a picture under the
neighbouring frame's palette is **12.8 dB worse than the correct pairing**. The
mismatch is a wipe rather than a flash — rows arrive top to bottom, so part of
the screen is always right, and the transfer is 55.2% of a frame slot (61) —
but at 12 fps that is a colour-scrambled region present for roughly half of
every frame slot, forever.
**And it was LOOKED AT, not only scored.**
`30_packed_container.py --mismatch-png` writes the frame whose mismatch is
closest to the mean — chosen that way so the picture is not an outlier picked to
flatter the number — as correct render | same frame under the next frame's
palette | 24-bit source:
![The palette mismatch: correct, mismatched, source](img/palette-mismatch.png)
**It is chroma speckle and a shifted ground, not a scramble.** Two median-cut
palettes of adjacent frames occupy a similar gamut, so nothing goes
psychedelic — the lava turns red where it should be ochre, the dragon's scales
break into noise, and the torch survives. That is milder than 12.8 dB sounds and
worse than it looks in a still, because the still does not show it arriving and
leaving twelve times a second.
**So B2 stopped being a headroom question and became a picture question, again
and worse.** 61.6 said whether buffer mode blanks decides *which player exists*.
63.4 says that if it does **not** blank, it also decides **which packed
container** exists, because the per-frame palette is what the artefact is made
of. The codec never had this exposure: its palette is scene-constant, so its
tear is old picture against new picture and never old colours against new ones.
**And the fallback is already in the encoder, which is the useful half.**
`pack.py --scene-palette` emits the control row: **30.79 dB, zero palette churn,
no mismatch to have**, still **+2.07 dB on the shipping codec as the display
renders both**, and with `--no-palette` alongside it — a scene palette is loaded
once at scene setup, which is what the codec has always done — the record loses
its 512 B and the wire is **576.0 KB/s against 582.0**, cheaper on the one
resource the packed branch is short of. The per-frame palette is worth +2.31 dB and it is now a **priced**
+2.31 dB rather than a free one.
### 63.5 What this does not settle
- **Nothing about clocks.** 29_packed_player.py owns those, off the measured
blit, and nothing here moves them: 55.2% of a frame at the 9 clk/B
dual-address floor, unchanged.
- **Nothing about the medium.** 582.0 KB/s is geometry. Whether anything
sustains it is **B1**, and this container cannot negotiate — a codec's
bitrate is a lever and a literal frame's is arithmetic.
- **Nothing on a machine.** No packed frame has been put on screen from a
container by a 68000 or by a channel. That is **K3**, and 63.2 is the strongest
statement available without it: the bytes are right, on a second emulator's
own GVRAM model, with the harness computing nothing.
+48 -10
View File
@@ -42,6 +42,19 @@ the run bounds the model and not the board, and what a real palette register
does with a byte write is UNMEASURED. B4 is the cheapest hardware item in the does with a byte write is UNMEASURED. B4 is the cheapest hardware item in the
project and a negative costs 0.28% of a frame. **K2, the packed container, is project and a negative costs 0.28% of a frame. **K2, the packed container, is
next.** next.**
Amended end of session 31: **K2 IS DONE, AND IT COST THE BRANCH SOMETHING
(FINDINGS 63).** DLXP1 is a 49,664 B record = 97 sectors exactly, no index, no
decoder, 582.0 KB/s — exactly 61.9's prediction — and px68k's own `gvram.c`
renders the container's bytes index-exact with the harness computing no
interleave. 61.9's picture claim survives the real builder: **34.05 dB against
its 34.08**, and the hardware GRB555 word is charged on top for the first time
in this project (0.53 dB, on every row, so it moves nothing). **But the control
row landed exactly on the codec's ceiling**, so the whole +2.31 dB is the
PER-FRAME PALETTE and nothing else — and 90% of that palette changes every
frame, which makes a mismatched paint **12.8 dB worse** than the correct
pairing. **B2 now decides which packed CONTAINER exists, not only which player**
(63.4). The fallback is a flag: `--scene-palette --no-palette` is 30.79 dB, zero
churn, 576.0 KB/s and still +2.07 dB on the shipping codec. **K3 is next.**
**THE COMPLETION TARGET IS M3, THE VERTICAL SLICE** (USER DECISION): one scene **THE COMPLETION TARGET IS M3, THE VERTICAL SLICE** (USER DECISION): one scene
tree — a decision point, two outcomes, a death clip — with audio, streaming from tree — a decision point, two outcomes, a death clip — with audio, streaming from
@@ -128,6 +141,17 @@ the priority register `0xE82500` at `0x0000` (47.3).
> is dark for **30..113%**. Under MAME's reading the cheap architecture is the > is dark for **30..113%**. Under MAME's reading the cheap architecture is the
> dark one. **B2 and B1 are now the same decision from two sides, and B2 is the > dark one. **B2 and B1 are now the same decision from two sides, and B2 is the
> five-minute half.** > five-minute half.**
>
> **Session 31 raised it again, from the other direction (FINDINGS 63.4).** If
> buffer mode does NOT blank, the packed player's per-frame palette is not
> merely visible during the paint — **231 of 256 entries change every frame**,
> and a picture under the neighbouring frame's palette is **12.8 dB worse** than
> the correct pairing, on screen for roughly half of every frame slot at 12 fps.
> So a non-blanking board does not just cost the packed player a blank interval,
> it may cost it the per-frame palette — which is **the whole +2.31 dB** the
> branch has over the codec's ceiling. The fallback is already an encoder flag
> (`pack.py --scene-palette --no-palette`: 30.79 dB, no churn, 576.0 KB/s), so
> what B2 decides is now **which packed container ships**, not whether one can.
**B4. Does a real palette register take a BYTE write?** (62.4, new session 30.) **B4. Does a real palette register take a BYTE write?** (62.4, new session 30.)
`$E82000` is 256 16-bit registers. The decoder-free packed player's per-frame `$E82000` is 256 16-bit registers. The decoder-free packed player's per-frame
@@ -243,14 +267,25 @@ frame is not tied to a scene palette the codec's codewords index into. It costs
over `memory_array`, whose `write16` is a plain `COMBINE_DATA`, so it has no over `memory_array`, whose `write16` is a plain `COMBINE_DATA`, so it has no
handler to be wrong about and cannot discriminate. And it filed one open handler to be wrong about and cannot discriminate. And it filed one open
design choice, 62.5: palette FIRST or 193rd is visible on screen and is not design choice, 62.5: palette FIRST or 193rd is visible on screen and is not
decided. decided. **Session 31 PRICED it and it is a wash — 20.32 dB against 20.33
- **K2. A packed container.** Quantise to **254** colours (index 0 is the (63.4) — so it is a container flag (`--palette-last`) and K3 runs both.**
transparency key, black at 255 — 47.2), interleave column *i* with *i+128*, ~~**K2. A packed container.**~~
emit a per-frame palette, sector-align the record. Simpler than a coded one: **DONE, session 31 — FINDINGS 63.** `tools/encoder/dlxp.py` is DLXP1 and
no VQ, no rate control, no mode map, no `lam`. `encode.py` already aligns `pack.py` writes it: 254 colours with index 0 held free and black at 255,
(DLX5) and `prep_frame.py --pack-transparent` already emits the layout. column *i* interleaved with *i+128*, a per-frame palette, and a **49,664 B
**Re-derive 61.9's 34.08 dB against `vq.scene_palette` here** — the measured record that is 97 sectors EXACTLY** — the alignment that cost session 28 a
figure is PIL's MEDIANCUT and is a direction, not the player's number. re-encode is free here because a packed record's length is geometry. **No
index and no length word**, for the same reason: nothing has to be walked.
**582.0 KB/s, which is 61.9's prediction to the tenth.** Encodes in 3.3 s
because there is no k-means in it.
**The re-derivation is done and 61.9 survives it: 34.05 dB against 34.08**,
and the GRB555 word is charged for the first time (63.3) — 0.53 dB, on every
row of the table, so it moves no comparison. **The two reserved entries cost
0.0003 dB.**
**What it also found** (63.4): the SCENE-palette control lands exactly on the
codec's ceiling, so the whole +2.31 dB is the per-frame palette; 90% of that
palette changes every frame; and a mismatched paint is 12.8 dB worse, for
roughly half of every frame slot, if buffer mode does not blank.
- **K3. End to end, off the disc.** Palette, page-1 X-scroll 384, priority - **K3. End to end, off the disc.** Palette, page-1 X-scroll 384, priority
`vc1 = 0x0002`, R20 bit 11; one chained DMA a frame. Gated pixel-exact over the `vc1 = 0x0002`, R20 bit 11; one chained DMA a frame. Gated pixel-exact over the
same 120-frame window the decoder is gated on. same 120-frame window the decoder is gated on.
@@ -261,8 +296,10 @@ DMAC-direct packed player has **no ring** — `ring.i`, `xfer.i` and most of
simplification that large usually hides something, and 61.7.2 names the specific simplification that large usually hides something, and 61.7.2 names the specific
untested thing: a chained transfer has never run back to back at 12 fps. untested thing: a chained transfer has never run back to back at 12 fps.
**K1 and K2 survive a bad answer to B2. K3 does not.** ~~Do K1 first.~~ **K1 is **K1 and K2 survive a bad answer to B2. K3 does not.** ~~Do K1 first.~~ ~~K1 is
done (session 30, FINDINGS 62); K2 is next.** done (session 30, FINDINGS 62); K2 is next.~~ **Both are done. K3 is next — and
63.4 added a second thing for it to run: BOTH palette orders, which is a flag
(`--palette-last`) and not a re-encode.**
--- ---
@@ -683,6 +720,7 @@ P2 re-encode bundle DONE (60): DLX5, records ARE sectors ───────
K1 palette-register DMA? ─> K2 packed container ─> K3 end to end ──┤ K1 palette-register DMA? ─> K2 packed container ─> K3 end to end ──┤
(61.9; K1/K2 survive a bad B2, K3 does not) │ (61.9; K1/K2 survive a bad B2, K3 does not) │
K1 DONE s30 (62), K2 DONE s31 (63) ── K3 is the open one │
P4a WIRING (the channel behind ring.i's mailbox) <- THE LAST ITEM ─┤ P4a WIRING (the channel behind ring.i's mailbox) <- THE LAST ITEM ─┤
P1 P2 P3 P4b P5 P7, P6 bus cost (52), G1 scene graph (56) ────────┼─> M2 ─> P1 P2 P3 P4b P5 P7, P6 bus cost (52), G1 scene graph (56) ────────┼─> M2 ─>
B1 seek+rate (sets HEADROOM, not fit) ─────────────────────────────┘ M3 ─> M4 B1 seek+rate (sets HEADROOM, not fit) ─────────────────────────────┘ M3 ─> M4
+112
View File
@@ -1,3 +1,115 @@
# Status & next-session handoff — end of session 31 (2026-08-25)
## Session 31: the packed container, and the palette that buys 2.31 dB has a price
**Green light first and last: `./tools/bench/check.sh` was ALL GREEN before any
of this (`tmp/check_s31_start.log`) and ALL GREEN after** — the same stages, plus
one new one.
**FINDINGS 63.** Mostly **host arithmetic** over the Blu-ray's own frames; the
one rendering check is **px68k's real `x68k/gvram.c`**, linked headless the way
`tools/bench/c68k` links its CPU core. No MAME run was needed and no real
hardware ran. 47.4 — does buffer mode blank the layer — is still the board
question, and this session made it **bigger**.
**THE HEADLINE. ROADMAP K2 is done.** `tools/encoder/dlxp.py` is the format,
`pack.py` the encoder, `tools/analysis/30_packed_container.py` the gate and the
re-derivation, `tools/bench/gvpack/verify_dlxp.py` the second-emulator render.
| | |
|---|---|
| record | **49,664 B = 97 sectors EXACTLY**, 512 B palette + 49,152 B picture |
| index | **none, and none can be needed** — a packed record's length is geometry |
| wire | **582.0 KB/s**, exactly what 61.9 predicted |
| encode | **3.3 s** for 120 frames, against ~55 s for the codec |
| render | **index-exact on px68k, with the harness computing no interleave** |
**1. THE RE-DERIVATION SESSION 30 ASKED FOR, and it had TWO debts not one**
(63.3). 61.9's 34.08 dB was PIL's free 256-colour MEDIANCUT; the unnamed second
debt was that **every PSNR this project has quoted — 29.19, 31.33, 34.08 — is
RGB888, upstream of the `GGGGGRRRRRBBBBBI` word (23.3)**, and a packed record
carries that word and nothing else.
| | RGB888 | **GRB555** |
|---|---:|---:|
| CODEC, the gate container (440.4 KB/s) | 29.07 | **28.72** |
| 256c scene palette — the codec's CEILING | 31.32 | **30.79** |
| PACKED, 254c SCENE palette *(control)* | 31.32 | **30.79** |
| **PACKED CONTAINER, 254c PER-FRAME** | **34.05** | **33.10** |
61.9 survives the real builder: **34.05 against 34.08**. The layout's two
reserved entries cost **+0.0003 dB**. The GRB555 word costs 0.53 dB and costs
every row, so it moves no comparison.
**2. THE CONTROL IS THE FINDING UNDER THE HEADLINE.** A packed container with a
SCENE palette lands **exactly on the codec's ceiling**, as it must. So the whole
+2.31 dB the packed branch has over that ceiling is **the per-frame palette and
nothing else** — not the packing, not literal frames, not 254 colours.
**3. 62.5 IS PRICED, AND THE ORDER IS A WASH** (63.4). Palette first 20.32 dB,
palette last 20.33 — 0.01 apart, so the choice can be made on other grounds, and
`dlxp.py` makes it a container flag rather than an assumption.
**4. AND THE MISMATCH IS NOT A WASH.** **231 of 256 palette entries change every
frame (90%)**, and a picture under the neighbouring frame's palette is **12.8 dB
worse**. It is a wipe, not a flash, and the transfer is 55.2% of a frame slot —
so at 12 fps a colour-scrambled region is on screen for roughly half of every
slot, forever. **B2 stopped being a headroom question and became a picture
question again**: if buffer mode does not blank, it decides not just which
player exists (61.6) but **which packed container** does. The codec never had
this exposure — its palette is scene-constant, so its tear is old picture
against new picture, never old colours against new ones.
**5. THE FALLBACK IS ALREADY IN THE ENCODER.** `pack.py --scene-palette
--no-palette`: **30.79 dB, zero churn, nothing to mismatch, +2.07 dB on the
shipping codec as the display renders both, and 576.0 KB/s instead of 582.0.**
The per-frame palette is now a **priced** +2.31 dB rather than a free one.
## HANDOFF — start here
**THE TREE IS ALL GREEN.** Session 31's work is a commit of its own.
### The work, in the order it should be done
**1. K3 — END TO END, OFF THE DISC.** Scene setup — palette, page-1 X-scroll
384, priority `vc1 = 0x0002`, R20 bit 11, the 384 zeroed words a row and the
letterbox rows — then **one chained DMA a frame** out of a DLXP1 record, with
the palette as the 193rd or 1st array entry (63.4 says either). Gate it
pixel-exact over the same 120-frame window the decoder is gated on. The
container is `tmp/packed_singe.dlxp` and `check.sh` rebuilds it every run.
**2. RUN BOTH PALETTE ORDERS while K3 is being built.** It is a flag
(`--palette-last`), the containers already exist, and 63.4 bounds the cost but
cannot decide it — dB over a whole frame is not what an eye sees in a wipe.
### What is PARKED, so it is not re-derived
**E7, E4 and C1** (61.8), and **P4a's wiring** for the video path. Do not delete
any of it — B2 is unanswered and 48.1's prior leans against packing.
### Risks that are OURS, not hardware
1. **A chained transfer has never run back to back at 12 fps.** Unchanged since
session 30: run 9 was seven entries, once. A frame is 193 and a second is
twelve frames.
2. ~~34.08 dB is PIL's MEDIANCUT~~ **CLOSED, 63.3: 34.05 dB against this
project's own builder, and the hardware word is charged on top.**
3. **The packed player deletes `ring.i`, `xfer.i` and most of `stream.s` from
the video path.** A simplification that large usually hides something.
4. **B2 sits over all of it, and 63.4 raised what it decides.** B4 still sits
over the palette entry specifically, with a small blast radius.
### Reproducing this session
./tools/bench/check.sh # ALL GREEN
python3 tools/encoder/pack.py tmp/fr_singe tmp/packed_singe.dlxp --nframes 120
python3 tools/analysis/30_packed_container.py tmp/packed_singe.dlxp
python3 tools/bench/gvpack/verify_dlxp.py tmp/packed_singe.dlxp 0 --controls
**WHAT IS NEXT.** K3: end to end, off the disc.
---
# Status & next-session handoff — end of session 30 (2026-08-25) # Status & next-session handoff — end of session 30 (2026-08-25)
## Session 30: one channel start paints a whole frame, and the palette has no handler to be wrong about ## Session 30: one channel start paints a whole frame, and the palette has no handler to be wrong about
Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

+282
View File
@@ -0,0 +1,282 @@
#!/usr/bin/env python3
"""The PACKED container: does it hold, and what is the picture actually worth?
python3 tools/analysis/30_packed_container.py [packed.dlxp]
[--frames tmp/fr_singe] [--codec tmp/rc_fr_singe_scsi_span.dlx]
ROADMAP K2. Two jobs, and they are different kinds of claim.
1. THE FORMAT HOLDS. A packed record is written into the palette registers and
GVRAM by a DMA channel with no bounds test anywhere -- the channel has no
opinion about what it is copying (FINDINGS 62) -- so "the geometry is right"
is not a tidiness check, it is the whole of the container's correctness.
Round-trip, sector geometry, and the two reserved indices are gated here.
2. THE PICTURE IS RE-DERIVED, and this is the number session 30 asked for.
FINDINGS 61.9 measured the packed player at 34.08 dB against the codec's
29.19 and filed TWO caveats: the quantiser was PIL's free 256-colour
MEDIANCUT rather than this project's builder, and the figure was quoted in
the RGB888 palette domain. Both are paid here:
* `vq.frame_palette` is what ships it -- 254 colours, because the packed
layout spends index 0 on the transparency key and 255 on black (47.2).
* the GRB555+I WORD is charged. A palette entry in a packed record is
already a hardware word; the display renders 5 bits a channel with one
shared LSB (23.3). Every PSNR in this project's encoder is measured
upstream of that, so the codec is charged it here too and the comparison
stays like for like.
And the scene-palette CONTROL is built and scored, because "per-frame
palettes became legal" is the mechanism 61.9 credits and an unrun control is
an assumption. The codec cannot take this row: every codeword it emits is an
index INTO `vq.scene_palette`, so 31.33 dB is its ceiling at any bitrate.
WHAT THIS DOES NOT DO. It does not put a packed frame on a machine -- that is
K3, and the layout itself was already rendered pixel-exactly on both emulators
in 47.2. It does not price clocks: 29_packed_player.py owns that, off the
MEASURED blit, and nothing here moves it. And it settles nothing about the
medium: 582.0 KB/s is geometry, and whether anything sustains it is B1.
"""
import argparse, glob, os, sys
sys.path.insert(0, "tools/encoder")
sys.path.insert(0, "tools/bench")
import numpy as np
from PIL import Image
import vq as VQ
import dlxp as P
from dlx import DLX
from dlxload import pack_palette
ap = argparse.ArgumentParser()
ap.add_argument("packed", nargs="?", default="tmp/packed_singe.dlxp")
ap.add_argument("--frames", default="tmp/fr_singe")
ap.add_argument("--mismatch-png", default=None,
help="write the 62.5 mismatch as a picture: correct render | the "
"same frame under the NEXT frame's palette | the 24-bit "
"source. A dB is not a look, and this claim is about a look.")
ap.add_argument("--codec", default="tmp/rc_fr_singe_scsi_span.dlx",
help="the shipping container this replaces. Its PSNR is COMPUTED "
"from its own bytes, not transcribed from docs (60.8).")
a = ap.parse_args()
fail = []
d = P.DLXP(a.packed) # every format invariant is checked in here
print(f"{a.packed}: DLXP{d.version} {d.W}x{d.H} {d.fps}fps {d.nframes} frames")
print()
# --- 1. the format -----------------------------------------------------------
print("THE FORMAT, and why each line is a gate and not a courtesy check:")
print(f" record {d.rec_bytes:,} B = {d.rec_bytes // P.SECTOR} sectors exactly, "
f"palette {d.pal_bytes} B "
f"{'LAST' if d.palette_last else 'first'}, picture {d.pic_bytes:,} B")
print(f" 1.0 B/pixel: {d.pic_bytes} bytes carry {d.W * d.H} pixels "
f"(the unpacked path needs {2 * d.W * d.H:,})")
zero = black = 0
for f in range(d.nframes):
idx = d.indices(f)
# The channel copies bytes; a container whose interleave is a byte out does
# not fail, it paints. So the round trip is the assertion that the bytes in
# the record ARE the picture, in the order GVRAM wants them.
if P.pack_picture(idx).tobytes() != d._split(f)[1]:
fail.append(f"frame {f}: the record does not round-trip through the "
f"interleave -- the container is not what it says it is")
break
zero += int((idx == 0).sum())
black += int((idx == 255).sum())
if zero:
fail.append(f"index 0 appears in the picture {zero:,} times -- it is the "
f"TRANSPARENCY KEY of the top page and must stay unused (47.2)")
print(f" round-trip: {d.nframes} records unpack and re-pack byte-identical")
print(f" index 0 (transparency key) used {zero} times; "
f"index 255 (black) {black:,} times in the picture")
kbps = d.kbps()
geom = d.rec_bytes * d.fps / 1024
if abs(kbps - geom) > 1e-6:
fail.append(f"wire {kbps} != geometry {geom}")
print(f" wire {kbps:.1f} KB/s = {d.rec_bytes:,} B x {d.fps} fps. FIXED. A codec's "
f"bitrate is a lever and a literal frame's is geometry (61.6)")
print()
# --- 2. the picture ----------------------------------------------------------
files = sorted(glob.glob(f"{a.frames}/f*.png"))[:d.nframes]
if len(files) < d.nframes:
sys.exit(f"{a.frames}: {len(files)} frames, container has {d.nframes}")
src = [np.asarray(Image.open(f).convert("RGB")) for f in files]
def rendered(pal):
"""RGB888 as the DISPLAY produces it, from the same maths the loader uses."""
return pack_palette(np.asarray(pal, np.uint8))[2]
def score(name, pal_rgb, idx_frames, note=""):
"""Two columns: the palette domain every encoder PSNR in this tree is
quoted in, and the hardware word the display actually renders."""
ren = rendered(pal_rgb)
p_pal = np.mean([VQ.psnr(s, np.asarray(pal_rgb)[i])
for s, i in zip(src, idx_frames)])
p_hw = np.mean([VQ.psnr(s, ren[i]) for s, i in zip(src, idx_frames)])
print(f" {name:<44s} {p_pal:6.2f} {p_hw:6.2f} {note}")
return p_pal, p_hw
print("PSNR vs the 24-bit source, mean over frames:")
print(f" {'':<44s} {'RGB888':>6} {'GRB555':>6}")
codec_pal = codec_hw = None
if os.path.exists(a.codec):
c = DLX(a.codec)
if c.nframes < d.nframes:
print(f" (the codec container has {c.nframes} frames and this has "
f"{d.nframes} -- its row is skipped rather than compared over a "
f"different window)")
else:
# Its rate is printed with it because this is the GATE container -- the
# heaviest stream the encoder emits, `--kbps 280 --span-kbps 488
# --spans all` (check.sh) -- and NOT the 496.7 KB/s / 29.19 dB "current
# encode" of the README. Two containers, two numbers; a row that named
# neither would invite the difference to be read as a drift.
ckbps = sum(c.record_lengths()) * c.fps / c.nframes / 1024
codec_pal, codec_hw = score("CODEC, the GATE container", c.pal,
c.decode_all()[:d.nframes],
f"{ckbps:.1f} KB/s, "
f"{os.path.basename(a.codec)}")
else:
print(f" (no codec container at {a.codec} -- its row is skipped)")
# The codec's CEILING: 256 colours, one palette for the scene, no VQ loss at
# all. Not a rival, a bound -- no bitrate takes the codec past this row.
ref, spal = VQ.scene_palette(src, reserve_black=True)
sidx = VQ.palettise(src, ref)
ceil_pal, ceil_hw = score("256c SCENE palette -- the CODEC'S CEILING",
spal, sidx, "no bitrate crosses this")
# The control for the mechanism 61.9 credits: same LAYOUT and the same 254
# picture colours, one palette for the scene instead of one per frame. It is
# built to 255 with black reserved and then black is MOVED from 0 to 255, which
# is the packed layout's convention (47.2) rather than the codec's -- so the
# only variable between this row and the container's is per-frame vs scene-wide.
cref, c255 = VQ.scene_palette(src, colors=255, reserve_black=True)
cpal = np.vstack([np.zeros((1, 3), np.uint8), c255[1:],
np.zeros((1, 3), np.uint8)])
cidx = [np.where(i == 0, np.uint8(255), i)
for i in VQ.palettise(src, cref)]
ctl_pal, ctl_hw = score("PACKED, 254c SCENE palette [the CONTROL]", cpal, cidx)
# And the container itself. The right-hand column is read out of the CONTAINER'S
# OWN BYTES -- `DLXP.render` unpacks the GRB555 words the record carries -- and
# the left-hand one is recomputed from the encoder, because a packed record has
# no RGB888 palette in it to score. The two are tied together by a gate rather
# than by trust: the palettes the encoder builds here must reproduce the
# container's indices exactly, or the left column is describing a different file.
pk_idx, pk_pal_rgb, mismatch, palbad = [], [], 0, 0
for n, s in enumerate(src):
pal, idx = VQ.frame_palette(s)
if not np.array_equal(idx, d.indices(n)):
mismatch += 1
# And the WORD. The encoder packed GRB555+I with `dlxload.pack_palette` and
# `DLXP.palette_rgb` unpacks it: two separate pieces of maths over the same
# 23.3 rule, and a container is the only place they meet. Required to agree,
# not assumed to -- a wrong shared LSB is a 1.96 dB bug that still renders.
if not np.array_equal(rendered(pal), d.palette_rgb(n)):
palbad += 1
pk_idx.append(idx)
pk_pal_rgb.append(pal)
if palbad:
fail.append(f"{palbad} of {d.nframes} records carry palette words that do "
f"not unpack to the RGB the encoder packed -- pack_palette and "
f"DLXP.palette_rgb disagree about GRB555+I")
if mismatch:
fail.append(f"{mismatch} of {d.nframes} frames re-quantise to different "
f"indices than the container holds -- the RGB888 column would "
f"be scoring a file that is not this one")
pk_pal = np.mean([VQ.psnr(s, p[i]) for s, p, i in zip(src, pk_pal_rgb, pk_idx)])
pk_hw = np.mean([VQ.psnr(s, d.render(f)) for f, s in enumerate(src)])
print(f" {'PACKED CONTAINER, 254c PER-FRAME':<44s} {pk_pal:6.2f} {pk_hw:6.2f} "
f"GRB555 read out of {os.path.basename(a.packed)}")
print()
print(" The right-hand column is the PLAYER'S number. Every PSNR this project")
print(" has quoted -- 29.19, 31.33, 34.08 -- lives in the left one, upstream of")
print(" the 5-bit hardware word (23.3), and 61.9's 34.08 is directly comparable")
print(" to the packed row's left-hand entry and to nothing else.")
print()
# --- 3. what it means --------------------------------------------------------
if codec_hw is not None:
print(f" packed vs the codec gate container, as the DISPLAY renders both: "
f"{pk_hw - codec_hw:+.2f} dB")
print(f" packed vs the codec's CEILING: "
f"{pk_hw - ceil_hw:+.2f} dB")
print(f" what the PER-FRAME palette is worth (vs the control): "
f"{pk_hw - ctl_hw:+.2f} dB")
print(f" what the GRB555 word costs the ceiling row: "
f"{ceil_hw - ceil_pal:+.2f} dB")
# 60.3 measured ONE reserved entry at 0.04 dB; the packed layout spends two.
# Scored here at scene scale, where the control makes it a clean subtraction.
print(f" what the packed layout's TWO reserved entries cost: "
f"{ctl_pal - ceil_pal:+.4f} dB (256c -> 254c, scene palette, RGB888)")
print()
# The three claims 61.9 makes, restated as gates. A tree where any of these
# flipped has a different answer to ROADMAP K and should say so out loud.
if codec_hw is not None and pk_hw <= codec_hw:
fail.append(f"the packed container is {pk_hw:.2f} dB and the codec it "
f"replaces is {codec_hw:.2f} -- 61.9's headline is inverted")
if pk_hw <= ceil_hw:
fail.append(f"the packed container is {pk_hw:.2f} dB and the codec's own "
f"CEILING is {ceil_hw:.2f} -- the per-frame palette bought "
f"nothing, and 61.9's reason for building this branch is gone")
if pk_hw <= ctl_hw:
fail.append(f"per-frame {pk_hw:.2f} dB is not better than the SCENE-palette "
f"control {ctl_hw:.2f} -- the mechanism 61.9 credits is absent")
# --- 4. FINDINGS 62.5, which needed this encoder to exist ---------------------
# 62.5 filed the chain's order -- palette first or 193rd -- as a free choice with
# a visible consequence, and said the severity "depends on how much the palette
# moves between consecutive frames, which is a property of the encoder K2 has
# not been written yet". It is written now, so the number exists.
#
# The mismatch is a WIPE, not a flash: rows arrive top to bottom, so at any
# instant part of the screen is right. What is bounded here is the WORST
# instant of each order -- the whole screen wrong -- which is the start of the
# transfer for palette-first and the end of it for palette-last. The mean over
# the transfer is about half of each, because the wipe is linear in rows.
print("FINDINGS 62.5 PRICED -- palette FIRST vs LAST, at the worst instant of each:")
churn = np.mean([int((d.palette_words(n) != d.palette_words(n - 1)).sum())
for n in range(1, d.nframes)])
first = np.mean([VQ.psnr(src[n - 1], d.palette_rgb(n)[d.indices(n - 1)])
for n in range(1, d.nframes)])
last = np.mean([VQ.psnr(src[n], d.palette_rgb(n - 1)[d.indices(n)])
for n in range(1, d.nframes)])
correct = np.mean([VQ.psnr(src[n], d.render(n)) for n in range(1, d.nframes)])
print(f" palette entries that CHANGE frame to frame: {churn:.1f} of 256 "
f"({100 * churn / 256:.0f}%) -- a per-frame palette is not a small delta")
print(f" palette FIRST, old rows under the new palette: {first:6.2f} dB "
f"({first - correct:+.2f} against the correct pairing)")
print(f" palette LAST, new rows under the old palette: {last:6.2f} dB "
f"({last - correct:+.2f})")
print(f" the container is currently palette "
f"{'LAST' if d.palette_last else 'FIRST'} (dlxp.py, --palette-last)")
if a.mismatch_png:
# The frame whose mismatch is CLOSEST TO THE MEAN, so the picture is not an
# outlier picked to make the point look worse than the number.
mis = np.array([VQ.psnr(src[n - 1], d.palette_rgb(n)[d.indices(n - 1)])
for n in range(1, d.nframes)])
n = int(np.argmin(np.abs(mis - mis.mean()))) + 1
z = lambda x: np.repeat(np.repeat(x, 2, 0), 2, 1)
gap = np.full((d.H * 2, 6, 3), 30, np.uint8)
Image.fromarray(np.concatenate(
[z(d.render(n - 1)), gap, z(d.palette_rgb(n)[d.indices(n - 1)]), gap,
z(src[n - 1])], axis=1)).save(a.mismatch_png)
print(f" wrote {a.mismatch_png}: frame {n-1} correct | frame {n-1} under "
f"frame {n}'s palette ({mis[n-1]:.2f} dB) | the 24-bit source")
print(" Both are one paint, and both are MOOT if buffer mode blanks the layer")
print(" (47.4/B2). This bounds the cost of being wrong; it does not decide it,")
print(" because dB over a whole frame is not what an eye sees in a wipe.")
print()
for x in fail:
print("FAIL " + x)
sys.exit(1 if fail else 0)
+41
View File
@@ -662,4 +662,45 @@ python3 tools/analysis/29_packed_player.py "$DLX" > tmp/packed_player.log 2>&1 \
grep -aE "SKIP block PAIRS|free / DMAC->GVRAM / PACKED|^ CODEC, gate" \ grep -aE "SKIP block PAIRS|free / DMAC->GVRAM / PACKED|^ CODEC, gate" \
tmp/packed_player.log tmp/packed_player.log
echo "--- session 31: the PACKED container, and the picture re-derived (FINDINGS 63) ---"
# ROADMAP K2. The container is REBUILT every run rather than reused when
# present, the way the codec's gate container is: a packed encode is 3 seconds
# because there is no k-means in it, so there is no reason to let a stale file
# stand between the encoder and the gate.
python3 tools/encoder/pack.py tmp/fr_singe tmp/packed_singe.dlxp \
--nframes "$NF" > tmp/pack_encode.log 2>&1 \
|| { cat tmp/pack_encode.log; exit 1; }
grep -aE "^ (record|wire)" tmp/pack_encode.log
# WHAT IS GATED. Four format invariants that a DMA channel cannot check for
# itself -- it copies bytes and has no opinion about them (FINDINGS 62) -- and
# the three quality claims FINDINGS 61.9 rests the whole packed branch on:
# round-trip, sector geometry, index 0 unused, palette words agree
# packed > the shipping codec / > the codec's CEILING / > a SCENE-palette control
# A tree where any of the last three flipped has a different answer to ROADMAP K
# and should say so out loud rather than let the branch keep building.
python3 tools/analysis/30_packed_container.py tmp/packed_singe.dlxp \
--codec "$DLX" > tmp/packed_container.log 2>&1 \
|| { cat tmp/packed_container.log; exit 1; }
sed -n '/^PSNR/,$p' tmp/packed_container.log | grep -aE "RGB888|CODEC|CEILING|CONTROL|PER-FRAME|packed vs|worth|costs"
# And the container's OWN BYTES through px68k's real gvram.c, with the harness
# computing no interleave -- the only test that can catch an encoder whose byte
# order is wrong, because the container round-trips against its own inverse
# either way. Same skip-not-fail rule as the C68K stage: px68k is not in here.
if [ -f "$PX68K/x68k/gvram.c" ]; then
make -s -C tools/bench/gvpack PX68K="$PX68K"
# Frame 0 carries the two negative controls; the other two are there because
# one frame rendering does not say the container's 120th record is placed
# right, and record placement is arithmetic this format has no index to check.
python3 tools/bench/gvpack/verify_dlxp.py tmp/packed_singe.dlxp 0 --controls || exit 1
for f in $((NF / 2)) $((NF - 1)); do
# NOT piped into head: `set -e` reads a pipeline's status from its LAST
# command, so a piped verifier that failed would be reported by head's zero.
python3 tools/bench/gvpack/verify_dlxp.py tmp/packed_singe.dlxp "$f" \
> "tmp/dlxp_f$f.log" 2>&1 || { cat "tmp/dlxp_f$f.log"; exit 1; }
head -1 "tmp/dlxp_f$f.log"
done
else
echo " SKIPPED: no px68k at $PX68K -- the container's bytes were not rendered"
fi
echo "ALL GREEN" echo "ALL GREEN"
+11 -4
View File
@@ -36,11 +36,18 @@ def pack_palette(d):
which is the point: the verifier and the loader must agree or a colour bug which is the point: the verifier and the loader must agree or a colour bug
reads as a decoder bug. reads as a decoder bug.
Returns (palette bytes 256x2 big-endian, index of the darkest entry). The Returns (palette bytes 256x2 big-endian, index of the darkest entry, and the
encoder does not yet reserve a black entry (docs/STATUS.md, encoder gaps), RGB888 the hardware actually RENDERS from those words). The encoder does not
so the letterbox gets the closest thing to black the palette has. reserve a black entry in the CODEC container (docs/STATUS.md, encoder gaps),
so the letterbox gets the closest thing to black the palette has; the PACKED
container does reserve one (tools/encoder/dlxp.py, index 255).
`d` is a DLX container OR a bare (256,3) uint8 palette. The packed path has
no codebooks and so no DLX object to carry a palette on, and this had to stay
the ONE copy of the GRB555+I maths -- the verifier, the loader and now the
packed encoder all have to agree or a colour bug reads as a decoder bug.
""" """
pal = d.pal.astype(int) pal = (d if isinstance(d, np.ndarray) else d.pal).astype(int)
p6 = lambda v: ((v << 2) | (v >> 4)) & 0xFF p6 = lambda v: ((v << 2) | (v >> 4)) & 0xFF
f = pal >> 3 f = pal >> 3
render = lambda I: p6((f << 1) | I[:, None]) render = lambda I: p6((f << 1) | I[:, None])
Binary file not shown.
+28 -2
View File
@@ -97,6 +97,17 @@ int main(int argc, char **argv)
int iw = (d[4] << 8) | d[5], ih = (d[6] << 8) | d[7]; int iw = (d[4] << 8) | d[5], ih = (d[6] << 8) | d[7];
if (n < (size_t)(8 + 768 + iw * ih)) { fprintf(stderr, "short blob\n"); return 2; } if (n < (size_t)(8 + 768 + iw * ih)) { fprintf(stderr, "short blob\n"); return 2; }
const BYTE *pix = d + 8 + 768; const BYTE *pix = d + 8 + 768;
/* 'DLXQ' -- the blob is PRE-INTERLEAVED: `pix` is already the bytes a DLXP1
* record carries, in GVRAM order. The ordinary 'DLXR' path computes the
* interleave here, which tests the LAYOUT; this path tests the CONTAINER,
* by writing its bytes verbatim and asking px68k's own gvram.c what they
* display as. The two agreeing is the claim ROADMAP K2 has to make: the
* encoder's byte order is the one 47.2 verified as a picture. */
int prepacked = (d[3] == 'Q');
if (prepacked && !packed) {
fprintf(stderr, "a pre-interleaved blob has no unpacked form\n");
return 2;
}
int yoff = (H - ih) / 2; int yoff = (H - ih) / 2;
const BYTE BLACK = 255; const BYTE BLACK = 255;
@@ -121,8 +132,23 @@ int main(int argc, char **argv)
for (int y = 0; y < H; y++) { for (int y = 0; y < H; y++) {
DWORD base = 0xC00000 + y * 1024; DWORD base = 0xC00000 + y * 1024;
for (int i = 128; i < 512; i++) wr16(base + i * 2, 0); for (int i = 128; i < 512; i++) wr16(base + i * 2, 0);
for (int i = 0; i < 128; i++) for (int i = 0; i < 128; i++) {
wr16(base + i * 2, (WORD)((PIX(y, i + 128) << 8) | PIX(y, i))); WORD w;
if (prepacked) {
/* The letterbox rows are STATIC SETUP and are not in a
* record (dlxp.py), so they are supplied here, the way a
* player's scene setup supplies them: both halves BLACK. */
if (y < yoff || y >= yoff + ih)
w = (WORD)((BLACK << 8) | BLACK);
else {
const BYTE *row = pix + (y - yoff) * iw;
w = (WORD)((row[i * 2] << 8) | row[i * 2 + 1]);
}
} else {
w = (WORD)((PIX(y, i + 128) << 8) | PIX(y, i));
}
wr16(base + i * 2, w);
}
} }
if (!keepbuf) set_r20(R20_DISPLAY); /* back to display */ if (!keepbuf) set_r20(R20_DISPLAY); /* back to display */
} else { } else {
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""The PACKED CONTAINER's own bytes, through px68k's real GVRAM code.
python3 tools/bench/gvpack/verify_dlxp.py [packed.dlxp] [frame] [--controls]
`verify_gvpack.py` checks the LAYOUT: it hands the harness a picture and lets
the harness compute the interleave, so what it proves is that FINDINGS 47.2's
scheme renders. This checks the CONTAINER: it writes a DLXP1 record's bytes
into GVRAM VERBATIM -- no interleave computed anywhere in the harness -- and
asks px68k what they display as. That is the only way to test a format whose
whole design is that nothing parses it (dlxp.py): if the encoder's byte order
were wrong, every check upstream of the display would still pass, because the
container round-trips against its own inverse.
It is the same second-emulator argument tools/bench/c68k makes for cycles: the
address decode, the R20 bit-11 write path, the page-byte selection, the scroll
wrap and the index-0 transparency test are px68k's own `x68k/gvram.c`.
Two negative controls, because a test that cannot fail proves nothing, and both
are mechanisms this container depends on rather than decoration:
--nobuffer R20 bit 11 CLEAR -- the high byte of every word is masked away,
so page 1 (columns 128..255) never gets written
--noscroll page 1 unscrolled -- its storage sits under the wrong columns
"""
import os, struct, subprocess, sys
sys.path.insert(0, "tools/encoder")
import numpy as np
from dlxp import DLXP
args = [x for x in sys.argv[1:] if not x.startswith("--")]
path = args[0] if args else "tmp/packed_singe.dlxp"
frame = int(args[1]) if len(args) > 1 else 0
controls = "--controls" in sys.argv
d = DLXP(path)
_, pic = d._split(frame)
blob = b"DLXQ" + struct.pack(">HH", d.W, d.H) + b"\0" * 768 + pic
open("tmp/dlxp_gvpack.bin", "wb").write(blob)
BIN = "tools/bench/gvpack/gvpack"
if not os.path.exists(BIN):
sys.exit(f"{BIN} not built -- make -C tools/bench/gvpack PX68K=...")
def run(extra=None):
cmd = [BIN, "tmp/dlxp_gvpack.bin", "tmp/dlxp_gvpack.raw", "--packed", "0x02"]
if extra:
cmd.append(extra)
subprocess.run(cmd, check=True, stderr=subprocess.DEVNULL)
g = np.frombuffer(open("tmp/dlxp_gvpack.raw", "rb").read(), np.uint8)
return g.reshape(256, 256)
want = d.indices(frame)
yoff = (256 - d.H) // 2
g = run()
act = g[yoff:yoff + d.H]
fail = []
if not np.array_equal(act, want):
bad = act != want
fail.append(f"{bad.sum()} px differ (left half {bad[:, :128].sum()}, "
f"right half {bad[:, 128:].sum()})")
bars = np.concatenate([g[:yoff], g[yoff + d.H:]])
if bars.size and (bars != 255).any():
fail.append(f"letterbox is not index 255: {(bars != 255).sum()} px")
if (act == 0).any():
fail.append(f"index 0 reached the screen: {(act == 0).sum()} px")
if controls and not fail:
for flag, why in (("--nobuffer", "R20 bit 11 clear"),
("--noscroll", "page 1 unscrolled")):
c = run(flag)[yoff:yoff + d.H]
n = int((c != want).sum())
print(f" control {flag:<11s} ({why}): {n:,} px differ"
+ ("" if n else " <-- IT DID NOT FAIL"))
if not n:
fail.append(f"control {flag} passed -- the test cannot fail on it")
for x in fail:
print("FAIL " + x)
if fail:
sys.exit(1)
print(f"OK {os.path.basename(path)} frame {frame}: px68k's own gvram.c renders "
f"the container's {d.pic_bytes:,} bytes index-exact over {d.W}x{d.H},")
print(f" letterbox on the reserved black, and the transparency key never "
f"reaches the screen. The harness computed no interleave.")
+218
View File
@@ -0,0 +1,218 @@
#!/usr/bin/env python3
"""DLXP1 -- the PACKED container, and the one place its layout rule is written.
from dlxp import DLXP, pack_picture, unpack_picture, write
THIS CONTAINER HAS NO DECODER. That is the point of it (FINDINGS 61): a record
is the bytes a DMA channel puts straight into the palette registers and GVRAM,
so the "reference decoder" here is not a decoder at all -- it is an assertion
about where each byte lands. `dlx.py` exists because a 68000 has to PARSE the
codec's container and can get it wrong; this file exists because a DMA channel
must NOT have to parse anything, and the format is what makes that true.
The layout, all of it fixed, all of it verified as a picture in FINDINGS 47.2:
* 256-colour GVRAM normally throws away the high byte of every word a CPU
writes, so a picture byte costs two disc bytes. CRTC R20 bit 11 turns the
masking off (46.5/47.1), and with the two 256-colour pages scrolled apart by
384 one word carries TWO pixels: word `i` of a row is
(pix[y][i+128] << 8) | pix[y][i]
-- page 1 (X-scrolled 384, transparent top) shows columns 128..255, page 0
(unscrolled, opaque bottom) shows columns 0..127.
* so a row is 128 words = 256 BYTES for 256 pixels, and big-endian storage
makes the byte order `pix[128], pix[0], pix[129], pix[1], ...`. That byte
order is not a serialisation choice: it is what the 68000's bus puts on the
high half of the word, and the channel copies bytes.
* 192 rows -> 49,152 B of picture, 1.0 B/pixel against the unpacked 2.0.
* index 0 is the TRANSPARENCY KEY and never appears; black is 255, which is
what the letterbox rows display (`vq.frame_palette`).
* words 128..511 of each row, and the letterbox rows themselves, are STATIC
SETUP -- written once at scene setup, never per frame -- so they are not in
the container. FINDINGS 47.2 lists them; keeping them out is what makes the
per-frame payload exactly the picture.
* the GVRAM line stride is 1,024 B and a row is 256 B, so the container's rows
are CONTIGUOUS and the 1,024 B step is the channel's, walked by array
chaining from one start (FINDINGS 62). A container that carried the stride
would be 4x the size and would say nothing extra.
header, 32 bytes, big-endian, then zero pad to the first sector:
0 'DLXP'
4 u16 version (1)
6 u16 flags bit 0: a per-frame palette is present
bit 1: the palette is at the END of the record
8 u16 width, u16 height
12 u16 fps, u16 nframes
16 u32 record bytes fixed, and a whole number of 512 B sectors
20 u32 palette bytes 512 (256 GRB555+I words), or 0
24 u32 picture bytes 49,152
28 u32 frames offset 512 B, i.e. sector 1
then nframes FIXED-SIZE records, each 49,664 B = 97 sectors EXACTLY.
THERE IS NO RECORD INDEX AND NO LENGTH WORD, and that is the difference DLX4's
index was invented for (49.3): a codec record's length is content-dependent, so
a producer cannot know where record i+1 starts without being told. A packed
record's length is GEOMETRY -- 192 rows of 256 B plus a palette -- so record `i`
is at `off_frm + i * rec_bytes` and a seek is arithmetic. Nothing in this
format has to be walked, which is also why the packed player has no ring
(ROADMAP K3): there is no variable-length thing to keep contiguous.
THE PALETTE ORDER IS A DECISION, NOT AN ACCIDENT (FINDINGS 62.5). Palette first
or 193rd is visible on screen for one paint -- old rows under the new palette, or
new rows under the old one -- and it is moot if buffer mode blanks the layer
(47.4/B2). It is a CONTAINER property here, chosen at encode time by
`--palette-last` and recorded in flags bit 1, so K3 can measure both without a
re-encode being an argument about which one the format assumed.
"""
import struct
import numpy as np
MAGIC = b"DLXP"
VERSION = 1
SECTOR = 512 # same rule and the same reason as dlx.SECTOR
PAL_BYTES = 512 # 256 entries, one GRB555+I word each
HDR_BYTES = 32
FLAG_PALETTE = 1 << 0
FLAG_PALETTE_LAST = 1 << 1
def pack_picture(idx):
"""(H,W) palette indices -> the bytes GVRAM wants, in GVRAM order.
The ONE place the interleave rule is applied, for the same reason
`dlx.record_lengths` is the one place the alignment rule is: every caller
that carries its own copy of a layout rule is a place the layout can drift.
"""
H, W = idx.shape
if W % 2:
raise ValueError(f"packed layout needs an even width, got {W}")
half = W // 2
left, right = idx[:, :half], idx[:, half:]
out = np.empty((H, half, 2), np.uint8)
out[:, :, 0] = right # high byte of the word -> page 1 -> col i+128
out[:, :, 1] = left # low byte -> page 0 -> col i
return out.reshape(H, half * 2)
def unpack_picture(buf, W, H):
"""The inverse, and the assertion that `pack_picture` is reversible."""
b = np.frombuffer(buf, np.uint8, W * H).reshape(H, W // 2, 2)
idx = np.empty((H, W), np.uint8)
idx[:, W // 2:] = b[:, :, 0]
idx[:, :W // 2] = b[:, :, 1]
return idx
def record_bytes(W, H, palette=True):
n = W * H + (PAL_BYTES if palette else 0)
if n % SECTOR:
raise ValueError(f"a {W}x{H} packed record is {n} B, which is not a "
f"whole number of {SECTOR} B sectors")
return n
def write(path, W, H, fps, frames, palette_last=False):
"""`frames` is a sequence of (palette_words_bytes | None, picture_bytes)."""
frames = list(frames)
pal_b = PAL_BYTES if frames and frames[0][0] is not None else 0
pic_b = W * H
rec_b = record_bytes(W, H, palette=bool(pal_b))
flags = ((FLAG_PALETTE if pal_b else 0)
| (FLAG_PALETTE_LAST if palette_last and pal_b else 0))
hdr = (MAGIC + struct.pack(">HHHHHH", VERSION, flags, W, H, fps, len(frames))
+ struct.pack(">III", rec_b, pal_b, pic_b)
+ struct.pack(">I", SECTOR))
assert len(hdr) == HDR_BYTES, len(hdr)
with open(path, "wb") as fh:
fh.write(hdr + b"\0" * (SECTOR - HDR_BYTES))
for i, (pw, pic) in enumerate(frames):
if len(pic) != pic_b or (pal_b and len(pw) != pal_b):
raise ValueError(f"frame {i}: record parts are the wrong size")
rec = pic if not pal_b else (pic + pw if palette_last else pw + pic)
fh.write(rec)
return rec_b
class DLXP:
"""Reader, and every invariant the format claims, CHECKED rather than read.
The checks are not defensive coding. A packed record is written into GVRAM
and the palette registers with no bounds test anywhere -- the channel has no
opinion about what it is copying -- so a container whose geometry is a byte
wrong does not fail, it paints.
"""
def __init__(self, path):
b = self.raw = open(path, "rb").read()
if b[:4] != MAGIC:
raise ValueError(f"{path}: not a DLXP container")
(self.version, self.flags, self.W, self.H, self.fps,
self.nframes) = struct.unpack(">HHHHHH", b[4:16])
(self.rec_bytes, self.pal_bytes,
self.pic_bytes, self.off_frm) = struct.unpack(">IIII", b[16:32])
if self.version != VERSION:
raise ValueError(f"{path}: DLXP version {self.version}")
self.has_palette = bool(self.flags & FLAG_PALETTE)
self.palette_last = bool(self.flags & FLAG_PALETTE_LAST)
if self.pic_bytes != self.W * self.H:
raise ValueError(f"{path}: picture is {self.pic_bytes} B for "
f"{self.W}x{self.H} -- the packed layout is 1.0 B/px")
if self.pal_bytes != (PAL_BYTES if self.has_palette else 0):
raise ValueError(f"{path}: palette section is {self.pal_bytes} B")
if self.rec_bytes != self.pal_bytes + self.pic_bytes:
raise ValueError(f"{path}: record is {self.rec_bytes} B, parts are "
f"{self.pal_bytes} + {self.pic_bytes}")
# The whole reason for DLX5 (58.3/59.4), and here it is free rather than
# a re-encode: the record is a fixed multiple of a sector by geometry.
if self.off_frm % SECTOR or self.rec_bytes % SECTOR:
raise ValueError(f"{path}: not sector-aligned -- stream at "
f"{self.off_frm}, record {self.rec_bytes}")
want = self.off_frm + self.nframes * self.rec_bytes
if len(b) != want:
raise ValueError(f"{path}: {len(b)} bytes, geometry says {want}")
def record(self, i):
o = self.off_frm + i * self.rec_bytes
return self.raw[o:o + self.rec_bytes]
def _split(self, i):
r = self.record(i)
if not self.has_palette:
return None, r
if self.palette_last:
return r[self.pic_bytes:], r[:self.pic_bytes]
return r[:self.pal_bytes], r[self.pal_bytes:]
def palette_words(self, i):
pw, _ = self._split(i)
if pw is None:
raise ValueError("this container carries no palette -- it was made "
"with --no-palette, and the palette a player would "
"display is not in the file to be read back")
return np.frombuffer(pw, ">u2").astype(np.uint16)
def indices(self, i):
_, pic = self._split(i)
return unpack_picture(pic, self.W, self.H)
def palette_rgb(self, i):
"""(256,3) uint8 -- what the DISPLAY produces, not what the encoder meant.
The palette in a record is already a GRB555+I word, so this is where the
5-bit hardware quantisation gets charged. Everything upstream of the
container is in RGB888 and 61.9's +4.89 dB was quoted there; a player's
number has to come from here. Same maths as `dlxload.pack_palette` and
`tools/bench/verify_frame256.py` -- the shared LSB `I` is a bit in the
word, so unpacking it needs no choice made.
"""
w = self.palette_words(i).astype(int)
f = np.stack([(w >> 6) & 31, (w >> 11) & 31, (w >> 1) & 31], 1) # R,G,B
p6 = lambda v: ((v << 2) | (v >> 4)) & 0xFF
return p6((f << 1) | (w & 1)[:, None]).astype(np.uint8)
def render(self, i):
"""(H,W,3) uint8 -- the frame as the display produces it."""
return self.palette_rgb(i)[self.indices(i)]
def kbps(self):
return self.nframes * self.rec_bytes / (self.nframes / self.fps) / 1024
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""Encode one scene to the PACKED container -- ROADMAP K2.
python3 tools/encoder/pack.py <frames_dir> <out.dlxp> [--fps 12]
[--nframes N] [--palette-last] [--no-palette]
[--scene-palette]
WHAT IS NOT HERE IS THE POINT. No VQ, no codebooks, no mode map, no rate
control, no `lam`, no leaky bucket, no span geometry -- `encode.py` is 452 lines
and 95% of its wall clock is k-means. A packed frame is a palette and a
picture, and both are geometry. FINDINGS 61: at the 9 clk/B dual-address floor
the codec is 110.4% of a 12 fps frame and this is 55.2%, so the thing that
replaces the codec is also the thing that is simpler than it.
THERE IS NO RATE CONTROL BECAUSE THERE IS NO RATE LEVER. A codec's bitrate is
adjustable; a literal frame's is geometry. The wire cost of this container is
fixed by W, H and fps and nothing an encoder does can move it, which is exactly
why FINDINGS 61.6 says the medium question decides which player exists. A
`--kbps` argument here would be a lie of the shape FINDINGS 50 removed from the
rest of the tree.
THE QUANTISER IS THIS PROJECT'S, NOT PIL'S DEFAULT PATH. 61.9's +4.89 dB was
measured with `18_text_plane_16col.py`'s free 256-colour MEDIANCUT and was filed
as "a direction, not the player's number" (risk 2 in the session 30 handoff).
`vq.frame_palette` is what actually ships it: 254 colours, index 0 held free for
the transparency key, black at 255. `tools/analysis/30_packed_container.py`
re-derives the figure against this and charges the GRB555 word on top.
"""
import argparse, glob, os, sys
import numpy as np
from PIL import Image
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "bench"))
import vq as VQ
import dlxp as P
from dlxload import pack_palette
ap = argparse.ArgumentParser()
ap.add_argument("frames_dir")
ap.add_argument("out")
ap.add_argument("--fps", type=int, default=12)
ap.add_argument("--nframes", type=int, default=None,
help="encode only the first N frames (the gate window is 120)")
ap.add_argument("--palette-last", action="store_true",
help="put the palette after the picture in every record. "
"FINDINGS 62.5 -- a design choice, not a default to inherit")
ap.add_argument("--no-palette", action="store_true",
help="picture only, 49,152 B a record. NOT a shipping option: "
"it re-imposes the scene palette the codec is capped by")
ap.add_argument("--scene-palette", action="store_true",
help="one palette for the whole scene, repeated in every record "
"-- the CONTROL for 61.9's per-frame claim")
a = ap.parse_args()
files = sorted(glob.glob(f"{a.frames_dir}/f*.png"))
if a.nframes:
files = files[:a.nframes]
if not files:
sys.exit(f"no f*.png in {a.frames_dir}")
rgb = [np.asarray(Image.open(f).convert("RGB")) for f in files]
H, W = rgb[0].shape[:2]
if any(r.shape[:2] != (H, W) for r in rgb):
sys.exit("frames are not all the same size")
# The scene-palette control shares ONE palette across every record, which is the
# constraint the codec cannot escape (61.9) and this format merely chooses not
# to inherit. It is built by the same routine the codec uses, with black at 0
# moved to 255 and index 0 vacated, so the only variable between the two runs is
# per-frame versus scene-wide.
scene = None
if a.scene_palette:
# 255, not 256: reserve_black spends one entry on black and the packed
# layout needs the OTHER end free too, so the picture gets 254 either way.
ref, spal = VQ.scene_palette(rgb, colors=255, reserve_black=True)
sidx = VQ.palettise(rgb, ref)
# 0 -> 255: the packed layout needs index 0 free and black displayed at 255.
spal = np.vstack([np.zeros((1, 3), np.uint8), spal[1:],
np.zeros((1, 3), np.uint8)])
scene = (spal, [np.where(i == 0, np.uint8(255), i) for i in sidx])
recs, psnr_pal = [], []
for n, src in enumerate(rgb):
if scene is not None:
pal, idx = scene[0], scene[1][n]
else:
pal, idx = VQ.frame_palette(src)
if (idx == 0).any():
sys.exit(f"frame {n}: index 0 is the transparency key and got used")
palw = None
if not a.no_palette:
palb, _dark, rendered = pack_palette(pal)
palw = palb.tobytes()
psnr_pal.append(VQ.psnr(src, pal[idx]))
recs.append((palw, P.pack_picture(idx).tobytes()))
rec_b = P.write(a.out, W, H, a.fps, recs, palette_last=a.palette_last)
d = P.DLXP(a.out) # re-read: every invariant is checked
if d.nframes != len(recs):
sys.exit("writer and reader disagree about the frame count")
kind = ("scene palette" if a.scene_palette else "per-frame palette")
if a.no_palette:
kind += ", NONE in the record"
print(f"{a.out}: DLXP1 {W}x{H} {a.fps}fps {d.nframes} frames, {kind}"
f"{', palette LAST' if a.palette_last else ''}")
print(f" record {rec_b:,} B = {rec_b // P.SECTOR} sectors exactly, "
f"file {os.path.getsize(a.out):,} B")
print(f" wire {d.kbps():.1f} KB/s -- FIXED by geometry, there is no lever")
print(f" palette-domain PSNR vs the 24-bit source: "
f"{np.mean(psnr_pal):.2f} dB (min {np.min(psnr_pal):.2f})")
+35
View File
@@ -65,6 +65,41 @@ def scene_palette(rgb, colors=256, stride=3, reserve_black=True):
return ref, pal return ref, pal
def frame_palette(rgb1, colors=254):
"""`scene_palette`'s sibling, for the PACKED layout: ONE FRAME, 254 colours.
The codec cannot have this. Every codeword it emits is an index INTO
`scene_palette`, so its palette is shared scene-wide and 31.33 dB is a
ceiling no bitrate crosses (FINDINGS 61.9). A literal frame has no
codebooks, so nothing forces a shared palette on it.
The layout spends TWO entries where `--reserve-black` spends one (47.2):
index 0 is the TRANSPARENCY KEY of the top graphics page and must never
appear in the picture, and black therefore lives at 255 for the letterbox.
So the picture gets 254.
The +1 shift is the whole mechanism, and it is why this does NOT go through
a P-mode reference image the way `scene_palette` does. `palettise` maps
against the FINAL 256-entry table, and that table has (0,0,0) at both 0 and
255 -- a nearest-colour mapper is free to pick either, and there is no way to
forbid the one that must stay unused. Quantising to 254 and shifting keeps
index 0 free BY CONSTRUCTION rather than by hoping the mapper agrees, and it
is still exact: `pal[idx]` reproduces the quantiser's own rendering.
Returns (pal (256,3) uint8, idx (H,W) uint8 in 1..254).
"""
q = Image.fromarray(rgb1).quantize(colors=colors, method=Image.MEDIANCUT,
dither=Image.NONE)
raw = q.getpalette()
if len(raw) < colors * 3:
raise ValueError(f"quantiser returned {len(raw)//3} entries, wanted {colors}")
pal = np.array(raw[:colors * 3], dtype=np.uint8).reshape(-1, 3)
pal = np.vstack([np.zeros((1, 3), np.uint8), pal, np.zeros((1, 3), np.uint8)])
idx = np.asarray(q, dtype=np.uint8) + np.uint8(1)
if idx.min() < 1 or idx.max() > colors:
raise ValueError("index 0 (transparency key) or 255 (black) got used")
return pal, idx
def palettise(rgb, ref): def palettise(rgb, ref):
return [np.asarray(Image.fromarray(r).quantize(palette=ref, dither=Image.NONE), return [np.asarray(Image.fromarray(r).quantize(palette=ref, dither=Image.NONE),
dtype=np.uint8) for r in rgb] dtype=np.uint8) for r in rgb]