Skip to content

agx, sigmoid: follow the output's peak for HDR export - #22669

Merged
TurboGit merged 13 commits into
darktable-org:masterfrom
nicknezis:output-headroom
Oct 21, 2026
Merged

TurboGit merged 13 commits into
darktable-org:masterfrom
nicknezis:output-headroom

Conversation

@nicknezis

@nicknezis nicknezis commented Oct 17, 2026 •

Copy link
Copy Markdown

Summary

colorout maps 1.0 to the 203 nit reference white for PQ and HLG, so a tone mapper's target white decided how bright an HDR export peaked, and it had to be set by hand for every target. Following the design agreed with @TurboGit in #22661, the export now decides and the tone mappers adapt on their own, so one edit exports to SDR, HLG and PQ.

  • Pixelpipe: each pipe carries an output headroom (output peak / 203 nits), set together with the output profile: 1 for SDR, 1000/203 for HLG, and for PQ the new "PQ peak" setting in the export module (default 1000 nits). The setting is only shown when the export profile is PQ or "image settings". The headroom is part of the pipe cache hash, and a darkroom on a PQ display reprocesses when the setting changes. darktable-cli takes it with --pq-peak <nits>. PQ export presets store it, and an export keeps the peak it was queued with.
  • AgX follows the HDR design of Blender 5's AgX (AgXHLG.py in EaryChow/AgX_LUT_Gen). The curve's output, target white included, is relative to the output's peak, with the shoulder power multiplied by H^log10(2) and capped at the slider's maximum of 10, where the curve fit would otherwise overflow. A second sigmoid in log2 space then moves the pivot output down by H, so shadows and the pivot keep their SDR brightness and the highlights extend to the peak. A new "HDR purity" slider (default 50%, Blender's value) sets how much of that step's hue and saturation shift is kept. The graph, its markers and the curve warnings use the headroom of the darkroom's full pipe, read on DT_SIGNAL_DEVELOP_UI_PIPE_FINISHED (so cache hits are covered too). The pivot picker keeps working on the SDR curve, since pivot target output is an SDR value.
  • Sigmoid multiplies its target white by the headroom, capped at its own slider maximum of 1600% (3248 nits). Above that cap, extreme contrast, skew and target black make the curve fit overflow.

SDR output is unchanged in both modules (H = 1 gives bit-identical output). Following the output is a hidden param, on for new instances, the auto-applied defaults and the built-in presets. Existing edits are migrated with it off and render exactly as before (option (a) in the design discussion). For those, a "follow output peak" toggle is shown, and only while it is off, so a user can opt an old edit in. Params versions go to AgX 8 and sigmoid 4.

Filmic is not included: its color science v3-v5 clip at 1.0 before the output power, and its spline raises midtone contrast to reach a higher white. It will follow in its own PR. Grain, vignetting, framing and watermark, which sit after the tone mappers, were checked by reading the code: none clips at 1.0 (vignette only with its legacy "unbound" off). Grain's LUT is clamped at L = 100, so grain fades out above reference white rather than clipping.

Referenced issue

Fixes #22664

Checklist

  • I have read CONTRIBUTING.md and the coding style.
  • I have not merged master into the topic branch.
  • The pull request is one logical change, and every commit compiles on its own.
  • I ran the relevant tests: unit tests, src/tests/integration/ where the pixelpipe is touched, or darktable-cli as a headless smoke test.
  • New user-visible strings use _(), new preferences are registered in data/darktableconfig.xml.in.
  • A RELEASE_NOTES.md entry was added (only needed if fixing an issue in a release). Do not reference GitHub issues.

Test instructions

  1. Export the same image as sRGB, PQ Rec2020 and HLG Rec2020 with AgX or sigmoid applied as a new instance, without changing the history. Highlights should reach about 1000 nits in PQ and HLG, and the sRGB export should be identical to before.
  2. In the export module, pick PQ Rec2020 and change "PQ peak" to 4000 nits, then export again: highlights follow the new peak. Pick sRGB: the setting is hidden.
  3. Open an edit made before this change: AgX or sigmoid shows "follow output peak" (off) and HDR exports look as before. Tick it to opt in.

What was run (Linux, CPU only, -DUSE_AI=OFF -DUSE_LUA=OFF -DUSE_GMIC=OFF, on master 748066d):

  • Each commit builds without warnings (git rebase --exec).

  • Integration tests 0097, 0151 (sigmoid), 0174, 0175 (AgX), 0001 and 0014 pass with OpenCL off.

  • darktable-cli on a neutral PFM ramp (one patch per EV, 0.18 x 2^EV) with AgX's and sigmoid's default params, following the output ("follow") and migrated ("legacy"). Output in nits, decoded from PQ and HLG:

    -2 EV 0 +2 +4 +6 +8
    AgX, SDR (follow and legacy) 5.5 36.5 123.8 184.9 201.3 203
    AgX follow, PQ 1000 and HLG 5.5 36.5 259.5 867.9 993.2 1000
    AgX follow, PQ 4000 5.6 36.5 415.7 3643 3993 4000
    AgX legacy, PQ and HLG 5.5 36.5 123.8 184.9 201.3 203
    sigmoid, SDR (follow and legacy) 5.5 36.3 129.2 189.6 201.2 202.8
    sigmoid follow, PQ 1000 and HLG 6.3 36.3 184.7 577.4 891.9 980.3
    sigmoid follow, PQ 4000 (capped at 3248) 6.5 36.3 196.6 873.8 2201 2999

    SDR output with the param on and off is bit-identical for both modules. No NaNs.

  • AgX with pivot target output 18/90/99/100%, shoulder power 3.3 and 10, and SDR, PQ 1000/4000/10000 and HLG output renders without black highlights or NaNs. Pivot output 100% with shoulder power 7 or more already rendered black on master in SDR (float overflow in the shoulder fit); _scale() now works in double, which fixes both.

  • A Sony ARW from the integration test images with the AgX workflow's auto-applied defaults: the brightest highlights reach 195 nits in SDR and 976 nits in PQ 1000 and HLG, with the same history.

  • New integration tests 0182-0189 (in darktable-tests, hlrecovery.arw): sigmoid to sRGB and PQ, AgX to sRGB and HLG, each as a new edit and as an edit made before this PR. SDR pairs are byte-identical; new edits reach 907 nits (sigmoid, PQ 1000) and 1000 nits (AgX, HLG), old edits stay at about 203. Old edits rendered by current master match their expected images (max dE 1.16).

  • darktable-cli --pq-peak 4000 gives the same output as the setting at 4000; out-of-range values are rejected.

  • An export keeps the PQ peak it was queued with: dt_control_export() stores it with the job, which hands it to its pipes through a per-thread value instead of rewriting the setting.

  • Export presets store the PQ peak (export module params version 9) when their profile is PQ or "image settings", and 0 (leave the peak alone) otherwise. A version 8 preset written into data.db was upgraded on startup with 0 inserted after print_dpi and the rest unchanged; a truncated one is dropped.

  • The AgX pivot picker reads the pivot from the SDR curve, as on master, so a pick doesn't depend on the display.

  • The export module was checked under Xvfb: "PQ peak" shows for PQ Rec2020 and is hidden for sRGB.

Not verified: the OpenCL path on a GPU (no GPU in the build container); the AgX and sigmoid GUI (the opt-in toggle, the HDR purity slider, the AgX graph and picker) and the darkroom refresh after changing the PQ peak, which were not opened; the batch-export path with the PQ peak changed mid-batch was not run (the job keeps the peak it was queued with; darktable-cli still reads the setting); saving and applying an export preset in the GUI (the stored layout and the upgrade of old presets were checked).

AI assistance

Written with Claude Code (Anthropic), which implemented the change and ran the tests above.

@nicknezis
nicknezis marked this pull request as ready for review October 17, 2026 19:29
@kofa73

kofa73 commented Oct 17, 2026

Copy link
Copy Markdown
Collaborator

Please let me have the bots check this out. Working with inter-module parameter passing between white balance and color calibration has burnt me badly, so I'd like to have them check out everything is safe here.

@kofa73

kofa73 commented Oct 17, 2026

Copy link
Copy Markdown
Collaborator

@nicknezis "I have not merged master into the topic branch." - this is false, your last commit is exactly such a merge. You must never do that; you should rebase your code on master, not merge master onto your branch.

@nicknezis

nicknezis commented Oct 18, 2026 •

Copy link
Copy Markdown
Author

@nicknezis "I have not merged master into the topic branch." - this is false, your last commit is exactly such a merge. You must never do that; you should rebase your code on master, not merge master onto your branch.

Sorry about that, you’re right: the last commit was a merge of master. I’ve rebased the branch onto current master and force-pushed, so it’s now four linear commits with no merge, each of which builds on its own. The final code is unchanged. Happy to address whatever the bots turn up.

@jenshannoschwalm

Copy link
Copy Markdown
Collaborator

There is also code style, we always use *d for the iop_module data and *p for parameters.

@kofa73

kofa73 commented Oct 18, 2026

Copy link
Copy Markdown
Collaborator

Wrong output

1. AgX loses its shoulder above about 600 nits, and its look moves the peak

_pipe_tone_mapping_params() writes the headroom into curve_target_display_white_ratio (src/iop/agx.c:990). The maximum of that parameter is 1.0 (agx.c:132), so the curve code never ran with a larger white before. Two parts of it fail above 1.0.

  • The shoulder. With the default curve and a peak above about 604 nits, the regular shoulder cannot reach the raised white. need_concave_shoulder (agx.c:970) then selects _fallback_shoulder() (agx.c:603). At 1000 nits its exponent is 0.74, so the curve gets steeper toward white instead of rolling off. The log encoding clips at scene white (agx.c:708), so the output stops hard there.
  • The look. _agx_look() applies slope, lift and power to the gamma-encoded value (agx.c:682), with 1.0 as the fixed point. With an HDR white, 1.0 is no longer the peak. The power of 1.35 in the "punchy" preset lifts a 1000-nit white to 1747 nits.
render +2 EV +4 EV +6 EV +6.5 EV (scene white)
default curve, SDR 123.8 184.9 201.3 203.0
default curve, PQ 1000 nits 138.1 343.2 759.1 1000.0
punchy, SDR 76.2 140.5 192.3 203.0
punchy, PQ 1000 nits 98.0 330.4 1067.4 1747.4

In SDR, the last half stop before scene white adds 0.01 EV of output. At 1000 nits it adds 0.4 EV. Shadows and middle gray do not change. The curve warnings (agx.c:1772) use the darkroom pipe, so on an SDR display they never show the fallback shoulder that an HDR export uses.

The shoulder needs a design for a white above 1.0, and the look should work relative to the effective white.

2. filmic raises midtone contrast and crushes shadows to reach the HDR white

_pipe_data() sets the target white from the headroom (src/iop/filmicrgb.c:2113). For spline v3, the default, dt_iop_filmic_rgb_compute_spline() keeps the slope of the linear segment at or above the slope of the straight line from middle gray to white (filmicrgb.c:2883, applied at :2895). Scene white keeps its log position, so a higher display white (gamma-encoded at :2842) raises that minimum and overrides the user's contrast. With scene-referred defaults (black -7.65 EV, white +4.56 EV, hardness 3.61, contrast 1.0), the slope goes from 1.43 in SDR to 2.50 at 1000 nits.

render -4 EV -2 EV -1 EV 0 EV +1 EV +2 EV +4 EV
SDR 0.8 7.2 17.3 36.6 67.9 111.2 195.7
PQ 1000 nits 0.0 1.4 8.9 35.9 103.7 258.8 907.0

Shadows at -2 EV are 5 times darker, and +2 EV is 2.3 times brighter. The graph and the contrast-clamp warning do not show this (issue 6). A hand-set 493% target white does the same, so this follows from filmic's design. It contradicts the option's promise that one edit renders correctly for SDR and HDR, though. To keep the midtones, the scene range would have to grow with the headroom. If filmic cannot do that, leave it out of the option or document the contrast change.

3. filmic color science v3 to v5 clips the raised white at 203 nits

_pipe_data() rebuilds the spline for the higher white in every color science version. The v3 (2019), v4 (2020) and v5 (2021) paths still clip the spline output to 1.0 before the output power. The clips are at filmicrgb.c:1449, :1497, :1547 and :1599 on the CPU, and at data/kernels/filmic.cl:753, :790, :899 and :933 in OpenCL. Only v6 and v7 clip at the display white. The option and the color science combobox are independent (filmicrgb.c:4603, :4615), and older edits keep their version.

render -2 EV +1 EV +2 EV +4 EV
v7, PQ 1000 nits 1.4 103.7 258.8 907.0
v5, PQ 1000 nits 1.4 103.7 203.0 203.0

With these versions, turning the option on is worse than leaving it off. The shadows are crushed as in issue 2, and the output clips at SDR white by +2 EV. I checked neutral gray only. With chroma preservation, the clip applies to the norm before the ratios are reapplied, so the exact ceiling for colored pixels can differ.

A migrated edit with spline v1 has a related problem. Spline v1 takes the white linearly (filmicrgb.c:2834), so with color science v6 or v7 the white maps to headroom^hardness. I did not render this case.

Either make the option insensitive and inactive for color science below v6 and for spline v1, or carry the higher white through those paths.

4. sigmoid's curve fit overflows with extreme settings at a high PQ peak

_pipe_data() passes the headroom to _compute_curve() (src/iop/sigmoid.c:430). With the 10000-nit maximum of the new preference (data/darktableconfig.xml.in:2689), that is 49.3. The slider stops at 16 (1600%, sigmoid.c:61). With contrast 10, skew 0.9 and target black 15%, paper_exposure overflows to infinity (sigmoid.c:392). Midtones then render as 0, and NaN results fall back to white (sigmoid.c:333).

render -2 EV 0 EV +2 EV +4 EV +6.5 EV
PQ peak 3248 nits (equal to the 1600% slider maximum) 32.0 37.3 66.4 474.8 3248.0
PQ peak 10000 nits 0.0 0.0 0.0 0.0 10000.0

These settings are extreme. Contrast and target black are far outside their soft ranges (0.7 to 3 and 0 to 1%). The overflow is also older than this branch. With skew 1.0, the same settings fail at 100% white with the option off, on the base commit too. The option still adds failures. The settings in the table work at the 1600% slider maximum and fail only at a peak that the slider cannot reach. Keep the fit finite over the whole peak range the preference allows, or limit the combination.

Wrong feedback while editing

5. The AgX pivot picker evaluates the slider's curve

To keep the picked patch's brightness, _apply_auto_pivot_xy() reads it from _calculate_tone_mapping_params(p) (agx.c:1222). That curve uses the slider's target white, not the effective one. With the option on, the new pivot moves the patch, and the picker records the result in history (agx.c:2766).

For example, set the slider to 50%, turn the option on, use SDR output, and pick a neutral patch at +2 EV. The patch's output drops from 0.610 to 0.463 (-0.40 EV). With the slider at 100% and a 1000-nit pipe, it drops from 0.680 to 0.610 (-0.16 EV). I computed these values with the module's curve code, not in the GUI. The picker should evaluate the curve that the pipe renders, through _pipe_tone_mapping_params().

6. filmic's graph, labels and contrast warning use the slider value

The graph builds its spline from self->params (filmicrgb.c:3378). The contrast-clamp warning (:3711) and the white labels (:3801) use the same values. With the option on, they show the slider's curve, not the curve that processing uses. The filmic commit message says that the graph shows the slider value. But the hidden clamp is exactly the contrast change of issue 2, so the user gets no sign of it.

The attached screenshots use the PQ Rec2020 display profile on an SDR X server, so the center view shows PQ code values. Turning the option on changed 419,500 pixels in the center image. The 270x155 graph area stayed pixel-identical.

Off:
filmic-pq-display-option-off

On:
filmic-pq-display-option-on

7. AgX's graph leaves its axes on an HDR display and does not refresh when the headroom changes

The graph uses the headroom of the full pipe (agx.c:1466) and plots y_norm * graph_height (agx.c:1658) against a 0 to 1 axis. At 1000 nits the curve reaches 2.06, so it runs off the top of the plot. Nothing redraws the graph or the shoulder warning when colorout sets a new headroom, for example after a display profile change. Also, the GTK thread reads output_headroom (agx.c:1466, :1772) while the pipe worker writes it (src/common/iop_profile.c:853), without a common lock. I found this by reading the source and did not run it in the GUI.

8. A changed PQ peak preference does not reach open darkroom pipes

colorout reads the preference only when it commits (iop_profile.c:814, :853). The preference-change handlers (src/views/darkroom.c:2607, src/iop/colorout.c:847) do not request a resync. With a PQ display profile, the darkroom keeps the old peak until the next full resync, while an export uses the new one.

In a GTK3/X11 test, I changed the peak from 2001 to 3001 nits and closed the preferences. An 850x500 area of the center view stayed pixel-identical. After I left and re-entered the darkroom, 276,949 pixels in that area changed.

To fix this, compare the peak on DT_SIGNAL_PREFERENCES_CHANGE, and call dt_dev_reprocess_all() if it changed.

Documentation

9. The tooltips and the release note promise more than AgX and filmic deliver

The three tooltips (agx.c:2049, sigmoid.c:1020, filmicrgb.c:4607) and RELEASE_NOTES.md:230 say that one edit renders correctly for SDR and HDR. Issues 1 to 3 contradict this for AgX and filmic. The AgX commit message (856d10f) says target white "had to be set by hand ... about 493% for HLG". AgX's slider stops at 100% (agx.c:132), so that was never possible. Only sigmoid and filmic allow 1600%.

Files and how to reproduce

Attached files:

  • tone-mapper-hdr-repro.zip reproduces issues 1 to 4. It has a neutral PFM ramp (-8 to +10 EV), five XMP sidecars and render.py. The script renders each case with darktable-cli, decodes PQ to nits, and prints a table. Run python3 render.py /path/to/darktable-cli [output-dir]. It uses the CPU, its own config and cache directories, and an in-memory library. It sets the PQ peak with --conf plugins/lighttable/export/pq_peak_nits=<nits>.
    tone-mapper-hdr-repro.zip
  • filmic-pq-display-option-on.png and filmic-pq-display-option-off.png show issue 6.

@nicknezis

Copy link
Copy Markdown
Author

Plan for kofa73's review of PR #22669 (output headroom)

The core change: AgX expands its SDR render instead of fitting the curve to the peak

Items 1, 5, 7 and 9 all come from the same cause. At the moment we feed the HDR
headroom (4.93 at 1000 nits) into AgX's target white, a parameter whose
curve maths was only built for values up to 1.0. Above about 600 nits the
fitted shoulder can't reach the new white, so AgX switches to its fallback
shoulder, which gets steeper near the top instead of rolling off. The look
controls also still treat 1.0 as the fixed point, so "punchy" pushes the
peak to 1747 nits.

Proposal. When "target white from output" is on, AgX always builds its
normal SDR curve with target white at 100%, so the curve and look work
exactly as they do in SDR. A gain then lifts the highlights to the
output's peak, gain-map style:

  • Pixels at or below the pivot's output level (18%, which is middle gray)
    are left alone, so shadows and midtones match SDR exactly.
  • Above the pivot, t = log(y/pivot) / log(1/pivot) and gain = H^(t²).
    SDR white (y = 1) lands exactly on the output peak H, and the curve
    has no kink at the pivot.
  • The gain is computed from max(RGB) and applied to all three channels
    equally. The SDR render's hue and saturation are kept, and no channel
    can go past the peak.
  • In SDR (H = 1) the gain is 1, so nothing changes there.

Expected neutral-ramp output at a 1000-nit PQ peak, computed from kofa73's
SDR numbers (not rendered yet):

Render 0 EV +2 +4 +6 +6.5
Default curve, SDR 36.6 123.8 184.9 201.3 203
Default curve, PQ 1000 (now) 138.1 343.2 759.1 1000
Default curve, PQ 1000 (proposed) 36.6 277.6 769.2 976.3 1000
Punchy, PQ 1000 (now) 98.0 330.4 1067.4 1747.4
Punchy, PQ 1000 (proposed) 102.1 375.7 857.9 1000

The proposed curve keeps the SDR roll-off: the last half stop adds 0.035 EV
of output, compared with 0.4 EV now. The punchy look also stays under the
peak.

The alternative is a new AgX shoulder designed for whites above 1.0, which
is the other fix kofa73 suggests. It's more invasive in his own module and
changes the HDR midtones, so I'd only go that way if he prefers it.

Item by item

  1. AgX shoulder and look above about 600 nits. Fixed by the expansion
    above. The curve itself never sees a white above 1.0, so the fallback
    shoulder and the look's fixed point both behave as in SDR. The same gain
    goes into process() and agx.cl.

  2. Filmic raises midtone contrast to reach the HDR white.
    Recommendation: drop filmic from this PR. Filmic has seven
    color-science paths, each with its own CPU and OpenCL code. kofa73 also
    offered dropping filmic as one of the fixes. AgX and sigmoid are the
    modules people pick for new edits. Filmic can come back later in its own
    PR, using the same gain approach.

  3. Filmic v3–v5 clip at 203 nits. Goes away with item 2. If we keep
    filmic instead, the option would be insensitive and ignored for color
    science below v6 and for spline v1.

  4. Sigmoid overflows at extreme settings and a very high peak. Cap the
    headroom sigmoid uses at its own slider maximum of 1600% (3248 nits).
    kofa73's failing settings work there. PQ peaks above that are clipped to
    3248 nits for sigmoid, and the tooltip will say so. The older overflow
    that happens with the option off (skew 1.0) is outside this PR, so I'd
    mention it but leave it alone.

  5. The AgX pivot picker evaluates the slider's curve. Run the picker
    through the same curve-plus-gain path the pipe renders, using the
    headroom stored for the GUI (see item 7).

  6. Filmic's graph uses the slider value. Goes away with item 2.

  7. AgX graph, refresh and data race.

    • When AgX processes the darkroom's full pipe, it saves the headroom
      into its GUI data under the module's GUI lock. If the value changed,
      it queues a redraw of the graph and refreshes the curve warnings
      through dt_control_queue_redraw_widget().
    • The GTK thread only reads that saved copy, never pipe->output_headroom.
      That fixes both the race and the missing refresh after a display
      profile change.
    • The graph keeps its current axes and shows the SDR curve. With headroom
      above 1, a second line shows the expanded curve, scaled so the peak sits
      at the top of the graph, plus a label giving the peak in nits.
    • The curve warnings use the SDR curve, which is the one the fallback
      shoulder applies to. With this design they match what export does.
  8. A changed PQ peak doesn't reach open darkroom pipes. In
    darkroom.c's _preference_changed(), keep the last-seen pq_peak_nits.
    If it changed, call dt_dev_reprocess_all(dev). The pipe cache already
    hashes the headroom, so that is enough.

  9. The docs overstate HDR support.

    • Change the tooltips and the release note to describe what happens:
      with the option on, shadows and midtones match SDR and highlights are
      lifted to the output's peak.
    • Fix the AgX commit message, which wrongly claims AgX's target white
      could be set to 493% by hand. The slider stops at 100%; only sigmoid
      and filmic go higher.

Commit series after the rework (rebased on master, each one building)

  1. pixelpipe: carry the output's peak luminance as output_headroom (the
    pipe field, the preference, the cache hash, and the darkroom reprocess
    from item 8)
  2. agx: optionally lift highlights to the output's peak (items 1, 5 and 7)
  3. sigmoid: optionally take target white from the output's peak (item 4)
  4. RELEASE_NOTES.md: tone mappers' target white from output (item 9)

