darktable-org / darktable-org/darktable

_auto_set_exposure() is manual-mode logic kept out of automatic mode by a reset call, not a guard

Open Beginner friendly
#22,184 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

priority: low
Dominant language
C
Stars
13.1k
Forks
1.4k
Avg merge
22h 14m
Merged PRs (30d)
198

Description

Found by code analysis, 2026-09-02. Split out of the #21974 Defect 3 work.

Updated 2026-09-06 against branch fix-21974-applied-exposure-disagreement at
11093f0767b118d8f046562026e37a29dec8aacf; all line references below are that HEAD.

Severity: low. Reachable, but only through a user-assigned shortcut. The sibling
entry points were given an explicit mode guard by the #21974 Defect 3 fix; this one
was left relying on a behavioural assumption.

Note on wording: the mode is EXPOSURE_MODE_DEFLICKER in the source, but the
combobox shows it as automatic (exposure.c:52). This report says "automatic".

Summary

_auto_set_exposure() (src/iop/exposure.c:918) is manual-mode logic. Both its
branches assume the pipe applies the manual composition:

  if(mode == DT_SPOT_MODE_MEASURE)
  {
    const float exposure_adjustment = _total_adjustment_ev(self, p);  // :957
    ...                                                               // displays it in the GUI
  }
  else if(mode == DT_SPOT_MODE_CORRECT)
  {
    _exposure_set_white(self, XYZ[1] / XYZ_target[1]);                // :994  writes p->exposure
  }

In automatic mode neither holds. _process_common_setup() (:476-517) replaces the
committed exposure with the histogram-derived correction, so p->exposure is inert:
MEASURE would display a number the pipe does not apply, and CORRECT would write a
parameter the pipe ignores while pushing a history item for it.

Nothing in _auto_set_exposure() tests p->mode.

Why the widgets are reachable

The controls that drive it are packed outside g->mode_stack, so they stay
visible when the stack shows the "deflicker" page (gui_init()):

  self->widget = dt_gui_vbox();                              // :1310  outer box
  g->mode = dt_bauhaus_combobox_from_params(self, N_("mode"));
  dt_gui_box_add(self->widget, g->mode_stack);               // :1314

  g->black = dt_bauhaus_slider_from_params(self, "black");   // :1316  outside the stack
  dt_gui_new_collapsible_section(&g->cs, ...);               // :1326  outside the stack

whereas g->exposure and the two compensation toggles are inside vbox_manual
(:1252-1280), and therefore hidden in automatic mode.

So in automatic mode the user is shown an "area exposure mapping" section: spot mode
combobox, lightness slider, origin/target swatches, all belonging to a mode they are
not in.

The behavioural chain that normally keeps it out of automatic mode

Both call sites into _auto_set_exposure() are gated on the picker attached to
g->exposure being active:

void color_picker_apply(...)                                  // :999
{ DT_GUARD_GUI_UPDATE(); _auto_set_exposure(self, pipe); }    // :1004, driven by the picker

static void _spot_settings_changed_callback(...)              // :1213
{
  ...
  if(mode == DT_SPOT_MODE_CORRECT && dt_iop_color_picker_is_active(g->exposure))
    _auto_set_exposure(self, darktable.develop->full.pipe);   // :1234
}

and entering automatic mode deactivates that picker, from both directions:

static void _autoexp_disable(dt_iop_module_t *self)           // :690
{ dt_iop_color_picker_reset(self, TRUE); }

  gui_update():  case EXPOSURE_MODE_DEFLICKER: _autoexp_disable(self);   // :755
  gui_changed(): case EXPOSURE_MODE_DEFLICKER: _autoexp_disable(self);   // :1036

The keep = TRUE argument matters: dt_iop_color_picker_reset()
(src/gui/color_picker_proxy.c:122-136) skips the reset when the picker widget is
named "keep-active". exposure.c never sets that name; only colorequal.c:3798,
colorzones.c:2633, rgbcurve.c:1495 and blend_gui.c:2610 do, so the reset does
fire.

So the chain is:

  1. every path into automatic mode calls _autoexp_disable();
  2. the picker is not "keep-active", so the reset is not skipped;
  3. the picker cannot be re-armed while its widget is hidden.

That is a behavioural chain, not a guard, and it was not written to be one.

Reachability: step 3 does not hold

