NitsBridge is a portable C++20 library for HDR tone mapping and display transforms. It maps PQ (SMPTE ST 2084) HDR video in BT.2020 to SDR BT.2020 with ITU-R BT.2446-1 Method A, and it implements every stage from the published standards rather than importing them.
Project site: https://jhuanxx44.github.io/NitsBridge/
The project is built in three layers, and the first two now exist:
- a platform-independent CPU reference implementation — done, and it is the contract every other backend is measured against;
- a native macOS/Metal backend and command-line tool — done;
- a Windows GPU backend after the core contract is stable — not started.
| Tests | 18 ctest targets plus a shell contract suite, 33,249 assertions, all passing |
| Backends | cpu (double reference), metal, metal-p010, metal-p010-rgba |
| Platforms | macOS 15+ on Apple silicon (measured on M5); the core builds anywhere |
| Warnings | clean at -Werror with the strict set enabled |
| Known gaps | see Known limitations |
Not a released library: there are no versioned API stability promises yet, and the Metal path is the only GPU backend.
Given a PQ HDR frame — either linear-light BT.2020 RGB or a P010
('x420'/'xf20') video frame straight out of a decoder — it produces a
deterministic SDR BT.2020 frame at a 100 cd/m² diffuse white:
PQ signal ──EOTF──▶ linear light (cd/m², BT.2020 primaries)
──▶ normalized decode against peak luminance
──▶ BT.2446-1 §4.1 Method A (strict, 1,000 → 100 cd/m²)
──▶ BT.2020 NCL reconstruction (BT.2020-2 Table 4)
──▶ narrow-range 10-bit quantization (BT.2100-2 Table 9)
Every one of those stages is a separately documented function with its own
tests, not a fused shader with a comment. The quantization follows BT.2020-2
Table 5 — code = 64 + 876 * E' for luma and code = 512 + 896 * C' for
chroma — and the two chroma channels are deliberately interleaved as (C'b, C'r)
pairs with no row padding throughout the backend contract.
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
ctest --test-dir build --output-on-failureRequires CMake 3.20+, a C++20 compiler, and Python 3 for the test fixtures. On
macOS, the Metal backend is built automatically and adds Objective-C++
(enable_language(OBJCXX)) plus the Metal, Foundation and CoreVideo frameworks.
To install the library and CMake package files:
cmake --install build --prefix /your/prefixConsumers can use find_package(NitsBridge CONFIG REQUIRED) and link
NitsBridge::core. On macOS, NitsBridge::metal is installed alongside the
core target.
With ffmpeg on PATH and the project built
(cmake --build build --target nitsbridge_sdr):
tools/hdr_to_sdr.sh input_hdr.mp4 output_sdr.mp4The script decodes with ffmpeg, runs every frame through the pipeline, and encodes with ffmpeg. All color math is NitsBridge's own; ffmpeg handles container I/O only.
To drive the tool directly — it reads raw yuv420p10le frames on stdin and
writes raw yuv420p10le on stdout:
./build/platform/macos/nitsbridge_sdr --backend metal-p010 --report 1920 1080 \
< in.yuv > out.yuv--backend accepts cpu, metal, metal-p010 and metal-p010-rgba.
On macOS, the A/B player demo shows the CPU reference and Metal P010 result for the same decoded frame side by side:
cmake --build build --target nitsbridge_ab_player
build/platform/macos/nitsbridge_ab_player input.movIt displays frame index, PTS, and per-frame CPU/Metal timing. Press Space to
pause or resume. See the player README.
--report splits the wall time into the backend call and the rest of the tool,
because an end-to-end number alone cannot say which of the two is the
bottleneck. The banner states which backend actually ran, what it covered, and
what stayed on the CPU — a backend is never silently substituted.
Apple M5, Release build, best of three runs, flat frame inside the Method A
operating range. The tool builds a CVPixelBuffer per frame from the raw
stream, which a decoder-based caller would not do, so these are a lower bound
on the win a real caller sees.
| Backend | 1080p (ms/frame) | 4K (ms/frame) |
|---|---|---|
cpu (double reference) |
~1,500 | — |
metal |
267.09 | 1,039.61 |
metal-p010-rgba |
39.51 | 133.82 |
metal-p010 |
22.74 | 63.35 |
The metal shape was slow for an instructive reason: it moved pixels to the
host and then ran the display transform in a double-precision CPU reference, so
97% of its time was host code computing six pow calls per pixel. metal-p010
removes that path entirely — the kernel reads the decoder's planes as textures
and writes the SDR P010 codes itself. The kernel alone is 1.41 ms at 1080p and
5.63 ms at 4K; the rest of the 4K time is the tool's video I/O.
Two numbers worth knowing if you are evaluating this for your own pipeline:
- Texture read-back costs 2.4–2.8× a buffer read-back (1.38 ms vs 0.58 ms at 1080p). That is why the default plane path writes P010 codes into a shared buffer — 3 bytes per pixel instead of 16 bytes of RGBA float.
- The plane path is 16.4× faster end to end at 4K than the M6 shape, because the win is in not doing the work on the host at all, not in the kernel.
Raw measurements: local/planning/m7-end-to-end-measurement.txt and
local/planning/metal-benchmark-m7-100frames.txt (not in git — see
Known limitations).
include/nitsbridge/— public C++ APIsrc/— platform-independent implementationtests/— reference and conformance testsplatform/macos/— Apple-specific adapters, Metal shaders and the tool backenddocs/— the project sitelocal/— ignored local plans, experiments, and generated artifacts
metal-p010 and metal-p010-rgba reproduce the cpu double-precision reference
exactly in the luma plane — 0 of 3,072 samples differ — on a graded fixture
inside the Method A operating range.
They do not agree with each other, or with the CPU, on saturated or over-peak
colours, and an earlier version of this section said they did. Measured on an M5
with a full-range luma ramp and a full chroma swing, metal-p010 and
metal-p010-rgba differ on 1,437 of 4,608 samples, worst case 321 codes.
Even with gentle chroma under the same over-peak luma the two differ by up to 14
codes. The cause is the same structural one described in limitation 2 below and
not a separate defect: the two backends quantize different chroma signals,
because the host 2×2 box-averages the SDR chroma and the plane kernel point-
samples the input chroma plane. Near a saturated colour the reconstruction clamps
a channel and a small signal difference becomes an arbitrarily large code
difference, so a resampler difference that is one code on a gradient becomes
hundreds there.
Exactness in luma is the claim worth making, and it is narrower than what this
section used to assert. The old text reported "0 of 4,608 samples differ" for the
whole frame, which was true and proved much less than it appeared to: the fixture
was a flat field with one luma code and neutral chroma, so one value per plane was
being compared with itself, U and V were interchangeable, and no chroma defect
could be observed at all. Underneath that vacuously green result the plane backend
was rounding where the host path truncates, and the two disagreed by one code
on about a third of the samples of a graded frame — invisible on the flat one only
because its single luma code happens to truncate and round to the same integer.
BT.2020-2 Table 5 specifies INT(), i.e. truncation, so the kernel was changed,
not the host.
The exactness that remains is in luma specifically because that is where it is real: both backends quantize the same float64-derived luma with the same rule, and neither touches the chroma resampler. Chroma is a separate story and is not exact — see limitation 2.
The contract suite asserts both halves of this: zero luma differences on the
graded fixture, and a measured worst case for the chroma resampler at a real
chroma edge. Reverting the truncation fix in p010.metal makes 1,597 of 3,072
luma samples differ, so the assertion is load-bearing rather than decorative.
Before three signal-domain defects were found and fixed, the same fixture family reported a 1-code difference that had been accepted as the float32-versus-float64 gap. It was not; it was two backends clamping different quantities at different stages.
The CPU path runs in double throughout and is the reference. On the GPU path
the same arithmetic runs in float with a documented error budget, and the
backend parity suite asserts each stage against the CPU path at that budget.
The rules the code enforces rather than documents:
- Reject at the boundary; never clamp silently. An out-of-domain input is an error with a name, not a quietly saturated output.
- A wrong color constant produces a plausible image. This is the single most
expensive lesson in the repository's history: two separate defects (a missing
2^(bit_depth-8)divisor, and clamping a chroma signal instead of its code) each produced a photograph that looked intentional, with no NaN, no rejection and no diagnostic. Both were caught only by dumping raw buffer contents and comparing against a host reference written to the same rule. - Validating and computing are separate passes. A kernel that validates while it writes can produce a partially-correct frame before it discovers the problem.
-
Narrow (video) range only. Full-range P010 pixel formats are rejected by name, not reinterpreted as narrow.
-
Chroma is downsampled by single-point sampling, not a 2×2 box average. The host path box-averages the SDR chroma over each 2×2 group before quantizing it (
tools/nitsbridge_sdr.cpp); the plane kernel samples the input P010 chroma at the co-sited texel (nb_p010_chromainplatform/macos/shaders/p010.metal). Those are different filters, and the difference is not bounded by a code: measured on an M5 with a 40-code chroma step under a luma grating,metal-p010reaches 26 codes of chroma error againstcpu. On a smooth gradient it stays within a code or two, and on a flat field it is at most the single signed-zero code.An earlier version of this entry said the difference "is bounded by 1 code and is asserted in the parity suite". Both halves were wrong. No such assertion existed, and the bound does not hold — the real-material numbers in the M7 acceptance record (233 codes, 27.5% of chroma samples) were already inconsistent with it. The contract suite now measures the difference at a real edge instead of asserting a bound that is false, and fails if the plane path stops showing a difference at all — because that would mean the two paths had silently converged, which is a change worth being told about.
Averaging on the GPU would remove the difference and cost a second full-image read per frame.
-
PQ only. HLG is recognized and rejected under its own name rather than decoded as PQ.
-
BT.2020 primaries only. BT.709 primaries are rejected — treating them as BT.2020 is a saturation error, not a rounding error.
-
The shaders compile at runtime. Building a distributable
.metallibneeds the Xcode Metal Toolchain component, which is not installed here. Right for a tool, wrong for a library you ship. -
Measured on Apple M5 only. The parity suite runs on any Apple4-and-later GPU, but it has only been run on M5.
-
local/is gitignored, so the planning documents, acceptance records and raw measurement logs referenced above are not in the repository. What is in the repository is the code and the tests.
MIT. See LICENSE.
Every constant and formula in this repository is derived from the published standards listed below, which are freely available from the ITU. No code was imported from another implementation.
- ITU-R BT.2100-2 — image parameter values for HDR television (PQ EOTF and its inverse, Table 4; narrow-range quantization, Table 9)
- ITU-R BT.2020-2 — parameter values for UHDTV (NCL Y'C'BC'R, Table 4; quantization, Table 5)
- ITU-R BT.2446-1 — methods for conversion of HDR to SDR (Method A, §4.1)
- SMPTE ST 2084 — the PQ transfer function