How I'll check it

  • Use darktable-cli on a neutral PFM ramp from -8 to +10 EV, at SDR, PQ
    1000, PQ 10000 and HLG. That's my own version of kofa73's
    render.py/XMP setup, because his zip couldn't be downloaded from here.
    Decode PQ to nits and fill in the tables above with real numbers.
  • Run the AgX and sigmoid integration tests on the CPU. OpenCL isn't
    available in this container, so agx.cl only gets a compile check and a
    careful read. That gap will be stated in the PR.
  • No GUI testing here (no display), so the graph and picker changes will be
    checked by reading the code. Worth a manual look on the MacBook.

Decisions for Nick and @kofa73

  • A. AgX approach: gain on the SDR render (recommended), or a new
    shoulder for whites above 1.0.
  • B. Filmic: drop it from this PR (recommended), or keep it with the gain
    approach and gate it to v6/v7.
  • C. Sigmoid peak: cap it at 1600% / 3248 nits (recommended), or make
    the fit robust up to 10000 nits.

@nicknezis

Copy link
Copy Markdown
Author

There is also code style, we always use *d for the iop_module data and *p for parameters.

Thank you. This coding style issue should be corrected now.

@kofa73

kofa73 commented Oct 18, 2026

Copy link
Copy Markdown
Collaborator

