Fix 21974 applied exposure disagreement - #22185
Conversation
…ED if deflicker fails
…arify spot gain maths
The area exposure mapping "correct" branch passed the measured luminance ratio straight to _exposure_set_white(). A target lightness of 0 is inside the lightness slider's range, and dt_Lab_to_XYZ() maps it back to a Y that is either exactly zero or, when the compiler contracts 116 * x - 16 into an FMA, slightly negative. The ratio was then infinite, or large and negative. Neither is a white point. white2exposure() floors its argument at 1e-20, so the stored exposure became about +66 EV, far outside the parameter's declared range of -18 to +18 EV, and a history item was recorded for it. A negative ratio also passed the black comparison inside the setter, which then stored it as the black point, about -3.8e9; returning the target to a usable value repaired the exposure but not the black, leaving the image white. That second half only became reachable when the round trip through white2exposure()/exposure2white(), which used to clamp the ratio to a small positive number on the way, was removed earlier in this series. Decline the correction when either luminance is at or below 1e-5, so that no parameter is written and no history item is pushed. The threshold sits below the finest value a raw can carry, since rawprepare normalizes to the raw white point and a 16-bit raw therefore quantizes at 1.53e-5, everything else being coarser. It also keeps every accepted match inside the declared exposure range: a sample at 1e-5 needs 16.6 EV to reach the brightest possible target.
|
@kofa73 : I'm a human :) And frankly such a long report with AI wording is not helping me. I'm ok to have this as a "long version" of the report but a self contain description should be first and written for a human. I suppose that the text above is what the AI has written, fine, but to me this is for the dev to asses the validity of the fix. |
|
The TL;DR was me, with my own fingers. :-D |
|
Expanded the TL;DR. I think it has enough details now. If wording is unclear, let me know. The following is a better-worded AI summary, but it covers the same: The exposure proxy cached a value written by a pipe thread and read by the This PR removes that cache and fixes three related problems in exposure. Changes
Compatibility and remaining limitsNo parameter layout or module version changes. Existing valid edits retain The black-point checks run in GUI callbacks. They do not repair invalid |
|
Merged manually with conflict resolutions. |
|
Thanks. I'll be more considerate with the PR message next time. |
exposure: use one definition of the exposure the module applies
Fixes #21974.
TL;DR:
_exposure_proxy_get_effective_exposure(^^^^^ That's not AI, it's my genuine, natural stupidity and bad writing ^^^^^)
ˇˇˇˇˇ AI from here on ˇˇˇˇˇ
Background
The
exposuremodule does not apply the exposure slider alone. It applies theslider plus two corrections read from EXIF:
compensate camera exposure: the exposure correction the user dialed into thecamera (clamped to -5 .. +5 EV)
compensate camera highlight preservation: the underexposure the cameraapplied by itself in an HDR / DR-boost / HLG mode (clamped to 0 .. +4 EV, on by
default for the first instance of the module)
The sum is what
commit_params()stores and whatprocess()turns intoblackand
scale. This PR calls that sum the total adjustment.Three places in the module used some other value where the total adjustment was
meant. This PR gives the module a single definition of it and routes every user
of it through that definition. Sharing that definition also exposed a fourth
defect, in the area exposure mapping tool, which is fixed here as well.
What was fixed
1. The shared
effective_exposurefield: data race and stale valueThis is the defect the issue was opened for.
commit_params()cached the applied exposure in a plainfloatinsidegui_data, from a pipe thread, with no lock.dt_dev_exposure_get_effective_exposure()read the same field from the GTKthread, also with no lock and with no NULL check on
gui_data.agxreads itin its "read exposure" button and writes the result into history.
Locking it would have been the wrong fix. The value was only ever produced by
commit_params(), so a caller asking before the pipe had re-committed got theprevious exposure. A lock removes the undefined behaviour and keeps the wrong
answer.
The field is now gone. In manual mode the accessor derives the value from the
parameters, on the caller's thread, exactly like the neighbouring
_exposure_proxy_get_exposure()and_exposure_proxy_get_black()already did.There is no shared field left to race on.
Automatic (deflicker) mode is the exception: its correction is computed from the
raw histogram inside the pipe, so it has to be published rather than derived.
That branch now takes the module's GUI lock, checks
gui_datafor NULL, andreturns 0 EV while the value is still undefined, which is the case before the
preview pipe has run once. Before this PR the accessor returned the manual
composition in automatic mode as well, which is a quantity the module never
applied there.
The thread contract of the whole proxy is now written down on
dt_dev_proxy_exposure_tindevelop.h: these accessors read live GTK-ownedstate, so they may only be called from the GTK thread.
2. Automatic mode rendered a fully black image when the histogram was missing
_compute_correction()(renamed to_compute_deflicker_correction()) wrote its"undefined" sentinel,
-FLT_MAX, before its early return. The caller hadalready seeded the same variable with the manual fallback, so the sentinel
destroyed it. The pipe then computed
white = exp2f(FLT_MAX), which isinfinity, so
scale = 1 / (inf - black) = 0and every output pixel was 0.This is deterministic, not a race:
_deflicker_prepare_histogram()returnswithout a histogram when the image is not single-channel
TYPE_UINT16, or whenthe raw buffer cannot be fetched. In that state automatic mode rendered black on
every pipe run.
The function now leaves the caller's value alone when it cannot compute
anything. The caller seeds it with the manual composition, and that same value
is both applied by the pipe and published to the proxy, so the two agree on what
the fallback was.
3. The black point was limited against the wrong white point, which could invert the image
The GUI keeps
blackbelow the white point so thatwhite - black, andtherefore
scale, stays positive. All the limit checks compared against thewhite point derived from the raw slider value, while the pipe derives the
white point from the total adjustment. The two differ by the compensations,
so by up to 9 EV.
This was reachable with ordinary slider positions, and highlight-preservation
compensation is on by default. Reproduced in the GUI: camera exposure bias -2 EV
compensated, exposure slider +4 EV, so the pipe applies +6 EV and the real white
point is 2^-6 = 0.0156. Moving
blackto 0.0160 passed the old check, because0.0160 < 2^-4 = 0.0625, and produced
scale = 1 / (0.0156 - 0.0160) = -2667:the image inverts.
black 0.0150, before the fix

