Skip to main content

Convert images to BT.2100 PQ HDR in a container that survives upload pipelines

Project description

version python license deps

png2hdr

Make an image glow on HDR displays :: and pick the container that actually survives the upload.


The brief

This started as a client question :: how do we make our logo pop on LinkedIn? Not larger, not louder. Actually brighter than the white of the page around it, the way a good HDR photo glows in a feed while everything beside it sits flat.

It took a couple of passes. The colour maths landed quickly. What ate the time was watching a correct file arrive on the platform looking like nothing had happened, because the part that carries the glow kept getting stripped in transit. png2hdr is where those passes settled :: the recommendation, the exact commands to reproduce it, and, further down, why any of it works.


Why png2hdr

HDR is not a pixel trick. There is no arrangement of samples that goes brighter than SDR white on its own. Something has to tell the compositor to allocate headroom, and every mechanism that does this (cICP, ICC profiles, gain maps) is ancillary data. Ancillary data is the first thing an upload pipeline throws away.

So you can do the colour science perfectly and still ship a file that renders as mud, because the one chunk carrying the signal got dropped somewhere between your machine and a CDN.

png2hdr does the conversion correctly, writes it into the container most likely to survive, and gives you a way to check what actually arrived.


Run it

Fastest, with uv or pipx ::

uv tool install git+https://github.com/danoszz/png2hdr
# or: pipx install git+https://github.com/danoszz/png2hdr
png2hdr --version

No uv or pipx? A plain virtualenv works with the Python that ships with macOS ::

git clone https://github.com/danoszz/png2hdr && cd png2hdr
python3 -m venv ~/.venvs/png2hdr
~/.venvs/png2hdr/bin/pip install -q --upgrade pip
~/.venvs/png2hdr/bin/pip install -q .
ln -sf ~/.venvs/png2hdr/bin/png2hdr ~/.local/bin/png2hdr   # anywhere on your PATH
png2hdr --version

Python 3.9+, numpy, Pillow. No native build, no libpng, no ImageMagick.

On macOS, do not pip install against the system interpreter. PEP 668 blocks it, and forcing past it drops numpy and Pillow into the OS Python.


What it does

Mode Command What happens
flat --mode flat (default) Uniform gain in linear light until the brightest channel hits --peak. For logos, marks, flat colour fields.
knee --mode knee Smoothstep lift above --knee linear luma, hue preserving. For photographs and specular highlights.
retag --mode retag Adds cICP without touching pixels. Pure #000/#fff artwork only.
inspect --inspect Reports what HDR signalling a file or URL actually carries.

Containers:

Flag Output Reach
--format jpg (default) 8-bit PQ + ICC v4 profile with a cicp tag Survives most upload pipelines
--format png 16-bit PQ + cICP, mDCV, cLLI Correct and higher fidelity. Usually stripped on upload.
# the one you want for anything you upload
png2hdr logo.png -o logo_hdr.jpg --peak 1000

# see the luminance report before committing
png2hdr logo.png --dry-run --peak 1600

# purist path :: 16-bit PNG, full chunk set
png2hdr photo.png -o photo_hdr.png --format png --mode knee

# what did the CDN actually serve back?
png2hdr https://cdn.example.com/served.jpg --inspect

How it works

flowchart LR
    S["Source image<br/>sRGB 8-bit"] --> L["Linearise<br/>sRGB EOTF"]
    L --> G["BT.709 to BT.2020<br/>primaries"]
    G --> T{"Tone map"}
    T -->|flat| F["Uniform gain<br/>to --peak"]
    T -->|knee| K["Smoothstep lift<br/>above --knee"]
    F --> Q["PQ OETF<br/>SMPTE ST 2084"]
    K --> Q
    Q --> C{"Container"}
    C -->|jpg| J["8-bit JPEG<br/>+ ICC v4 cicp tag"]
    C -->|png| P["16-bit PNG<br/>+ cICP mDCV cLLI"]

retag skips the middle entirely and only writes the label.


First principles

A walk through the whole idea, assuming you have never met any of these terms. Every step maps to one arrow in the diagram above.

A screen's white is not its brightest. Show a blank white page and the panel is loafing, holding power in reserve. A standard image cannot reach that reserve, because its brightest possible pixel, #ffffff, is defined as white. There is no number above white. That is what SDR (standard dynamic range) means :: the code and the paper-white of the display are pinned together.