I don't use HDR. If you don't break my simple SDR renders, I'm OK. However, AFAIK, Blender 5 did HDR work for AgX. Maybe you should look into that.

I'll run another round later, but I also have stuff to do for 5.8, and I must prioritise that.

@nicknezis

Copy link
Copy Markdown
Author

Revised plan based on Blender 5's AgX.

The core change: follow Blender 5's AgX HDR design

Items 1, 5, 7 and 9 all come from the same cause. At the moment we feed the HDR
headroom (4.93 at 1000 nits) into AgX's target white, a parameter whose
curve maths was only built for values up to 1.0. Above about 600 nits the
fitted shoulder can't reach the new white, so AgX switches to its fallback
shoulder, which gets steeper near the top instead of rolling off. The look
controls also still treat 1.0 as the fixed point, so "punchy" pushes the
peak to 1747 nits.

What Blender 5 does

Blender 5 ships an "AgX - HDR 1000 nits" view for Rec.2100 PQ and HLG
displays. The view is a baked LUT generated by Eary Chow's AgXHLG.py in
EaryChow/AgX_LUT_Gen; the
view itself is in release/datafiles/colormanagement/config.ocio. The
design decisions in it are:

  • The curve output is relative to the peak. The same log2 scene range
    (-10 to +6.5 EV), pivot and contrast as SDR form the image on 0..1, but
    1.0 now means the HDR peak, not reference white. Scene white still lands
    exactly on the peak.
  • The shoulder is harder for HDR. The shoulder power is multiplied by
    ratio^log10(2), where ratio = peak / reference white. At 1000/100 that
    doubles it (1.5 becomes 3.0).
  • Middle gray keeps its SDR brightness. After linearizing, a second
    sigmoid in log2 space maps 0.18 to 0.18/ratio. That is the same absolute
    luminance as in SDR (18 nits for Blender's 100-nit reference white). Below
    gray the sigmoid has slope 1, so shadows only shift and keep their SDR
    brightness. Above gray it rolls highlights up into the peak.
  • Highlights keep only half the chroma change. That darkening step is
    per channel. Its hue and saturation shifts are then blended 50/50 with the
    values before darkening ("HDR purity 0.5").
  • Peak and reference white are fixed. Blender uses 1000 nits and a
    100-nit reference white, limited to P3. The script's comments say the
    intended tunable range is a 400–1000 nit peak with a 100–400 nit
    reference white.

Proposal: port that design into darktable's AgX

With "target white from output" on and headroom H above 1:

  1. Build the curve with target white 1.0, now meaning the output's peak.
    Multiply the shoulder power by H^log10(2). Contrast, pivot, toe and the
    look stay as they are, so the look's fixed point is the peak.
  2. Linearize, then apply Blender's log2-space sigmoid, which maps the pivot
    output (18%) to 18%/H. Blend hue and saturation 50/50 as Blender does.
    Multiply by H so 1.0 is again the 203-nit reference white that colorout
    expects.
  3. In SDR (H = 1) both steps drop out, so nothing changes.

The curve itself never sees a white above 1.0, so neither the fallback
shoulder nor the look's fixed-point problem can happen. Because the curve
still lives on 0..1, the graph's axes fit as they are (item 7).

Neutral ramp in nits, from a Python model of darktable's AgX curve.
The model reproduces kofa73's SDR numbers exactly; none of these were
rendered in darktable yet:

Default curve -2 EV 0 +2 +4 +6 +6.5
SDR 5.5 36.5 123.8 184.9 201.3 203
PQ 1000, current branch (kofa73) 138.1 343.2 759.1 1000
PQ 1000, Blender-style 5.5 36.5 259.5 867.9 993.2 1000
PQ 1000, gain on SDR render 5.5 36.5 277.5 769.4 976.9 1000
PQ 4000, Blender-style 5.6 36.5 415.7 3643 3993 4000
PQ 4000, gain on SDR render 5.5 36.5 559.9 2657 3856 4000

The two approaches agree up to middle gray and both stop at the peak. The
Blender-style version compresses the top of the range harder, and more so
as the peak rises. My earlier gain-on-SDR idea spreads the highlights more
evenly, but it is our own invention. The Blender design has the advantage
of coming from AgX's own lineage. darktable's AgX was ported from Blender
and already cites it for its defaults and presets, so it should be the
easier sell to kofa73.

The fallback, if kofa73 prefers it, is a new AgX shoulder designed for
whites above 1.0.

Item by item

  1. AgX shoulder and look above about 600 nits. Fixed by the
    Blender-style design above. The curve itself never sees a white above
    1.0, so the fallback shoulder and the look's fixed point both behave as
    in SDR. The same steps go into process() and agx.cl.

  2. Filmic raises midtone contrast to reach the HDR white.
    Recommendation: drop filmic from this PR. Filmic has seven
    color-science paths, each with its own CPU and OpenCL code. kofa73 also
    offered dropping filmic as one of the fixes. AgX and sigmoid are the
    modules people pick for new edits. Filmic can come back later in its own
    PR.

  3. Filmic v3–v5 clip at 203 nits. Goes away with item 2. If we keep
    filmic instead, the option would be insensitive and ignored for color
    science below v6 and for spline v1.

  4. Sigmoid overflows at extreme settings and a very high peak. Cap the
    headroom sigmoid uses at its own slider maximum of 1600% (3248 nits).
    kofa73's failing settings work there. PQ peaks above that are clipped to
    3248 nits for sigmoid, and the tooltip will say so. The older overflow
    that happens with the option off (skew 1.0) is outside this PR, so I'd
    mention it but leave it alone.

  5. The AgX pivot picker evaluates the slider's curve. Run the picker
    through the same path the pipe renders (curve plus middle-gray
    darkening), using the headroom stored for the GUI (see item 7).

  6. Filmic's graph uses the slider value. Goes away with item 2.

  7. AgX graph, refresh and data race.

    • When AgX processes the darkroom's full pipe, it saves the headroom
      into its GUI data under the module's GUI lock. If the value changed,
      it queues a redraw of the graph and refreshes the curve warnings
      through dt_control_queue_redraw_widget().
    • The GTK thread only reads that saved copy, never pipe->output_headroom.
      That fixes both the race and the missing refresh after a display
      profile change.
    • The graph keeps its current axes, because the curve stays on 0..1, now
      relative to the peak. When the headroom is above 1, a label gives the
      peak in nits.
    • The curve warnings follow the same headroom the darkroom pipe renders
      with.
  8. A changed PQ peak doesn't reach open darkroom pipes. In
    darkroom.c's _preference_changed(), keep the last-seen pq_peak_nits.
    If it changed, call dt_dev_reprocess_all(dev). The pipe cache already
    hashes the headroom, so that is enough.

  9. The docs overstate HDR support.

    • Change the tooltips and the release note to describe what happens:
      with the option on, shadows and midtones keep their SDR brightness and
      highlights extend to the output's peak, following Blender's AgX HDR.
    • Fix the AgX commit message, which wrongly claims AgX's target white
      could be set to 493% by hand. The slider stops at 100%; only sigmoid
      and filmic go higher.

Commit series after the rework (rebased on master, each one building)

  1. pixelpipe: carry the output's peak luminance as output_headroom (the
    pipe field, the preference, the cache hash, and the darkroom reprocess
    from item 8)
  2. agx: optionally form the image for the output's peak, as Blender's AgX HDR (items 1, 5 and 7)
  3. sigmoid: optionally take target white from the output's peak (item 4)
  4. RELEASE_NOTES.md: tone mappers' target white from output (item 9)

