open-standards/wai/extensions/exact-tonemap.md
Transaction Science 2b54ac2054 tonemap: digests of L2, E2 and tan over whole ranges in the corpus
A second falsification: L2's squarings rounded instead of truncated still
regenerated the corpus (the variant differs on about 3 in 100000 mantissas).
vectors.json now states the SHA-256 of L2 at 2^18 spread values, of E2's
mantissa for every fraction and of tan for every angle; both implementations
check them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 03:43:06 -04:00

656 lines
39 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# WAI Extension: Exact Tone Mapping
> Status: Draft. Registers two companions and a record. `wai.meta.gainmap` carries a gain map of
> ISO 21496-1:2025 (its metadata and its gain map image) as a companion of an image payload, and
> `wai.meta.st2094_50` carries the dynamic metadata of SMPTE ST 2094-50:2026-08 (Application #5)
> as a companion of a video payload. A sink presents the picture it decoded at its display's
> **headroom**, the ratio of its peak to the luminance of HDR reference white, as a `log2` value.
> Both standards state the computation from the base, the companion and the headroom in real
> numbers, so two sinks at one headroom compute it in their own floating point and present
> different pixels. This extension pins it in integer arithmetic: `wai-tonemap:gainmap` and
> `wai-tonemap:st2094-50` map the base's `RGB8` samples, the companion's bytes and a headroom,
> an integer in units of 1/10000 of a stop, to one `RGB16` rendition, with a stated
> fixed-point `log2`, a stated fixed-point `exp2`, and a stated rounding at every step. Every
> sink at a headroom presents the same bytes, and a verifier holding the base and the companion
> recomputes them. A sink that presents a rendition signs a **rendition record** naming the
> base, the companion, each headroom and the digest of each rendition. The derivation is a
> `convert` step of [synthesis](synthesis.md): it rebuilds what the base and the companion hold
> and generates nothing. No new transport. The reference implementation is the `exact_tonemap`
> module of `wai-rs` (feature `exact_tonemap`); the corpus is
> [`live-conformance/tonemap/`](../live-conformance/tonemap/), and its second implementation is
> `live-conformance/tonemap_verify.py`.
> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174.
>
> Texts read for this revision: SMPTE ST 2094-50:2026-08, *Dynamic Metadata for Color Volume
> Transform — Application #5*, approved 2026-08-24, in its published text, every clause and
> annex (its figures were not read); the WAI texts this one cites; RFC 8032 and RFC 8785.
> **ISO 21496-1:2025** (first edition, *Digital photography — Gain map metadata for image
> conversion — Part 1: Dynamic range conversion*) **was not read**: its text is sold, and no
> part of it was obtained for this revision. What this extension states of it is what
> ST 2094-50:2026-08 says of it (its terms 3.1 to 3.11 cite ISO 21496-1:2025 clauses 3.1, 3.2,
> 3.4, 3.6, 5.2.6, 5.2.7 and 6.3) and what public, open-source readers of its binary metadata
> implement: the field list of §4.2 and the computation of §4.5. Each is marked where it is
> used. A revision that reads the standard confirms each against its text or corrects it.
## 1. Scope
In scope: the payloads of the two companions (§4.1, §5.1) and how their metadata reads (§4.2,
§5.2); the integer arithmetic the derivations share (§3); the two derivations (§4.4, §5.3 to
§5.6), each step mapped onto the standard's own computation (§4.5, §5.7); their error against
a floating-point evaluation of the same formulas (§6); the rendition record and its place in
synthesis's class lattice (§7); verification (§8).
Out of scope: a transport; how a sink measures its own headroom; a base in another transfer than
sRGB, a gain application colour space whose primaries differ from the base's, and the HDR
reference white's luminance in candelas, each of which this revision does not compute (§4.2,
§5.2); how a gain map is made from an HDR original, which is not part of the derivation (the
corpus's encoder is the reference's own, §10); the presentation of a rendition on a panel, which
takes the rendition's code values as linear drive values relative to the display's peak (§3.5)
and does with them what the panel does.
Relationship to the core spec: `wai.meta.gainmap` and `wai.meta.st2094_50` are
**companion-only** capabilities ([SPEC](../SPEC.md) §5, §7.1): each is applied with the rendition
it binds to and never decoded on its own. `wai.meta.gainmap` applies to a payload of the `image`
media class, `wai.meta.st2094_50` to one of the `video` media class, as SPEC §7.1's rule 2
states. `wai.media.tonemap` is a **record** ([SPEC](../SPEC.md) §5): a signed record a WAI tool
emits and verifies, which names no payload format and is not dispatchable. The two derivations
are terms of this extension, not `wai.derive.*` strings: the registry's derivation kind is the
operations of ISO/IEC 23001-16 ([derived-tracks](derived-tracks.md)), and a tone map is none of
them.
Relationship to [synthesis](synthesis.md): a rendition is a `convert` of its inputs (synthesis
§3.2): it rebuilds, at the display's headroom, what the base and the companion together hold,
names no model and generates nothing; its class is the greatest of `reconstructed` and its
inputs' classes, so a rendition of a synthesized picture is synthesized (§7.2).
Relationship to [determinism-tiers](determinism-tiers.md): display adaptation changes the
decoded medium, so a tier covers a rendition only if its computation is pinned (determinism-tiers
§2, *What a tier covers*). The derivations are integer throughout, so a rendition is a pure
function of the base's samples, the companion's bytes and the headroom, and a sink that presents
it holds the tier of the decode it ran on.
## 2. Records and buffers
The record of this extension (§7) is a JSON object under the encodings of
[synthesis](synthesis.md) §2: byte strings in lowercase hexadecimal, unsigned integers of at
most 2^53 − 1 in plain decimal, no other number, no boolean, terms and date-times as
[jwp-receipts](jwp-receipts.md) §2.4.3 defines them, no member named twice, at most 32 levels of
nesting, exactly the members §7.1 lists at every level, and a `kind` naming the record; one that
does not is `record_malformed`. It carries `signer_pubkey` (32 bytes) and `sig` (64 bytes):
```
sig = Ed25519(signer_key, "wai:tonemap\x01" || JCS(record without "sig"))
link_hash = BLAKE3("wai:tonemap-link\x01" || JCS(record))
```
JCS is RFC 8785. The signature is verified strictly ([SPEC](../SPEC.md) §7 *Signatures*): a
key that fails step 1 or 2 is `bad_key`, and a signature that fails a later step
`bad_signature`.
A **base** picture is an `RGB8` buffer (rows top to bottom, pixels left to right, three bytes a
pixel, R, G, B, no padding), read as sRGB-coded (IEC 61966-2-1, as ST 2094-50:2026-08 Formula
(A.2) restates it). A **rendition** is an `RGB16` buffer: the same order, three unsigned 16-bit
samples a pixel, each written little-endian. A buffer's **digest** is SHA-256 (FIPS 180-4) over
it. An envelope's **content hash** is [jwp-receipts](jwp-receipts.md) §2.1's.
## 3. Integer arithmetic
### 3.1 Notation
Every value is an integer. ⌊*a* / *b*⌋, for *b* > 0, is the floor of the quotient, also for a
negative *a*. rnd(*a*, *b*) = ⌊(*a* + ⌊*b* / 2⌋) / *b*⌋ for *b* > 0: the integer nearest *a* / *b*,
halves upward, for either sign of *a*. clamp(*v*, *lo*, *hi*) = min(max(*v*, *lo*), *hi*).
A value in **Q*n*** stands for itself divided by 2^*n*: a `log2` value in Q16 is in units of
1/65536 of a stop, a linear light value in Q30 is in units of 2^−30 of HDR reference white. No
intermediate value of this section or of §4 and §5 needs more than 127 bits and a sign; an
implementation computes each exactly.
### 3.2 `L2`: a fixed-point `log2`
For an integer *v* ≥ 1, L2(*v*) is a Q16 approximation of log2(*v*) from below:
1. *n* is the bit length of *v* less 1, so 2^*n* ≤ *v* < 2^(*n*+1).
2. *m* = *v* · 2^(30 − *n*) where *n* ≤ 30, and ⌊*v* / 2^(*n* − 30)⌋ otherwise: the mantissa in
Q30, 2^30 ≤ *m* < 2^31.
3. *f* = 0; for *i* = 1 to 16 in turn: *m* = ⌊*m*² / 2^30⌋; where *m* ≥ 2^31, *m* = ⌊*m* / 2⌋
and *f* = *f* + 2^(16 − *i*).
4. L2(*v*) = *n* · 2^16 + *f*.
Step 3 is the bitwise logarithm: each squaring doubles the fractional part, and a mantissa past
2 gives the next bit. Every squaring truncates.
### 3.3 `E2`: a fixed-point `exp2`, and scaling by it
For a Q16 integer *y*, E2(*y*) is a pair (*p*, *e*) with 2^(*y* / 2^16) ≈ *p* · 2^(*e* − 30):
1. *e* = ⌊*y* / 2^16⌋ and *f* = *y* − 2^16 · *e*, 0 ≤ *f* < 2^16.
2. *p* = 2^30; for *i* = 1 to 16 in turn, where bit 16 − *i* of *f* is 1, *p* = rnd(*p* ·
*K*ᵢ, 2^30).
*K*ᵢ is 2^(2^−*i*) in Q30, made by integer square roots: *R*₀ = 2^61, *R*ᵢ = ⌊√(*R*ᵢ₋₁ ·
2^60)⌋ (each a Q60 value, the integer square root rounded down), and *K*ᵢ = rnd(*R*ᵢ, 2^30):
| *i* | *K*ᵢ | *i* | *K*ᵢ | *i* | *K*ᵢ | *i* | *K*ᵢ |
|---|---|---|---|---|---|---|---|
| 1 | 1518500250 | 5 | 1097253708 | 9 | 1075196443 | 13 | 1073832680 |
| 2 | 1276901417 | 6 | 1085434106 | 10 | 1074468888 | 14 | 1073787251 |
| 3 | 1170923762 | 7 | 1079572136 | 11 | 1074105294 | 15 | 1073764537 |
| 4 | 1121280436 | 8 | 1076653033 | 12 | 1073923544 | 16 | 1073753181 |
`scale`(*X*, *y*, *F*), for an integer *X* and a Q16 *y*, is *X* · 2^(*y* / 2^16) / 2^*F* as an
integer: with (*p*, *e*) = E2(*y*) and *t* = *e* − 30 − *F*, it is *X* · *p* · 2^*t* where *t* ≥ 0,
and rnd(*X* · *p*, 2^−*t*) otherwise.
Since both functions are functions of one integer, an implementation MAY tabulate them, or
compute them any other way that gives the same integers: a table of E2 over the 2^16 values of
*f* is the same function.
### 3.4 `S`: the base's samples in linear light
S[*k*], for each sample value *k* from 0 to 255, is the sRGB decode of *k* / 255 in Q30, with
HDR reference white at 2^30 (an sRGB-coded sample of 255 is reference white, ST 2094-50:2026-08
Note 4 to Formula (A.1)):
- for *k* ≤ 10: S[*k*] = rnd(*k* · 100 · 2^30, 255 · 1292), the linear segment *k* / 255 / 12.92
(*k* / 255 ≤ 0.04045 exactly when *k* ≤ 10);
- for *k* ≥ 11: with λ = L2(1000 · *k* + 14025) − L2(269025), the Q16 `log2` of
(*k* / 255 + 0.055) / 1.055, S[*k*] = `scale`(2^30, rnd(12 · λ, 5), 0): that value to the
power 2.4.
S[255] = 2^30 exactly.
### 3.5 The rendition's code values
A sink presents a rendition to a display of headroom *h*, an integer from 0 to 60000 in units of
1/10000 of a stop (the unit ST 2094-50:2026-08 Annex C encodes headrooms in): the display's
peak is 2^(*h* / 10000) times HDR reference white. *H*ₜ = rnd(*h* · 2^16, 10000) is *h* in Q16.
A rendition's code value is linear light relative to the display's peak, at 16 bits: a
linear value *T* (Q30, relative to reference white) is presented as
code(*T*) = clamp(`scale`(*T* · 65535, −*H*ₜ, 30), 0, 65535),
so the display's peak is 65535 and a value past it is clamped, the projection of
ST 2094-50:2026-08 Clause A.4, Note. Each derivation forms its value and its exponent together
and rounds once (§4.4 step 7, §5.6).
## 4. Gain maps: `wai.meta.gainmap` and `wai-tonemap:gainmap`
### 4.1 The payload: `WGM1`
A `wai.meta.gainmap` payload is a `WGM1` frame. WAI's own fields are little-endian.
| off | size | field | rule |
|---|---|---|---|
| 0 | 4 | magic `WGM1` | |
| 4 | 1 | version | `1` |
| 5 | 1 | reserved | `0` |
| 6 | 2 | `md_len` | 1 to 4096: the metadata's length |
| 8 | `md_len` | metadata | ISO 21496-1:2025 metadata in its binary form (§4.2), as written |
| 8 + `md_len` | the rest | gain map image | one PNG file (W3C PNG, Third Edition) of 8-bit samples, colour type 0 (one channel) or 2 (three), no interlace |
A frame that breaks a rule of this table, or whose image is not such a PNG, or does not decode,
is `companion_malformed`. Nothing follows the PNG's `IEND` chunk.
### 4.2 The metadata
The metadata is ISO 21496-1:2025's binary form. **This revision did not read ISO 21496-1**; the
fields below are the ones public, open-source readers of the form read, in their order, with
the names they give. Its multi-byte fields are big-endian.
| size | field | type |
|---|---|---|
| 2 | `minimum_version` | unsigned |
| 2 | `writer_version` | unsigned |
| 1 | `is_multichannel` (bit 7), `use_base_colour_space` (bit 6), six reserved bits | |
| 4 + 4 | `base_hdr_headroom` numerator, denominator | unsigned, unsigned |
| 4 + 4 | `alternate_hdr_headroom` numerator, denominator | unsigned, unsigned |
| per channel, 1 or 3 times (3 where `is_multichannel` is 1) | | |
| 4 + 4 | `gain_map_min` numerator, denominator | signed, unsigned |
| 4 + 4 | `gain_map_max` numerator, denominator | signed, unsigned |
| 4 + 4 | `gamma` numerator, denominator | unsigned, unsigned |
| 4 + 4 | `base_offset` numerator, denominator | signed, unsigned |
| 4 + 4 | `alternate_offset` numerator, denominator | signed, unsigned |
Each pair is the rational numerator / denominator. Headrooms, `gain_map_min` and `gain_map_max`
are `log2` values. A reader, in this order:
1. refuses metadata whose `minimum_version` is not 0 with `metadata_unsupported`: a reader does
not apply metadata whose minimum version it does not know;
2. refuses metadata shorter than its fields, or whose `writer_version` is below its
`minimum_version`, or, with a `writer_version` of 0, longer than its fields, with
`metadata_malformed`; bytes after the fields of metadata of a later `writer_version` are read
past, and the reserved bits are not read;
3. refuses with `metadata_out_of_range` metadata outside the range this revision computes: a
denominator of 0; a headroom past 6 (the range of ST 2094-50:2026-08 §6.2.2); two equal
headrooms, between which no weight is defined; a `gain_map_min` or `gain_map_max` past 16 in
magnitude, or a `gain_map_min` greater than its `gain_map_max`; a `gamma` of 0 or outside
1/64 to 64; a `base_offset` or `alternate_offset` past 1 in magnitude;
4. refuses metadata whose `use_base_colour_space` is 0 with `gain_space_unsupported`: its gain is
applied in the alternate image's colour space, which a conversion of primaries reaches, and
this revision computes none.
Then, for each channel *j*: min*ⱼ* and max*ⱼ* in Q16 are rnd(*n* · 2^16, *d*) of `gain_map_min`
and `gain_map_max`; *bo*ⱼ and *ao*ⱼ in Q30 are rnd(*n* · 2^30, *d*) of `base_offset` and
`alternate_offset`; γⱼ is the rational `gamma` itself. A single-channel map applies its one set
to all three channels.
### 4.3 The gain map image
The PNG's colour type is 0 for metadata whose `is_multichannel` is 0, and 2 where it is 1
(`map_channels_mismatch`). Its size is *g*w × *g*h, and the base's *w* × *h*: *s* = *w* / *g*w is
an integer of 1, 2, 4 or 8, and *h* = *s* · *g*h (`map_dimensions_mismatch`). *g*(*x*, *y*) is
its sample at column *x* and row *y*, of channel *j*.
### 4.4 `wai-tonemap:gainmap`
`wai-tonemap:gainmap` maps a base of *w* × *h*, a `wai.meta.gainmap` payload that reads
(§4.1 to §4.3) and a headroom *h*ᵈ (§3.5) to a rendition of *w* × *h*. With *H*b =
`base_hdr_headroom` = *b*n / *b*d, *H*a = `alternate_hdr_headroom` = *a*n / *a*d and *H*ₜ =
rnd(*h*ᵈ · 2^16, 10000):
1. **Weight.** *A* = *h*ᵈ · *b*d · *a*d − 10000 · *b*n · *a*d and *D* = 10000 · (*a*n · *b*d −
*b*n · *a*d), so *A* / *D* = (*h*ᵈ / 10000 − *H*b) / (*H*a − *H*b). Where *D* > 0, *W* =
clamp(rnd(*A* · 2^16, *D*), 0, 2^16); where *D* < 0, *W* = −clamp(rnd(−*A* · 2^16, −*D*), 0,
2^16). *W* is in Q16, and is negative where the alternate's headroom is below the base's.
For each pixel (*x*, *y*) and each of its channels *c*, with *j* = *c* for a three-channel map and
*j* = 0 for a single-channel one:
2. **Base.** *B* = S[base(*x*, *y*)[*c*]].
3. **The map's sample at the pixel.** *X* = max(0, 2*x* + 1 − *s*), *x*₀ = ⌊*X* / 2*s*⌋,
*ax* = *X* − 2*s* · *x*₀ and *x*₁ = min(*x*₀ + 1, *g*w − 1); *Y*, *y*₀, *ay* and *y*₁ the same
from *y* and *g*h. Then *N* = *g*(*x*₀, *y*₀) · (2*s* − *ax*) · (2*s* − *ay*) + *g*(*x*₁, *y*₀) ·
*ax* · (2*s* − *ay*) + *g*(*x*₀, *y*₁) · (2*s* − *ax*) · *ay* + *g*(*x*₁, *y*₁) · *ax* · *ay*, and
*Q* = 255 · 4*s*²: the map's normalised sample is *N* / *Q*, a centre-aligned bilinear
interpolation of its codes, the edges repeated. With *s* = 1 it is the sample itself.
4. **Recovery.** *v* (Q30) = rnd(*N* · 2^30, *Q*) where γⱼ = 1; 0 where *N* = 0; otherwise, with
γⱼ = *γ*n / *γ*d, λ = L2(*N*) − L2(*Q*) and *v* = `scale`(2^30, rnd(λ · *γ*d, *γ*n), 0): the
sample raised to 1/γⱼ.
5. **Log gain.** *L* (Q16) = rnd(minⱼ · (2^30 − *v*) + maxⱼ · *v*, 2^30).
6. **Weighted gain.** *G* (Q16) = rnd(*L* · *W*, 2^16).
7. **Output.** *P* = `scale`((*B* + *bo*ⱼ) · 65535, *G* − *H*ₜ, 0) − `scale`(*ao*ⱼ · 65535, −*H*ₜ,
0), and the rendition's sample is clamp(rnd(*P*, 2^30), 0, 65535): code((*B* + *bo*) ·
2^(*L* · *W*) − *ao*) of §3.5, both terms in Q30 of a code value and rounded once.
The rendition at a headroom at or past the alternate's is the alternate image the map was made
from, within the error of §6; at the base's headroom (*W* = 0) it is the base, offsets
cancelling where they are equal.
### 4.5 Against ISO 21496-1's computation
The computation the integer steps reproduce is the one the public readers of §4.2 implement:
with the base's linear value *B*, the map's sample *g* in [0, 1] at the pixel, and the weight
*W* = clamp((*H*ₜ − *H*b) / (*H*a − *H*b), 0, 1), negated where *H*a < *H*b,
*L* = min + (max − min) · *g*^(1/γ), *T* = (*B* + *bo*) · 2^(*L* · *W*) − *ao*.
ST 2094-50:2026-08 term 3.7 names ISO 21496-1:2025 clause 6.3 as the algorithm that produces an
image at a targeted HDR headroom from per-pixel metadata; this revision did not read that clause
(header note). Step by step:
| step | real-number form | integer form (§4.4) | what is pinned |
|---|---|---|---|
| base to linear | sRGB decode of *k* / 255 | S[*k*] (§3.4) | `L2`, `E2`, one rounding each |
| the map at the pixel | upsampled by a filter each implementation chooses | step 3: bilinear, centre-aligned, edges repeated, exact | the filter itself |
| recovery | *g*^(1/γ) | step 4 | Q30, through Q16 `log2` |
| log gain | min + (max − min) · *g*^(1/γ) | step 5 | Q16 |
| weight | clamp of a ratio of headrooms | step 1, exact rational, rounded once | Q16 |
| gain and offsets | (*B* + *bo*) · 2^(*L* · *W*) − *ao* | step 7 | Q30, through Q16 `exp2` |
| presentation | *T* / 2^*H*ₜ at the display's peak | §3.5 | 16-bit code values |
The upsampling filter is where two floating-point sinks differ most, and the standard, as far
as this revision knows it, leaves it to the implementation. The derivation fixes it.
## 5. ST 2094-50: `wai.meta.st2094_50` and `wai-tonemap:st2094-50`
### 5.1 The payload: `WD50`
A `wai.meta.st2094_50` payload is a `WD50` frame: the metadata sets of a video, each with the
frames it applies to. ST 2094-50:2026-08 carries one metadata set with the frames it applies to
by ITU-T T.35 messages in a codec or a container (its §7.3), and its Annex C encoding holds no
time; the frame gives each set its frames, the TimeInterval of its §7.1, in frames of the base.
WAI's own fields are little-endian.
| off | size | field | rule |
|---|---|---|---|
| 0 | 4 | magic `WD50` | |
| 4 | 1 | version | `1` |
| 5 | 1 | reserved | `0` |
| 6 | 2 | `n_sets` | 1 to 256 |
| 8 | … | the sets | `n_sets` sets, one after another; nothing follows the last |
A set:
| size | field | rule |
|---|---|---|
| 4 | `first_frame` | the index of its first frame, frames counted from 0 |
| 4 | `frame_count` | 1 or more |
| 2 | `len` | 1 to 4096 |
| `len` | the set | one `smpte_st_2094_50_application_info()` of ST 2094-50:2026-08 Annex C, as written |
Each set's `first_frame` is at least the end of the set before it (its `first_frame` plus its
`frame_count`), and no end passes 2^32 − 1. A frame that breaks a rule of these tables is
`companion_malformed`.
### 5.2 Reading a metadata set
A set reads as ST 2094-50:2026-08 Annex C has it: each syntax element of type u(N) an N-bit
unsigned integer, most significant bit first, and the structures of its Tables C.1 to C.5 in
order. The reader, in this order:
1. refuses a set whose `minimum_application_version` is not 0 with `metadata_unsupported`: Annex
C.2.1 has a parser ignore the whole bitstream;
2. refuses a set that ends inside a structure, or holds a `reserved_zero` element that is not 0
(Annex C.1), with `metadata_malformed`. Bytes after the last element are padding and are not
read (Annex C.2.1);
3. assigns the metadata items as Annex C.3 does: HDR reference white, 203 or clamp(·, 1,
50000) / 5 (C.3.3); headrooms min(·, 60000) / 10000 (C.3.4); `NumAlternateImages`
min(·, 4); component mixing (C.3.6); gain curves, with the sign *s* of C.3.7, *x* = min(·,
64000) / 1000 and *y* = *s* · min(·, 60000) / 10000, and each slope from its angle or by
C.3.9 (§5.4);
4. refuses with `metadata_out_of_range` a set that breaks a constraint of ST 2094-50:2026-08
§6.2.2, §6.4.2 or §6.5.2 (an alternate headroom equal to the baseline's, alternate headrooms
not increasing, mixing weights that sum to 0, control points whose *x* decreases, or two of one
*x* with different *y*), or whose gain curve's last control point has *x* = 0, beyond which
Formula (12) is not finite;
5. refuses with `gain_space_unsupported` a set whose `use_reference_white_tone_mapping_flag` is
1, whose gain application colour space C.3.8 sets to the primaries of ITU-R BT.2020, or whose
`gain_application_space_chromaticities_mode` is not 0, the primaries and white point of ITU-R
BT.709: this revision applies gains in the base's primaries, those of sRGB, and converts none.
The recommended constraints of §6.2.3 are not checked: §6.2.3 lets an implementation adjust
metadata to meet them, and the derivation applies the metadata as it reads.
For a base read as sRGB-coded (§2), the conversion of ST 2094-50:2026-08 Formula (A.3)
divides the luminance of Formula (A.2) by the HDR reference white it was formed with, so the
value in the gain application colour space is S[*k*] of §3.4 and the reference white's value in
candelas does not enter. A set with no headroom-adaptive tone map (`has_adaptive_tone_map_flag`
0) leaves the choice to the output system (its Clause A.3); the derivation then applies none,
as a set with no alternate images does (its Clause B.4): the rendition is the base, projected
(§3.5).
### 5.3 The parameters as integers
- **Headrooms** *H* in units of 1/10000 of a stop: the Annex C values, min(·, 60000).
- **Mixing weights** in units of 1/150000: type 0, *k*max = 150000; type 1, *k*component =
150000; type 2, *k*red = *k*green = *k*blue = 25000 and *k*max = 75000; type 3, each
coefficient 3 · min(·, 50000); every other weight 0. *k*sum is their sum.
- **Control points**: *X*ᵢ = min(·, 64000), *x* in units of 1/1000; *Y*ᵢ = *s* · min(·,
60000), *y* in units of 1/10000; slopes *m*ᵢ in Q30 (§5.4).
### 5.4 Slopes
**From an angle** (C.3.7, `gain_curve_use_pchip_slope_flag` 0): *m* = tan((clamp(θ, 1, 35999) −
18000) · π / 36000). With *d* = clamp(θ, 1, 35999) − 18000, *P* = ⌊π · 2^62⌋ =
14488038916154245684 and the angle in Q62 *a* = rnd(*d* · *P*, 36000), and *a*² in Q62 *q* =
rnd(*a* · *a*, 2^62):
- sine: *t* = *a*, *σ* = *a*; for *k* = 1 to 15 in turn, *t* = rnd(−*t* · *q*, 2^62 · 2*k* ·
(2*k* + 1)) and *σ* = *σ* + *t*;
- cosine: *t* = 2^62, *κ* = 2^62; for *k* = 1 to 15 in turn, *t* = rnd(−*t* · *q*, 2^62 · (2*k* −
1) · 2*k*) and *κ* = *κ* + *t*;
- *m* = rnd(*σ* · 2^30, *κ*).
The angle is within π/2 of 0, so *κ* > 0, and fifteen terms of each series leave a remainder below
2^−62.
**By PCHIP** (C.3.9, flag 1): each slope of C.3.9 is a rational function of the control points,
computed exactly, and rounded once to Q30. With *h*ᵢ = *X*ᵢ₊₁ − *X*ᵢ and Δᵢ = *Y*ᵢ₊₁ − *Y*ᵢ, the
slope of interval *i* is *s*ᵢ = Δᵢ / (10 · *h*ᵢ) stops per unit of *x*, and 0 where *h*ᵢ = 0
(Formula (C.7)). The interior rule (C.8), the endpoint rules (C.9, C.10) with their sign tests
and their cap at 3 · |*s*|, and the degenerate case are C.3.9's, each comparison made on the exact
rationals; the slope chosen, a rational, is *m* = rnd(numerator · 2^30, denominator) with a
positive denominator.
### 5.5 Component mixing and the gain curve
**Mixing** (Formulas (9), (10)): for a colour *C* = (*c*r, *c*g, *c*b) in Q30,
*M*ⱼ = rnd(*c*ⱼ · *k*component + *k*red · *c*r + *k*green · *c*g + *k*blue · *c*b + *k*max ·
max(*C*) + *k*min · min(*C*), *k*sum), in Q30.
**The gain curve** (Formulas (11), (12)) at *M* in Q30, against 1000 · *M* and *X*ᵢ · 2^30, its
value *G* in Q16:
- 1000 · *M* < *X*₀ · 2^30: *G* = rnd(*Y*₀ · 2^16, 10000);
- 1000 · *M* = *X*ᵢ · 2^30 for some *i*: *G* = rnd(*Y*ᵢ · 2^16, 10000);
- *X*ᵢ · 2^30 < 1000 · *M* < *X*ᵢ₊₁ · 2^30: the cubic of Formula (11). With *t* = rnd(1000 · *M*
− *X*ᵢ · 2^30, *h*ᵢ) (Q30), *Y*′ᵢ = rnd(*Y*ᵢ · 2^30, 10000) (Q30) and *m̂*ᵢ = rnd(*h*ᵢ · *m*ᵢ,
1000) (Q30), the coefficients *c*₃ = 2*Y*′ᵢ + *m̂*ᵢ − 2*Y*′ᵢ₊₁ + *m̂*ᵢ₊₁, *c*₂ = −3*Y*′ᵢ +
3*Y*′ᵢ₊₁ − 2*m̂*ᵢ − *m̂*ᵢ₊₁, *c*₁ = *m̂*ᵢ and *c*₀ = *Y*′ᵢ are evaluated by Horner's rule, *u* =
rnd(*c*₃ · *t*, 2^30) + *c*₂, then *u* = rnd(*u* · *t*, 2^30) + *c*₁, then *u* = rnd(*u* · *t*,
2^30) + *c*₀, and *G* = rnd(*u*, 2^14);
- 1000 · *M* > *X*ₙ₋₁ · 2^30, past the last control point: *G* = rnd(*Y*ₙ₋₁ · 2^16, 10000) +
L2(*X*ₙ₋₁ · 2^30) − L2(1000 · *M*), the term log2(*x*ₙ₋₁ / *x*) that holds the tone curve
*x* · 2^*G* level.
The colour gain function of an alternate image is the curve at each of *M*'s three components
(Formula (7)); the zero function's is 0 (Formula (8)).
### 5.6 `wai-tonemap:st2094-50`
`wai-tonemap:st2094-50` maps a frame of a base of *w* × *h*, a `wai.meta.st2094_50` payload
that reads (§5.1, §5.2), the set whose frames include the frame's index (`frame_uncovered`
where none does), and a headroom *h*ᵈ (§3.5) to a rendition of *w* × *h*. Following §6.2.5:
the baseline headroom is placed among the alternates' in sorted position, *H*₀ < … < *H*ₙ₋₁,
with the zero gain function at its place; *h*c = clamp(*h*ᵈ, *H*₀, *H*ₙ₋₁). For each pixel, *C*
= (S[*r*], S[*g*], S[*b*]) of its samples, and the gain *G* of each component (Q16) is:
- Gainᵢ(*C*) where *h*c = *H*ᵢ (Formula (2));
- otherwise, for the one *i* with *H*ᵢ < *h*c < *H*ᵢ₊₁, rnd(Gainᵢ(*C*) · (*H*ᵢ₊₁ − *h*c) +
Gainᵢ₊₁(*C*) · (*h*c − *H*ᵢ), *H*ᵢ₊₁ − *H*ᵢ) (Formulas (3), (4), exact in the weights and
rounded once).
Each component of the rendition is clamp(`scale`(*c* · 65535, *G* − *H*ₜ, 30), 0, 65535) with
*H*ₜ = rnd(*h*ᵈ · 2^16, 10000): code(*C* · 2^*G*) of §3.5 (Formula (5)), rounded once.
### 5.7 Against ST 2094-50's computation
| ST 2094-50:2026-08 | real-number form | integer form | what is pinned |
|---|---|---|---|
| Formula (A.2), (A.3) | sRGB decode, over reference white | S[*k*] (§3.4) | `L2`, `E2` |
| C.3.7 | *m* = tan of the angle | §5.4, series in Q62, *m* in Q30 | the series and its rounding |
| C.3.9 | PCHIP slopes | §5.4, exact rationals, rounded once | Q30 |
| (9), (10) | weighted mix over *k*sum | §5.5, rounded once | Q30 |
| (11) | cubic Hermite in *t* | §5.5, Horner in Q30 | each product's rounding |
| (12) | *y* + log2(*x*ₙ₋₁ / *x*) | `L2` difference | Q16 |
| (2) to (4) | weights of headrooms | §5.6, exact, rounded once | Q16 |
| (5) and A.4 | *C* · 2^*G*, projected at the display | §5.6 and §3.5 | 16-bit code values |
Every formula of §6 of ST 2094-50:2026-08 has an integer step above, and every value in it is
the one Annex C assigns from the bits.
## 6. Error against floating point
The derivations round where the standards compute in real numbers, so a rendition differs from
a floating-point evaluation of the same formulas by a little. The reference measures it over
every rendition of the corpus: the same steps in IEEE 754 binary64 (sRGB decoding by
`pow`, `log2`, `exp2` and `tan` of the platform library, the same bilinear weights, the PCHIP
slopes in binary64), each code value rounded to nearest and clamped as §3.5 has it, against the
integer rendition's:
| derivation | renditions | samples | largest difference | mean difference |
|---|---|---|---|---|
| `wai-tonemap:gainmap` | 10 | 41472 | 2 | 0.1269 |
| `wai-tonemap:st2094-50` | 25 | 172800 | 2 | 0.1642 |
in code values of 65535. The corpus states the same figures (`measurement`), and its test
fails when they change. A difference is the integer derivation's distance from that one
floating-point evaluation; another floating-point implementation differs from it as well, by
its own rounding and its own upsampling filter, and it is that spread which the derivation
removes.
## 7. The rendition record
### 7.1 The record
A sink that presents renditions signs a **rendition record**, `kind` `"wai-tonemap"`, for them,
under a key of its session or of its own (the record names no viewer). A party that publishes a
rendition signs one for what it published.
| member | type | meaning |
|---|---|---|
| `kind` | string | `"wai-tonemap"` |
| `derivation` | term | `wai-tonemap:gainmap` or `wai-tonemap:st2094-50` |
| `base` | object | `{"object", "rendition", "width", "height"}`: the content hash of the envelope that carries the base and the companion; the index of the base's entry among its `renditions` (0 for a `WAI1`); and the base's size, each 1 to 4096 |
| `companion` | object | `{"index", "sha256"}`: the index of the companion's entry, and the SHA-256 of its payload |
| `pictures` | array | 1 to 256 entries, each `{"frame", "headroom", "input", "output"}`: the frame's index (0 for a still), the headroom (§3.5, 0 to 60000), the digest of the base frame's `RGB8` samples, and the digest of the rendition's `RGB16` samples; no two entries with the same frame and headroom, and entries in increasing order of frame, then of headroom |
| `class` | string | the class of the renditions (§7.2) |
| `inputs` | array | 1 to 16 entries, each `{"content_hash", "origin", "class"}`: an object the derivation read, the link hash of its origin claim ([synthesis](synthesis.md) §3.1) or `null`, and that claim's class, restated; one of them is `base.object`, and no content hash appears twice |
| `issued_at` | date-time | when it signed it |
| `signer_pubkey`, `sig` | | §2 |
A `wai-tonemap:gainmap` record's pictures are all of frame 0. `class` and an input's `class` are
one of `captured`, `reconstructed`, `assistive` and `synthesized`. A record that breaks a rule of
this table is `record_malformed`; one whose headroom passes 60000 is `headroom_out_of_range`.
### 7.2 Provenance
A rendition is a `convert` step of [synthesis](synthesis.md) §3.2. It rebuilds what its inputs
hold: the base's samples, and, at a headroom, the alternate rendering the companion states of
them. It names no model, asserts no source of its own and makes no evidence claim: there is no
edit to check, only a computation to repeat, and §8 repeats it. Its class is the greatest of
`reconstructed` and its inputs' classes, so a rendition never stands lower than what it was made
from: a rendition of a captured photograph is `reconstructed`, and one of a synthesized picture
is `synthesized`. A record whose `class` is another is `class_mismatch`.
Where a rendition is published as an object, its origin claim is a `convert` claim whose inputs
are the inputs of its record (synthesis §3.4, the row for a decoder's reconstruction), and its
record is the claim's `record`.
## 8. Verification
### 8.1 Inputs
A verifier is given, each in a list: rendition records; the envelopes it holds, each named by its
content hash; the decoded frames it holds, each the `RGB8` samples of one frame of the base of an
envelope, named by the envelope's content hash and the frame's index (a verifier that decodes a
base itself holds its frames; the base of a `wai.image.png` entry is decoded by its PNG); and a
**policy**:
| member | values | default | meaning |
|---|---|---|---|
| `tonemap` | `accept-unrecomputed`, `require-proof` | `accept-unrecomputed` | `require-proof`: a picture the verifier does not recompute is refused (step 6) |
### 8.2 Steps
The verifier runs these steps over each record in order and stops at the first refusal, which it
reports with the reason and where: the record by its index (`["record", i]`), a picture of it by
both (`["record", i, "picture", j]`).
1. **Reading.** In this order: the record reads (§2, §7.1, `record_malformed`), its signature
verifies (`bad_key`, `bad_signature`), and no picture's headroom passes 60000
(`headroom_out_of_range`).
2. **Class.** The record's `class` is §7.2's: `class_mismatch`.
3. **The envelope**, where the verifier holds the one `base.object` names:
1. it parses ([SPEC](../SPEC.md) §2, §7.1) and every component conforms to SPEC §7.1's rules
and to the role SPEC §5 gives its capability (the reference
`WaiMulti::check_component_roles`): otherwise `envelope_nonconforming`;
2. entry `base.rendition` is a selectable rendition, entry `companion.index` a companion bound
to it (SPEC §7.1, *Companion components*) whose capability is the derivation's
(`wai.meta.gainmap` for `wai-tonemap:gainmap`, `wai.meta.st2094_50` for
`wai-tonemap:st2094-50`), and the SHA-256 of the companion's payload is `companion.sha256`:
otherwise `companion_mismatch`;
3. the companion reads: §4.1 to §4.3, or §5.1 and §5.2, in their order, each with its reason.
4. **Each picture**, in order, where the verifier holds its frame:
1. for `wai-tonemap:st2094-50`, a set of the companion covers the frame (`frame_uncovered`);
2. the frame's samples are `width` · `height` · 3 bytes and their digest is `input`
(`input_samples_mismatch`); for `wai-tonemap:gainmap`, the map's size fits the base's
(§4.3, `map_dimensions_mismatch`);
3. the derivation of §4.4 or §5.6 at the picture's headroom gives a rendition whose digest is
`output` (`rendition_mismatch`). The picture is then **proved**.
A picture whose frame or companion the verifier does not hold is **not recomputed**.
5. Nothing else is read: a picture's output is never compared with anything but its
recomputation.
6. Under `tonemap` `require-proof`, a picture that is not proved: `rendition_not_proved`.
### 8.3 What an accepted case states
For each record, in order: its link hash, its class, and for each picture, in order, `proved` or
`not-recomputed`.
## 9. Reasons
The codes this extension adds, each with where it is raised:
| reason | raised at |
|---|---|
| `record_malformed` | §2, §7.1, §8.2 step 1 |
| `bad_key` | §2 |
| `bad_signature` | §2 |
| `headroom_out_of_range` | §7.1, §8.2 step 1 |
| `class_mismatch` | §7.2, §8.2 step 2 |
| `envelope_nonconforming` | §8.2 step 3.1 |
| `companion_mismatch` | §8.2 step 3.2 |
| `companion_malformed` | §4.1, §5.1 |
| `metadata_unsupported` | §4.2 step 1, §5.2 step 1 |
| `metadata_malformed` | §4.2 step 2, §5.2 step 2 |
| `metadata_out_of_range` | §4.2 step 3, §5.2 step 4 |
| `gain_space_unsupported` | §4.2 step 4, §5.2 step 5 |
| `map_channels_mismatch` | §4.3 |
| `frame_uncovered` | §5.6, §8.2 step 4.1 |
| `input_samples_mismatch` | §8.2 step 4.2 |
| `map_dimensions_mismatch` | §4.3, §8.2 step 4.2 |
| `rendition_mismatch` | §8.2 step 4.3 |
| `rendition_not_proved` | §8.2 step 6 |
A sink that is given an envelope which breaks SPEC §7.1's component rules reports SPEC's own code
for the break (`companion-only-as-capability`, `companion-only-as-rendition`,
`incompatible-base`, …); a verifier reports the record over it as `envelope_nonconforming`.
## 10. Conformance
[`live-conformance/tonemap/`](../live-conformance/tonemap/) holds the corpus, written by
`wai_tonemap_vectors gen` from fixed seeds and from the integer video corpus: `vectors.json`
(with the arithmetic of §3 and §5.4 at stated points and over whole ranges: `L2`, `E2`, the
table S, `tan` of every angle and the slopes of the video's curves, which a rendition of the
corpus would not always expose), the envelopes it names in `envelopes/`, and `frames.rgb8`, the five frames of the `int_video` clip
of `wai-rs/tests/vectors/int_video/` as `wai.video.int_motion` decodes them (their digests are
that clip's sidecar's). Its stills are generated: an HDR scene made from integer formulas,
rendered to an sRGB base and a gain map by the reference's own encoder, which is integer
throughout and pinned by its code, not by this text. It has:
- **Accepted**: a single-channel map at a quarter of the base's size and a three-channel map at
full size with a `gamma` of 2, each at several headrooms from the base's to past the
alternate's; a map whose alternate's headroom is below the base's (a negative weight); and the
int_video clip under two metadata sets, one with slopes from angles and two alternates, one
with PCHIP slopes, component mixing of types 2 and 3, at headrooms between, on and past the
alternates'; a record whose base is a synthesized picture, `synthesized`; and the envelopes
that carry each, which conform.
- **Refused**: each reason of §9 at least once, each a control of one defect: among them a
rendition at another headroom than the one stated (`rendition_mismatch`), a headroom past 6
stops (`headroom_out_of_range`), a map whose size does not divide the base's
(`map_dimensions_mismatch`), metadata out of range in each companion
(`metadata_out_of_range`), a rendition one sample of which was changed (`rendition_mismatch`),
and a gain map carried as a rendition rather than a companion (`envelope_nonconforming`); and
envelopes that break SPEC §7.1, each with SPEC's code: a gain map as a `WAI1`'s capability, a
gain map as a selectable rendition, a gain map bound to a video, ST 2094-50 metadata bound to an
image.
A conforming verifier gives every case the outcome, the reason, the place and the statements the
corpus states. `wai_tonemap_vectors verify` and the test `tests/exact_tonemap.rs` check that the
corpus regenerates byte for byte, that `wai-rs` gives every case its outcome, that §9 lists
exactly the reference's codes, that §6's figures are the corpus's, and that `frames.rgb8` is the
decode of the `int_video` clip. `live-conformance/tonemap_verify.py` is the second
implementation, written from this text: it shares no code with `wai-rs`; it computes both
derivations in integer arithmetic of its own, reads PNG, the envelopes and both companions
itself, recomputes §6's figures within one code value of the largest and 0.01 of the mean, reads
§9's codes from this text, mutates copies of the corpus and requires every copy to be refused, and
drops each of its own checks in turn and requires the corpus to catch the weakened verifier.
## 11. Security and privacy considerations
- A rendition is a function of the base, the companion and the headroom. A proved record says
that the sink presented that function at that headroom; it does not say the companion is the
one the content's owner made. Who may supply a companion is the envelope's: a companion rides
in the envelope of its base, bound by SPEC §7.1, so a third party cannot attach one without
changing the envelope's content hash.
- A record names the display's headroom. Headroom is a property of the display and its settings
at the time, and a sequence of records from one key can show how a viewer's display was
adjusted. A sink that signs records under a session key reveals no more than the session's.
- A companion's metadata can drive the gain to 16 stops and the offsets to ±1. The derivation is
defined over all of it, and §3.5 clamps every code value to the display's range, so no value it
reads makes a sink present past its peak.
- The readers of §4.2 and §5.2 bound every count and length before they read, and a set or a frame
outside them is refused before anything sized from it is allocated.