HDR is permission, not paint. An HDR display can drive small regions far past paper white, often ten times past. Nothing in the pixels alone unlocks that. The file has to carry a note to the compositor that says "read these values on an absolute brightness scale, and give them the headroom they ask for." Make the note convincing and a flat logo lifts off the page. That note is the whole game.

Colour is coordinates, and the axes can move. A triple (R, G, B) means nothing until you say which red, green, and blue. sRGB, the web default, uses one set of primaries; Rec.2020, the wide gamut HDR rides on, uses far more saturated ones. Before any of that you undo the display gamma to reach linear light, where values are proportional to photons and safe to scale. The sRGB decode is piecewise:

C_\text{lin} =
\begin{cases}
C / 12.92, & C \le 0.04045 \\
\left(\dfrac{C + 0.055}{1.055}\right)^{2.4}, & C > 0.04045
\end{cases}

Then rotate the coordinates from BT.709 (sRGB's primaries) into BT.2020 with a fixed 3x3 matrix:

\begin{bmatrix} R \\ G \\ B \end{bmatrix}_{2020}
=
\begin{bmatrix}
0.6274 & 0.3293 & 0.0433 \\
0.0691 & 0.9195 & 0.0114 \\
0.0164 & 0.0880 & 0.8956
\end{bmatrix}
\begin{bmatrix} R \\ G \\ B \end{bmatrix}_{709}

Now attach real brightness. Linear light is still relative :: 1.0 only means "as bright as the source could go." png2hdr scales it onto an absolute axis measured in cd/m^2 (nits). In flat mode every pixel takes one shared gain, chosen so the brightest channel lands exactly on --peak:

Y = w \, g \, C_\text{lin}, \qquad g = \frac{\text{peak}}{w \cdot \max_i C_{\text{lin},\,i}}

w is diffuse white, 203 cd/m^2 by ITU-R BT.2408. knee mode leaves the midtones alone and lifts only the highlights with a smoothstep, which is what photographs want. Either way the luminance in the report is the BT.2020 weighted sum Y = 0.2627 R + 0.6780 G + 0.0593 B.

PQ is an absolute ruler. To store those nits png2hdr applies the Perceptual Quantizer (SMPTE ST 2084), the transfer function almost every HDR format speaks. Unlike gamma it is absolute :: a given code always means a given luminance, from 0 to 10000 cd/m^2, spaced to match how the eye notices steps.

V = \left( \frac{c_1 + c_2\,Y_n^{\,m_1}}{1 + c_3\,Y_n^{\,m_1}} \right)^{m_2},
\qquad Y_n = \frac{Y}{10000}
m_1 = \tfrac{2610}{16384},\quad
m_2 = \tfrac{2523}{4096}\cdot 128,\quad
c_2 = \tfrac{2413}{4096}\cdot 32,\quad
c_3 = \tfrac{2392}{4096}\cdot 32,\quad
c_1 = c_3 - c_2 + 1

That is m1 = 0.15930, m2 = 78.844, c1 = 0.8359, c2 = 18.852, c3 = 18.688, and it puts 100 nits at signal 0.508, 1000 at 0.752, and 10000 at 1.0. --dry-run prints the numbers behind those curves before you write anything.

The signal is metadata, and metadata is disposable. The pixels are PQ now, which is meaningless until something tags them "BT.2020, PQ, full range." That tag is the four code points 9 / 16 / 0 / 1, and it can ride three ways :: a PNG cICP chunk, an ICC profile, or a gain map. Here is the hack. Upload pipelines re-encode your image and drop any ancillary block they do not recognise. cICP is new, so it gets stripped. ICC profiles are decades old and load-bearing for colour management, so pipelines carry them through untouched. So png2hdr puts 8-bit PQ pixels in a JPEG and smuggles the 9/16/0/1 signal inside the ICC profile's cicp tag. Ugly on paper, correct in practice, because it is the version that survives the trip.

Why a neutral mark on a dark field. If the tag is stripped anyway, the PQ pixels get read as ordinary sRGB. A neutral bright mark degrades to a legible light grey; a saturated field degrades to mud. Keep the bright area small and its frame-average brightness (MaxFALL) low, and the display grants the headroom without a fight. That is why the trick flatters a logo far more than a photo, and why --inspect exists :: point it at the URL the platform hands back and see which of the three fates your file met.


The greyscale trap

The ICC hack has a failure mode, and it is the most useful thing in this repo because it is completely silent. A monochrome logo (a black-and-white wordmark, a neutral icon) has equal channels everywhere, R == G == B. Some platforms, LinkedIn among them, re-encode any such JPEG as a 1-component greyscale image to save bytes. The ICC profile stays bolted on, byte-for-byte intact, still declaring space = RGB. The data underneath is now Gray. On that mismatch the rendering engine discards the profile, the PQ samples get read as plain sRGB, and you get a flat grey logo with a perfect, useless profile attached.

Two files served back from the same platform, same 9196-byte Rec2020-PQ profile, same cicp of 9 / 16 / 0 / 1. The only difference was component count:

Asset Served as Result
chromatic logo 3 components renders HDR
neutral logo 1 component renders grey

It is invisible before upload. What you send is 3-component and correct :: the damage is done server-side, and the only way to see it is to --inspect what the CDN hands back.

The fix is on by default. png2hdr breaks channel equality by injecting a trace of chroma into the shadows only, where PQ has enormous code range and almost no light. PQ code 12 is 0.05 cd/m^2, so against a 1600 cd/m^2 mark it is 1/30000th of the brightness, perceptually absent but mathematically present, and it survives JPEG at q96 with 4:4:4. The mark itself is never touched.

png2hdr logo.png -o logo_hdr.jpg --peak 1600            # auto-detects, injects, says so
png2hdr logo.png -o logo_hdr.jpg --anti-greyscale off   # opt out
png2hdr logo.png -o logo_hdr.jpg --anti-greyscale 8     # set the level yourself

--anti-greyscale auto (the default) only fires when the encoded image is near-neutral, so photographs and coloured marks are left alone. --inspect now reports JPEG component count and shouts when it finds the 1-component-plus-RGB-profile signature, so you can catch the trap on any file, including ones png2hdr never made.


Choosing a peak

The number that predicts success is not peak brightness. It is MaxFALL, the frame-average light level. Displays grant peak output for small windows, not full fields, so a bright mark on a dark background can run the full display peak while a near-white field is already over budget before you pick anything.

Asset shape Coverage Peak MaxFALL
White mark on black 15% 1600 238
Saturated field, black mark 94% 600 462
Saturated field, black mark 94% 1000 771

--dry-run prints MaxFALL before you write anything, and flags it past ~500. That threshold speaks to only one of two independent failure modes, and it is the weaker one.

Frame-average overrun. A display grants peak output to small windows, not full fields, so a high frame-average can make it tone-map the whole image down. A chromatic logo at MaxFALL 462 rendered HDR; the same logo at 771 rendered flat. That is the best explanation for the pair, but it is n=2 and has not been retested since the greyscale fix, so treat the ~500 line as a nudge to check the served file, not as physics.

Greyscale re-encode. The other mode, and the one that earlier notes wrongly blamed on MaxFALL. It has nothing to do with brightness :: it is the colour-space mismatch in the greyscale trap, and it is fixed by default now.

A small neutral mark on a dark field is still the shape the technique flatters most :: a low frame-average keeps you clear of the first mode, and if the profile is stripped entirely a neutral mark degrades to legible grey while a saturated field degrades to mud. Neutral art benefits fully. The earlier notion that it could not was the greyscale bug in disguise, not a limit of the method.


Verify

png2hdr out.jpg --inspect
  container      JPEG
  APP2             2,620  ICC_PROFILE
  encoding       progressive
  components     3
  ICC            2,604 bytes, cicp tag -> [9, 16, 0, 1] :: BT.2020 / PQ (ST 2084) / matrix 0 / full range

  VERDICT  HDR signalled :: PQ (ST 2084). Should drive display headroom.

Point it at the URL a platform serves back to you. That is the only measurement worth trusting, and it takes about ten seconds. Watch the components line especially :: a 1 next to an RGB profile is the greyscale trap, and nothing else will tell you.


ICC profiles

For JPEG output the profile is resolved in this order:

  1. --icc /path/to/profile.icc
  2. A system Rec.2020 PQ profile, if one is installed
  3. A generated ICC v4.4 profile (~2.6 KB), built from BT.2020 primaries, a sampled PQ tone curve, and a cicp tag of 9 / 16 / 0 / 1

The cicp tag is what HDR-aware colour engines read. The matrix and TRC tags exist so that engines which do not understand cicp fall back to something sane instead of nonsense.

--neutral-blue helps saturated sources whose blue channel is genuinely zero. The BT.709 to BT.2020 primaries change invents a small blue term, and because PQ is steep near black that term encodes to a large code value and wrecks the fallback. Zeroing it costs nothing in HDR and keeps the fallback on-hue.


Limits

  • 8-bit JPEG output bands on gradients. Flat colour and hard-edged artwork are fine; skies are not. Use --format png when fidelity beats reach.
  • Display headroom is not constant. macOS allocates it from ambient light and the brightness slider. In a bright room at full SDR brightness it can collapse toward 1.0x and the effect disappears.
  • Platform behaviour is observed, not guaranteed. Re-run --inspect rather than trusting anything written here.
  • retag refuses non-pure images by default. PQ and sRGB agree at neither endpoint's neighbours, so relabelling a mid-tone rotates its hue hard. #CEF900 retagged decodes to 1671 / 7994 / 0 cd/m², collapsing chartreuse into pure green. --force if you mean it.
  • mDCV primary ordering follows PNG Third Edition (R, G, B), not the G, B, R inherited from HEVC SEI. Verify with pngcheck -v if it matters.

Tests

~/.venvs/png2hdr/bin/pip install -q '.[test]'
~/.venvs/png2hdr/bin/pytest

The suite pins the PQ transfer to its ST 2084 anchors (100 cd/m² -> 0.5081, 1000 -> 0.7518), parses the generated profile under ImageCms and reads its 9 / 16 / 0 / 1 cicp tag, confirms the ICC survives a JPEG save and load, checks the PNG chunk order (IHDR, cICP, mDCV, cLLI, ..., IDAT, IEND), exercises the retag guard, and verifies flat mode leaves linear-light channel ratios untouched. It also covers the anti-greyscale path :: neutral input triggers shadow-chroma injection while chromatic input does not, the mark stays bit-identical, the injected chroma decodes to under 0.1 cd/m², and inspect flags a 1-component file that still carries an RGB profile. CI runs it on Python 3.9 through 3.13.


Status

v0.2.3, early. Conversion, both containers, the anti-greyscale fix, and the inspector all work and are covered by tests. Two caveats stated plainly :: the platform-survival claims rest on a handful of real uploads, and every one of them used a LUT-based profile extracted from a third-party file. The generated ~2.6 KB profile parses and carries the right cicp, but it has never been through a live upload. If it turns out not to survive, the ICC section overstates its case. Re-measure with --inspect rather than believing any of this.

Issues and PRs welcome.


Prior art


License

MIT. See LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

png2hdr-0.2.3.tar.gz (24.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

png2hdr-0.2.3-py3-none-any.whl (20.8 kB view details)

Uploaded Python 3

File details

Details for the file png2hdr-0.2.3.tar.gz.

File metadata

  • Download URL: png2hdr-0.2.3.tar.gz
  • Upload date:
  • Size: 24.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for png2hdr-0.2.3.tar.gz
Algorithm Hash digest
SHA256 ce157dc033a58ba1f03618aa99f90bc83cd02cfa08bddf43ac2177d935615c17
MD5 b348972b5d21ae0d52cfbf82eabf40ee
BLAKE2b-256 7ae11304d3e52c898be661f276857e4ec7c6a6e1afa95a4737939f9d7eee4689

See more details on using hashes here.

Provenance

The following attestation bundles were made for png2hdr-0.2.3.tar.gz:

Publisher: publish.yml on danoszz/png2hdr

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file png2hdr-0.2.3-py3-none-any.whl.

File metadata

  • Download URL: png2hdr-0.2.3-py3-none-any.whl
  • Upload date:
  • Size: 20.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for png2hdr-0.2.3-py3-none-any.whl
Algorithm Hash digest
SHA256 db3b1ee0cc4bf5b538e535a3eba86185f20bdeb0ccd9287b5daa119c8cbf1342
MD5 cd989beef2c6adf86d05d13229070421
BLAKE2b-256 227bc85ca7dcaea5170407793ffd7b974e787dc433d579b2e516456b1801aba7

See more details on using hashes here.

Provenance

The following attestation bundles were made for png2hdr-0.2.3-py3-none-any.whl:

Publisher: publish.yml on danoszz/png2hdr

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page