How I'll check it

  • Use darktable-cli on a neutral PFM ramp from -8 to +10 EV, at SDR, PQ
    1000, PQ 10000 and HLG. That's my own version of kofa73's
    render.py/XMP setup, because his zip couldn't be downloaded from here.
    Decode PQ to nits and fill in the tables above with real numbers.
  • Run the AgX and sigmoid integration tests on the CPU. OpenCL isn't
    available in this container, so agx.cl only gets a compile check and a
    careful read. That gap will be stated in the PR.
  • No GUI testing here (no display), so the graph and picker changes will be
    checked by reading the code. Worth a manual look on the MacBook.

Decisions

  • A. AgX approach: Blender 5's design (recommended), our own gain on the
    SDR render, or a new shoulder for whites above 1.0.
  • A2. HDR purity: keep Blender's 50% hue and saturation blend (recommended)
    or make it a slider. A slider means a new param and a new UI string.
  • B. Filmic: drop it from this PR (recommended), or keep it with the same HDR
    approach and gate it to v6/v7.
  • C. Sigmoid peak: cap it at 1600% / 3248 nits (recommended), or make
    the fit robust up to 10000 nits.

@nicknezis nicknezis changed the title agx: optionally take target white from the output's peak agx, sigmoid: follow the output's peak for HDR export Oct 18, 2026
Comment thread src/iop/sigmoid.c
Comment thread src/iop/agx.c
@TurboGit
TurboGit requested a balanced review from Copilot October 18, 2026 19:12
@TurboGit TurboGit added this to the 5.8 milestone Oct 18, 2026
@TurboGit TurboGit added priority: medium core features are degraded in a way that is still mostly usable, software stutters feature: new new features to add scope: image processing correcting pixels labels Oct 18, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Numerical overflow, export-setting isolation and persistence, and HDR graph consistency remain unresolved.