black 0.0160, before the fix

With the fix, moving

blackto 0.0160 pulls the exposure slider down to+3.265 EV, so the applied total is +5.265 EV and the white point stays above
black:
What changed in the code:
p->blackagainstexposure2white(_total_adjustment_ev(self, p)), which is the white point thepipe will actually use
_exposure_set_white()converts back through_required_exposure_slider_ev(), so writing the slider really produces therequested white point instead of missing it by the compensation offset
module returns to manual mode. The second case matters because black can be
edited in automatic mode, where the manual exposure is not applied, so a
configuration that was valid in automatic mode can be invalid the moment
manual mode comes back
hidden but still reachable through keyboard shortcuts, and a white point that
the pipe does not use must not move the black point that it does use
4. Area exposure mapping could store an out-of-range exposure
With the area picker active in "correct" mode, moving the target lightness to 0
made the module store an exposure of about +66 EV. The parameter's declared
range is -18 .. +18 EV, the slider cannot show such a value, and a history item
was recorded for it, so the image blew out until the target was set back.
The tool divides the sampled luminance by the target luminance to get the white
point. A target lightness of 0 is inside the lightness slider's range, and
dt_Lab_to_XYZ()converts it back to a Y of either exactly zero or, where thecompiler contracts
116 * x - 16into an FMA, slightly negative. The ratio isthen infinite or large and negative, and
white2exposure()floors its argumentat 1e-20, which is where +66 EV comes from.
The module now declines the correction when the sampled or the target luminance
is at or below 1e-5: no parameter is written and no history item is pushed.
Two separate reasons put the threshold at 1e-5 rather than at zero:
rawprepareemits(in - black) / (white - black), so the raw white point becomes 1.0 and thequantization step is
1 / (white - black): 1.53e-5 for a 16-bit raw with azero black level, and coarser for everything else, such as 7.0e-5 for a
14-bit raw with a black level of 2048. White balance multiplies by
coefficients of order 1, and the camera matrix is normalized so that neutral
maps to neutral, so the sampled luminance stays in that same scale
+16.6 EV to reach the brightest possible target and +14.2 EV to reach
mid-gray, both inside the declared range. At 1e-6 the same match would need
19.9 EV, so the module would store an exposure outside its own limits again
A note for anyone bisecting this series: the simplification described below
removed a round trip that had incidentally clamped the ratio to a small positive
number, so between
9195d35176and362a3b8288a negative ratio was alsostored as the black point, at about -3.8e9, which survived setting the target
back. Both halves are closed by the same check.
Smaller changes in the same commits
exposure.c, allstatic inline:_exposure_compensation_ev()(the two EXIF corrections),_total_adjustment_ev()(slider plus corrections) and_required_exposure_slider_ev()(the inverse, used when the GUI knows thewhite point it wants and has to write the slider)
arithmetic and then apply it as
exposure2white(-expo), which isexp2f(expo)written in a confusing way. It now calls the shared helper andmultiplies by a plain gain
round trip through
white2exposure/exposure2white. It now passes themeasured ratio straight to
_exposure_set_white(), which does the inverseconversion in one place, guarded by the luminance check from section 4
_compute_correction()is now_compute_deflicker_correction(), US spelling fixes in the two comments thatwere touched anyway, and a note explaining what
whitemeans inexposure2whiteBehaviour, and compatibility with existing edits
mode when the histogram is available:
d->params.exposure,d->blackandd->scaleare computed exactly as beforelegacy_params()workalready in the invalid state does not rewrite it, so old images still render
as they did. Making the pipe itself reject an invalid transform is a separate
decision, see the follow-ups
automatic mode with an unusable raw histogram renders the manual fallback
instead of a black frame;
agx's "read exposure" gets the correct value inautomatic mode; setting the area mapping target lightness to 0 now does
nothing instead of writing an exposure far outside the parameter's declared
range of -18 .. +18 EV and pushing a history item for it
Files touched
src/iop/exposure.c: all of the abovesrc/develop/develop.h: the thread contract ondt_dev_proxy_exposure_tandon the four accessors, plus a note about the one known violation
RELEASE_NOTES.mdNot fixed here
Reviewing this branch turned up more problems in the same area. All of them
exist on master today, none is a regression from this PR, and each one needs a
decision that does not belong in this change.
Filed:
channelmixerrgb: the colour-checker profiler inverts the exposure transform wrongly, from the wrong thread #22005:channelmixerrgb's ColorChecker profiler reads exposure'sGTK-owned parameters from the preview pipe thread, and inverts the exposure
transform with the wrong quantity and the wrong formula. Only marked with a
TODOhereexposure:deflicker_histogramis freed by the GTK thread while a pipe thread walks it #22006: the deflicker histogram is freed and reallocated by the GTK threadwhile a pipe thread is reading it
exposure:_show_computed()idle source can outlive the module #22007: the idle source that shows the computed correction can outlive themodule; teardown removes one source, but one is queued per preview pipe run
dt_dev_exposure_handle_event()has no darkroom guard, unlike its three sibling getters #22008:dt_dev_exposure_handle_event()has no darkroom guard, unlike thethree getters next to it, and it is the only one that writes parameters
agx: "read exposure" leaves a stalecurve_gammain history #22009:agxleavescurve_gammastale in history after its "readexposure" button
GUI refresh that is not an edit, for example opening the blending "display
output channels" menu, resets it, and changing the target level or percentile
does not invalidate it.
agxcan then store range values that belong to adifferent correction. A lock does not fix this; the published result needs to
say which settings it describes
white - blackat all. GUI limits cannotcover automatic mode, pasted or imported history, or edits made before this
PR. A guard there changes how existing edits render, so it needs an explicit
compatibility decision
ignores
black, in both measurement and correction, while the pipe applies(in - black) * scale_auto_set_exposure()(the area exposure mapping tool) ismanual-mode logic with no mode check. Normal mode changes reset the picker,
but a user-assigned shortcut can re-activate it in automatic mode, where it
would report and write values the pipe ignores
Open point
The comment in
develop.hrefers todev-doc/GUI_Threading.md, which is addedby #21912 and is still a draft.
What was tested
built on its own, so
git bisectstays usable0001-exposureintegration image, with its original version-5 manual historyand with a version-7 automatic-mode variant, were compared between the branch
point and the branch: ImageMagick reports 0 differing pixels for both
exposure.cand callsthe real
commit_params()andprocess()covered 6,722 valid manualparameter combinations: both compensation switches, EXIF bias -5 .. +5 EV,
highlight preservation 0/1/2/4 EV, exposure -18 .. +18 EV, and negative, zero
and positive black. No parameter bytes were rewritten. Of 26,888 output
floats, 76 differed, by at most 1.19e-07 absolute and 2.11e-07 relative, which
is the reassociation of the same arithmetic, not a behaviour change
gone after it, with the settings and screenshots above. The same case in the
harness:
scalegoes from -2666.66 to +100, with the slider moved to+3.26534 EV
module's real
color_picker_apply(). With a sample luminance of 0.25 and thetarget moved to 0 and then back to 0.5, the branch point stored +66.4386 EV and
one history item at the zero target; the branch stores nothing and pushes no
history item, and the restored target gives the correct 0.5 at the sample on
both. Between
9195d35176and362a3b8288the same sequence also leftblackat -3.7887e9 and the sample at 1.0histogram and a +2 EV manual fallback,
scalewas 0 and the output 0 beforethe fix; after it,
scaleis 4, an input of 0.18 becomes 0.72, and the proxyreports +2 EV, so the pipe and the proxy agree. Not reproduced in a GUI
session, which would need a raw whose histogram cannot be built
not something a test run shows
opencv,numpyorcolour-sciencefor its comparison script. The export comparisonabove uses the same image but compares the two revisions against each other,
not against the suite's stored reference
process_cl()was not exercised: OpenCL initialisation finds 0 platformshere, and an export that requested OpenCL fell back to the CPU. The OpenCL
path is unchanged and consumes the same
d->blackandd->scalethat the CPUpath does, both produced by the shared
_process_common_setup(); the kernelarithmetic in
data/kernels/basic.cl:241is untouchedand a build with OpenCL disabled at configure time
PR written by Claude Code.