Step 3 was previously untraced. It is false. darktable's shortcut system can
dispatch to a widget on a non-selected GtkStack page, and g->exposure carries a
registered shortcut (GDK_KEY_e, exposure.c:1280), so its quad, i.e. the picker
toggle, can be given a shortcut by the user and pressed in automatic mode.

  1. shortcut dispatch accepts a widget that passes dt_action_widget_invisible()
    (src/gui/accelerators.c:4123)
  2. that predicate reads only the visible property of the widget and of its
    immediate parent (accelerators.c:3810-3817). It does not look at the stack's
    selected page
  3. GtkStack hides the unselected page with gtk_widget_set_child_visible()
    (gtk/gtkstack.c:1118, GTK 3.24.52), which does not clear the visible
    property. So the predicate reports the hidden slider as visible
  4. the slider's button element goes to _action_process_button()
    (src/bauhaus/bauhaus.c:4269, body at :4195), which presses and releases the
    quad and emits "quad-pressed" (bauhaus.c:1616, signal defined at :4139)
  5. that signal is connected to the colour picker
    (src/gui/color_picker_proxy.c:549). Activation installs the picker proxy and
    sets request_color_pick (color_picker_proxy.c:185), with no test of exposure's
    mode
  6. incoming samples then reach the module through
    module->color_picker_apply() (color_picker_proxy.c:426), i.e.
    _auto_set_exposure()

The default bare e shortcut targets the slider's value element, not the button, so
this needs a shortcut the user assigned to the button/quad element. That is the only
thing keeping the severity low. Not reproduced interactively: the chain above is
read off the source, not observed in a GTK session.

Suggested fix

Return early from _auto_set_exposure() unless p->mode == EXPOSURE_MODE_MANUAL.
One line, and it makes the precondition local to the function that depends on it
instead of distributed across two mode-switch handlers.

Optionally, and separately, hide the "area exposure mapping" section in automatic
mode by moving g->cs inside vbox_manual, or by toggling its sensitivity from the
g->mode branch of gui_changed(). That is a UX question, not a correctness one,
and it changes the panel layout, so it is worth deciding on its own rather than
bundling.

Do not re-derive _auto_set_exposure()'s sign conventions while fixing this. The
double negation that looks wrong was cleared as a negative result while investigating
potential issues found durint the #21974 fix,
and re-confirmed on 2026-09-02 when the #21974 Defect 3 fix replaced the CORRECT
branch's twelve hand-rolled lines with _exposure_set_white(self, XYZ[1] / XYZ_target[1]).

That equivalence holds only for a positive luminance ratio. The old round trip
through white2exposure() and exposure2white() also clamped a non-positive ratio
to a small positive white before it reached the black comparison inside
_exposure_set_white(); the short form does not.

Dependencies and neighbours

  • #21974, Defect 3: depends on it, do not land before. That fix gave the two
    sibling clamps in gui_changed() an explicit p->mode == EXPOSURE_MODE_MANUAL
    guard, for exactly this reason: g->black sits outside the mode stack, so its
    clamp did run in automatic mode against a manual white point.
    _auto_set_exposure() is the remaining unguarded entry point of that same set. As
    of 2026-09-06 Defect 3 is committed on branch
    fix-21974-applied-exposure-disagreement, not merged.
    A mode guard here is nevertheless valid on its own: it does not need the new
    compensation helpers.
  • #22008: sibling, no code overlap. Same shape one layer up: the exposure
    proxy's one write entry point (dt_dev_exposure_handle_event()) lacks the view
    guard its three read siblings have. If the two are fixed together, the shared
    lesson is worth stating once: in this module the read paths have been audited
    repeatedly, and the write paths keep turning out to be missing a precondition.
  • #22183: same function, different defect.
    Both branches model the pipe as a pure gain and ignore the black point, in manual
    mode, where this report's guard does not apply. Previously mentioned here as an
    unfiled aside; it now has its own record.
  • same function, branch regression.
    A target lightness of 0 lets the CORRECT branch write an extreme negative black.
    That one has to be fixed on the #21974 branch; this one does not.

Environment

Present in 54c024cc5a and unchanged in behaviour by #21974. Static
analysis only: no display in this container, nothing verified at runtime.
The GTK
and bauhaus line references were read in this checkout and in
gtk-3.24.52.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in src/iop/exposure.c at _auto_set_exposure() and inspect its callers and the p->mode handling described in the issue. Add the local manual-mode precondition without changing the manual branches, then verify that automatic-mode picker activation no longer reaches this logic; account for dependency #21974 before landing.

Written by the indexing model from the issue text.

Assessment

Tech stack
c
Domain
desktop
Issue type
Bug
Difficulty
2/5
Estimated time
1-3 hours
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
75/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.