5 open issues
What changed in this PR

Makes AgX and sigmoid adapt to output brightness so one edit can serve SDR and HDR exports.

Changes:

  • Adds per-pipe output headroom and a configurable PQ peak.
  • Adapts tone mapping while preserving legacy edits.
  • Updates HDR controls, graph rendering, and release notes.
File Description
src/​libs/​export.c Adds PQ peak control.
src/​iop/​sigmoid.c Scales target white for HDR.
src/​iop/​agx.c Adds HDR mapping and GUI support.
src/​develop/​pixelpipe_hb.h Declares output headroom.
src/​develop/​pixelpipe_hb.c Initializes output headroom.
src/​develop/​pixelpipe_cache.c Includes headroom in cache identity.
src/​common/​iop_profile.c Derives headroom from output settings.
RELEASE_NOTES.md Documents adaptive HDR output.
data/​kernels/​agx.cl Adds OpenCL HDR mapping.
data/​darktableconfig.xml.in Registers PQ peak preference.

Review effort: Balanced

Comment thread src/iop/agx.c Outdated
Comment thread src/common/iop_profile.c Outdated
Comment thread src/iop/agx.c Outdated
Comment thread src/iop/agx.c
Comment thread src/libs/export.c
@nicknezis
nicknezis force-pushed the output-headroom branch 2 times, most recently from ac5161a to 36824ab Compare October 19, 2026 01:14
@jenshannoschwalm

Copy link
Copy Markdown
Collaborator

Ok, i am definitely out of reviewing here.

Comment thread src/iop/agx.c Outdated
Comment thread data/kernels/agx.cl Outdated

@TurboGit TurboGit left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks ok by me. I have also tested the integration tests (thanks). Looks good too. I will regenerate the expected output to ensure a closer match when run on my machine.

I'll sleep on this and will merge tomorrow if nothing wrong come to mind.

@kofa73

kofa73 commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator

I'll get the bots to have another look, too

@kofa73

kofa73 commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Codex (one could say it's an AgX bug, not HDR related - setting pivot output to 100% should probably be prevented):


Findings

  • medium src/iop/agx.c:1043: HDR shoulder adjustment turns supported white highlights black.

    With "follow output peak" enabled, set AgX's pivot target output to 100%, leave shoulder power at its default 3.3, and export to PQ Rec2020 at 4000 nits. Bright neutral pixels become black. This violates the requirement that the shoulder reaches target white. The setting is within the declared slider range. The pivot picker (src/iop/agx.c:1339) can also assign exactly 100%.

    The new multiplier raises shoulder power to approximately 8.095. When pivot output equals target white, the existing _scale()(src/iop/agx.c:583) clamps the remaining rise to 1e-6. Raising that value to -8.095 overflows a float, so the fitted scale becomes zero. _scaled_sigmoid() (src/iop/agx.c:611) then divides by zero and produces NaN. The CPU curve clamp converts that result to target black. The fixed cap of 10 therefore does not prevent the overflow described in the new comment.

    Confirmed with full CPU CLI exports of a neutral PFM ramp, using neutral AgX settings with primaries adjustments disabled. Both Release and RelWithDebInfo builds produce black highlights and exit successfully. Values below are decoded from the exported PQ TIFFs:

    Linear input, each RGB channel Base AgX v7, same test host PR AgX, follow enabled, PQ 4000
    0.36 203.0 nits 0 nits
    0.72 203.0 nits 0 nits
    11.52 203.0 nits 0 nits

    The same PR settings render white highlights in SDR and at PQ 1000. Turning follow off also preserves white highlights at PQ 4000. The underlying fit limitation predates this PR, but the new automatic power adjustment exposes it with the default shoulder power. The base comparison used AgX source from 748066d59b22e52a50, compiled against the same host and headers, rather than a full base build.

Keep the fit finite when the pivot approaches or equals target white, or provide a valid limiting curve for that case. A constant shoulder-power cap alone is insufficient. Both CPU and OpenCL processing receive the affected host-computed parameters; GPU behavior was not exercised.

@kofa73

kofa73 commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator

Some of the items here are marked with "commit message documents this", but the described behaviours still sound weird to me.

Claude:

  • Unsafe preset parsing: the new v8→v9 export preset migration (src/libs/export.c:2355) assumes the stored blob is at least 36 bytes. A crafted preset shorter than that makes the copy length underflow. The older v7→v8 branch and set_params() already have the same unchecked pattern, so the whole export preset parser deserves a separate security pass.
  • Presets set a hidden value: applying an sRGB preset, or one saved before this change (which gets 1000 nits), changes the PQ peak while its slider is hidden. Later PQ exports then use that value. The commit message documents this.
  • Pivot picker depends on the display: in AgX, picking on an HDR display stores a different "pivot target output" than the same pick on an SDR display, so the edit's SDR export depends on which display was used.
  • One-way toggle: once "follow output peak" is turned on, the toggle disappears; only undo or history turns it off. The commit messages say this is intended.

@nicknezis

Copy link
Copy Markdown
Author

Codex (one could say it's an AgX bug, not HDR related - setting pivot output to 100% should probably be prevented):

Findings

  • medium src/iop/agx.c:1043: HDR shoulder adjustment turns supported white highlights black.
    With "follow output peak" enabled, set AgX's pivot target output to 100%, leave shoulder power at its default 3.3, and export to PQ Rec2020 at 4000 nits. Bright neutral pixels become black. This violates the requirement that the shoulder reaches target white. The setting is within the declared slider range. The pivot picker (src/iop/agx.c:1339) can also assign exactly 100%.
    The new multiplier raises shoulder power to approximately 8.095. When pivot output equals target white, the existing _scale()(src/iop/agx.c:583) clamps the remaining rise to 1e-6. Raising that value to -8.095 overflows a float, so the fitted scale becomes zero. _scaled_sigmoid() (src/iop/agx.c:611) then divides by zero and produces NaN. The CPU curve clamp converts that result to target black. The fixed cap of 10 therefore does not prevent the overflow described in the new comment.
    Confirmed with full CPU CLI exports of a neutral PFM ramp, using neutral AgX settings with primaries adjustments disabled. Both Release and RelWithDebInfo builds produce black highlights and exit successfully. Values below are decoded from the exported PQ TIFFs:

    Linear input, each RGB channel
    Base AgX v7, same test host
    PR AgX, follow enabled, PQ 4000

    0.36
    203.0 nits
    0 nits

    0.72
    203.0 nits
    0 nits

    11.52
    203.0 nits
    0 nits

    The same PR settings render white highlights in SDR and at PQ 1000. Turning follow off also preserves white highlights at PQ 4000. The underlying fit limitation predates this PR, but the new automatic power adjustment exposes it with the default shoulder power. The base comparison used AgX source from 748066d59b22e52a50, compiled against the same host and headers, rather than a full base build.

Keep the fit finite when the pivot approaches or equals target white, or provide a valid limiting curve for that case. A constant shoulder-power cap alone is insufficient. Both CPU and OpenCL processing receive the affected host-computed parameters; GPU behavior was not exercised.

Thanks for running the bots over it. Taking the findings in order:

1. Black highlights with pivot target output at 100% (f77da38)

Reproduced, and the cause is as described: with the pivot at target white, the rise left for the shoulder is clamped to _epsilon, and _scale() raised it to -power in float. Above a shoulder power of about 6.4 that overflows to inf, the scale becomes 0 and _scaled_sigmoid() returns NaN. This already happens on master in SDR: with pivot output 100% and shoulder power 7 or 10, current master renders everything above the pivot black. Following the output peak raises the power enough to hit it at the default 3.3.

_scale() now computes those powers in double. Normal settings render as before (integration tests 0174, 0175 and 0182-0189 give the same dE as before the change). A sweep of pivot output 18/90/99/100% x shoulder power 3.3/10 x SDR, PQ 1000, PQ 4000, PQ 10000 and HLG has no black highlights or NaNs. The cap of the scaled power at 10 stays: without it, powf() in _sigmoid() still overflows at the highest PQ peaks. Since it is a master bug too, the fix has its own release note under bug fixes.

Blocking pivot output at 100% would also avoid it, but that changes the slider range for everyone, so I've left it as your call.

2. Preset upgrade reading past a short blob (aef5b12)

The v8 to v9 upgrade now checks that the blob holds the nine leading ints before copying it, and drops the preset otherwise (tested with a truncated v8 preset in data.db). The older upgrade steps and set_params() walk the blob with strlen() the same way and were not changed here; they predate this PR and are better looked at in a separate review of the whole parser.

3. Presets silently changing the PQ peak (aef5b12)

Fixed. A preset now stores the peak only when its profile is PQ or "image settings", and 0 otherwise, which leaves the current peak alone when applied. Presets from before this PR are upgraded with 0 as well, so they no longer reset it to 1000. Multi-preset export puts the peak back afterwards.

4. Pivot picker depending on the display (705fe68)

Fixed. Pivot target output is an SDR value, so the picker now reads it from the SDR curve, as on master, whatever display it is used on.

5. The "follow output peak" toggle only working one way

That is by design, following @TurboGit's suggestion in #22661: following the output is a hidden param, on for new edits, and the toggle only exists so that edits made before this PR can opt in. Once it's on, the module behaves like a new instance. If you'd rather it stayed visible so it can be switched back, that's a small change and I'm happy to make it.

What I didn't verify: the OpenCL path (no GPU here) and the GUI (picker, presets dialog) and multi-preset export, beyond building and the darktable-cli and data.db checks above.

claude added 13 commits October 20, 2026 22:23
colorout maps 1.0 to the 203 nit reference white for PQ and HLG, so
a tone mapper needs to know how far above that the output can go
before it can use an HDR display's range. Nothing in the pipe says so
today.

The pipe now carries an output headroom, the peak of its output
relative to 203 nits, set together with the output profile: 1 for
SDR, 1000/203 for HLG (the nominal display colorout's OOTF assumes),
and for PQ the new "PQ peak" setting of the export module (default
1000 nits) divided by 203. The setting is only shown when the export
profile is PQ or taken from the image. It is part of the pipe cache
hash, since the PQ peak can change without the profile changing, and
a darkroom on a PQ display reprocesses when it changes there.

Related: darktable-org#22664
colorout maps 1.0 to the 203 nit reference white for PQ and HLG, so
AgX's target white, which stops at 100%, caps an HDR export at
reference white, and one history cannot render correctly for both SDR
and HDR.

AgX now follows the output's peak, using the pipe's output headroom
H and the HDR design of Blender 5's AgX (AgXHLG.py in
github.com/EaryChow/AgX_LUT_Gen):

- the curve's output, target white included, is relative to the
  output's peak; the shoulder power is multiplied by H^log10(2), so
  the shoulder stays smooth at the higher peak
- after linearizing, a second sigmoid in log2 space moves the pivot
  output down by H, so shadows and the pivot keep their SDR
  brightness and the highlights extend up to the peak
- per-channel darkening saturates highlights; a new "HDR purity"
  slider (default 50%, as in Blender) sets how much of that hue and
  saturation shift is kept

In SDR (H = 1) nothing changes. The curve itself never sees a white
above 1.0, so the regular shoulder always reaches the peak and the
look's slope, lift and power keep their fixed point there.

New instances and presets follow the output. Existing edits are
migrated with it off, so they render exactly as before, and a
"follow output peak" toggle, shown only while it is off, opts them
in.

The headroom is only known once colorout has committed, so the curve
is rebuilt at process time. The shoulder power is capped at its
slider maximum, since a higher one makes the curve fit overflow for a
high pivot target at the highest PQ peaks.

On DT_SIGNAL_DEVELOP_UI_PIPE_FINISHED the GUI reads the darkroom full
pipe's headroom, with a trylock on busy_mutex, so that cache hits are
covered too. The graph, its markers, the curve warnings and the pivot
picker then show what the center view renders and follow display
profile changes. In HDR the graph's top is the peak and its guide
labels are relative to reference white.

Fixes darktable-org#22664
Like AgX, sigmoid's target white sets how bright an HDR export peaks,
since colorout maps 1.0 to the 203 nit reference white for PQ and HLG.

Sigmoid now multiplies its target white by the pipe's output
headroom, capped at the slider's own maximum of 1600% (3248 nits).
Above that the curve fit can overflow for extreme contrast, skew and
target black, which renders midtones black. In SDR nothing changes.
The curve fit moves out of commit_params() so it can be redone at
process time, when the headroom is known.

As in AgX, new instances and presets follow the output, existing
edits are migrated with it off, and a "follow output peak" toggle,
shown only while it is off, opts them in.

Related: darktable-org#22664
The eight hdr_* fields of tone_mapping_params_t, and their copy in the
OpenCL kernel, become one hdr_params_t member. _set_hdr_params(),
_hdr_darken() and _agx_hdr() now take only that struct.

No change in output.
The PQ peak was read from the export module's setting whenever an
image's output profile was set, so changing it while a batch ran also
changed the images still waiting in that batch.

The export module now reads it once, when the export is queued, and
the job stores it with its other settings. The job hands it to the
pipes it runs through a per-thread value in iop_profile.c, rather than
by writing the setting back, which would change the darkroom and undo
a new value picked in the export module during the batch.
darktable-cli and other pipes still read the setting.
Export presets did not include the PQ peak, so applying a preset kept
whatever peak was set, and multi-preset export could not give each
preset its own peak.

The peak is now stored after print_dpi, so the export module's params
go to version 9. Presets saved by older versions get the default peak
of 1000 nits. Since an export job keeps the peak it was queued with,
each preset of a multi-preset export renders with its own peak.
darktable-cli could only set the PQ peak through the export module's
setting, with --core --conf plugins/lighttable/export/pq_peak_nits=...
It now takes --pq-peak <nits>, from 203 to 10000, which applies to the
exports of that run only.
With pivot target output at 100%, the rise left for the shoulder is
clamped to _epsilon, and _scale() raised it to -power in float. Above a
shoulder power of about 6.4 that overflowed to inf, the scale became
zero, and _scaled_sigmoid() returned NaN, so everything above the pivot
rendered black. This already happened in SDR with shoulder power 7 and
above; following an HDR output's peak raises the power enough to hit
it at the default 3.3.

_scale() now computes the powers in double. Normal settings render as
before.
The pivot picker read the picked value off the curve for the darkroom's
output, so on an HDR display it stored a different pivot target output
than the same pick on an SDR display, and the edit's SDR export then
depended on the display it was made on.

Pivot target output is an SDR value that HDR output keeps, so the
picker now uses the SDR curve, as it did before HDR output.
Every export preset stored the PQ peak, and presets from before it gave
1000 nits. Applying an sRGB preset, or an older one, therefore changed
the PQ peak while its slider was hidden, and later PQ exports used that
value. Multi-preset export also left the last preset's peak behind when
the current profile was not PQ.

Presets now store the peak only for a PQ or "image settings" profile,
and 0 otherwise, which leaves the peak as it is when applied. Older
presets get 0 too. Multi-preset export puts the peak back afterwards.
The upgrade of an older preset also checks that it is long enough
before copying it.
@kofa73

kofa73 commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator

Blocking pivot output at 100% would also avoid it, but that changes the slider range for everyone, so I've left it as your call.

If you think about it, setting pivot at 100% means everything from the pivot to the white point becomes white, the shoulder is basically horizontal -> the pivot is the white point (everything above it clips). That only makes sense if the pivot input is on the right edge of the graph (there is no shoulder at all).

@TurboGit

Copy link
Copy Markdown
Member

What I didn't verify: the OpenCL path (no GPU here)

I have checked that and found no issue.

@TurboGit

Copy link
Copy Markdown
Member

I have tested using the GUI & export module using the mire1.cr2 from integration test.

Standard export:

mire1

Using export profil Rec2020 PQ RGB (too dark?):

mire1_01

From Darkroom, using display profil:

sRGB Rec2020 PQ RGB
image image

Note that when Rec2020 PQ RGB is used as display profil, the thumb in the filmstrip for the edited image match the one on display but all others are way too light:

image

@nicknezis

Copy link
Copy Markdown
Author

I have tested using the GUI & export module using the mire1.cr2 from integration test.

Thanks for testing, and for checking the OpenCL path.

PQ export looking too dark. Your PQ export (mire1_01) looks like PQ code values shown as if they were SDR: grey whites, lifted blacks, low saturation. That is what a PQ JPEG looks like in a browser or any viewer that doesn't decode PQ, because PQ puts reference white (SDR 100%) at 203 nits, about 58% of the signal, and the Rec2020 primaries aren't converted either. JPEG has no CICP tag, and darktable's PQ ICC profile has no cicp tag, so a viewer has nothing telling it the file is HDR. I get the same look from my own PQ export of mire1.cr2 with its code values written straight into an sRGB PNG. A PNG, AVIF, JXL or HEIF export, which carry CICP, opened in an HDR-aware viewer should look like the sRGB export with brighter highlights.

This PR doesn't make it darker; it only adds range above reference white. mire1.cr2 with the default sigmoid workflow and no edits, on master and on this branch:

median 99th percentile max
master, PQ Rec2020 67 nits 138 nits 196 nits
this PR, PQ Rec2020 71 nits 209 nits 711 nits
both, sRGB (identical) code 0.61 code 0.84 code 0.98

Shown as SDR, master's PQ export looks the same, very slightly dimmer (mean code 0.418 vs 0.431).

Darkroom with the PQ display profile. Same cause: darktable sends PQ codes to a screen that isn't in PQ mode, so the center view looks like your PQ export. Master does the same.

Filmstrip thumbnails too light. This one isn't from this PR; it comes from #20562's fix (4b0878d). Since then the pipe encodes PQ with 1.0 = 203 nits (colorout and iop_profile.c use dt_hdr_encode()). The thumbnail cache, however, is stored in AdobeRGB (dt_mipmap_cache_get_colorspace(), cache_color_managed is on by default), and dt_view_image_get_surface() converts it to the display with LittleCMS through the PQ ICC profile, whose curve (_PQ_fct() in colorspaces.c) still maps 1.0 to 10000 nits. Mid gray then lands at PQ code 0.82 instead of 0.41, and white at 1.0 instead of 0.58. I haven't traced why the edited image's own thumbnail escapes this. HLG display profiles probably have the same mismatch. It's outside this PR, since it's in the display transforms and not the tone mappers, but I'm happy to fix it in a separate PR if you'd like.

@TurboGit

Copy link
Copy Markdown
Member

It's outside this PR, since it's in the display transforms and not the tone mappers, but I'm happy to fix it in a separate PR if you'd like.

Would be great to fix that in a follow-up PR. Thanks.

@TurboGit

Copy link
Copy Markdown
Member

To me this is good to go, @kofa73 ok on your side or do you see something still broken on AgX?

@kofa73

kofa73 commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator

No objections from me.

@TurboGit TurboGit left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

@TurboGit
TurboGit merged commit 2af6857 into darktable-org:master Oct 21, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature: new new features to add priority: medium core features are degraded in a way that is still mostly usable, software stutters scope: image processing correcting pixels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Output headroom: let tone mappers take their peak from the output

6 participants