Skip to content

IOP Color EQ : Add interactive editing mode for color adjustments in image - #21397

Merged
TurboGit merged 4 commits into
darktable-org:masterfrom
Christian-Bouhon:colorequal
Aug 22, 2026
Merged

IOP Color EQ : Add interactive editing mode for color adjustments in image#21397
TurboGit merged 4 commits into
darktable-org:masterfrom
Christian-Bouhon:colorequal

Conversation

@Christian-Bouhon

Copy link
Copy Markdown
Contributor

Hello,

Here’s an old idea that’s resurfaced in my mind: an adaptation of the color equalizer module inspired by the tone equalizer module.

I’ve implemented the ability to correct a color by selecting it directly on the photo, on the fly. Needless to say, the AI has been a big help.

New Features

  • mouse_moved() – reads the UCS hue from the preview pipe and converts it to GUI degrees
  • mouse_leave() – invalidates the hue when the mouse leaves the image
  • gui_post_expose() – draws a color circle at the cursor (black + RGB fill)
  • scrolled() – scroll wheel on the image → applies a Gaussian weighting (σ=35°) to the nodes of the active channel, locks zooming with return 1
  • _area_scrolled_callback() rewritten: dual mode (classic if no picker, Gaussian otherwise)
  • _get_param_ptr() – direct access to parameters via offsetof(), used by the Gaussian loop

Here is the forum discussion on pixls.us; I haven’t changed the user interface,

Greetings from the Luberon,
Christian

@Christian-Bouhon Christian-Bouhon changed the title Add interactive editing mode for hue adjustments in image IOP Color EQ : Add interactive editing mode for color adjustments in image Jun 22, 2026
@jenshannoschwalm

Copy link
Copy Markdown
Collaborator

Hmm, just did a first test.

This does work only when used with "default scheduling profile" (this is very likely just one trigger for the issue), otherwise code after

  if(!g->cursor_valid) return 0; // no valid hue: normal zoom

is never reached.

So how is g->cursor_valid is set to TRUE?

  1. You test for preview dimensions, this seems to be not correct to me as we can easily modify data effecting hue by any shortcut means in other modules before CE. I think you have to protect data via the color equalizer piece hash. Or the valid gboolean could be the hash and tested for identity.
  2. So there remains the problems of preview pipe data not available - current dt pixelpipe code does not ensure that. Not sure how to treat this "best way".

@Christian-Bouhon

Copy link
Copy Markdown
Contributor Author

Hmm, I think you're right. 😉
Thanks for your analysis.
I think I have a solution to the problem you brought up; I'll test it and get back to you.

@Christian-Bouhon

Christian-Bouhon commented Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

Hello,
Thank you very much for your careful review, your remarks were spot on and led to real improvements.
Here is a summary of what was addressed:

  1. preview_pipe_hash is stored in process() via dt_dev_pixelpipe_piece_hash(piece, roi, TRUE) right after filling the hue buffer (same pattern as toneequal.c).
  2. mouse_moved() now sets cursor_valid = TRUE only when the hash matches — exactly as you suggested. The GUI indicator (gui_post_expose) and the graph's Gaussian mode (_area_scrolled_callback) gate on cursor_valid, so they never see stale data.
  3. scrolled() reads the hue directly from the cached buffer (under the critical section) instead of calling mouse_moved(), to avoid falling through to image zoom when the hash doesn't match. Scroll-based adjustment tolerates slightly stale data gracefully.
  4. _area_scrolled_callback() checks preview_hue_buf != NULL (buffer existence) rather than cursor_valid for the Gaussian/single-node decision, so the graph remains usable even when the hash is stale.
  5. Hash is reset to DT_INVALID_HASH in gui_init()gui_focus() (on focus loss), and the critical section protects the buffer read against the use-after-free race.

Bottom line: non-default scheduling profiles are now safe, image zoom on scroll is never triggered, and the graph keeps its node-weighted behavior.

Thanks again for taking the time, this is a much more solid implementation because of your feedback.
Greetings from the Luberon,
Christian

@jenshannoschwalm

Copy link
Copy Markdown
Collaborator

I did some fresh testing, i think it works now as you intended! The way you change the nodes seems good to me too.

Don't take me wrong but personally i don't think the strategy "if mouse is in main canvas use scrolling for CE changes" is a good one (knowing hat TE also does it like this ...) . One simple reason is, i miss zooming in/out using the scroll wheel a lot.

I would prefer it to be bound to the picker. That would also mean,

  1. the "position-for-color-change" would follow any drag, rotate, zoom ...
  2. would correspond to positioning of the hue hint
  3. just take hue data from picker data instead of the hue buffer.

@TurboGit

Copy link
Copy Markdown
Member

One simple reason is, i miss zooming in/out using the scroll wheel a lot.

You can press 'a' while zooming/panning to pass the events to the canvas. But you certainly know that.

@Christian-Bouhon

Copy link
Copy Markdown
Contributor Author

Hello,
Thank you for your feedback. It is certainly possible to add a second button to enable or disable the mouse's interactive mode.
Personally, I prefer to keep the same mode as in TE, but if you'd like, I can add this feature.
Capture d’écran du 2026-06-26 16-39-17
Greetings from the Luberon, 🌿
Christian

@jenshannoschwalm

Copy link
Copy Markdown
Collaborator

I dont have a strong opinion. I thought, if the picker is active, that could be checked and your modify-nodes from hue could step in. No?

@jenshannoschwalm

Copy link
Copy Markdown
Collaborator

There is some irritating effect.

  1. Use an image with a deep-red part and drastically change hue for visibility until it gets greenish.
  2. Then use CE at the part origin-red-now-greenish.
    a) Your algo finds red and corrects the nodes accordingly but the color circle is wrong/misleading (greenish)
    b) when using the picker the little dot is also wrongly colored but at least the "marker" in the CE module part is right.

I think we need some marker as it would be very surprising if you hover over green but change reds. Another argument to go via the picker.

The TE has a much easier job here going for blurred luminance.

@Christian-Bouhon

Copy link
Copy Markdown
Contributor Author

Hello,

A new button has been added to allow direct adjustments to the image when it is activated.
The module’s two color pickers automatically exclude each other: if one is activated, the other is immediately deactivated, thanks to a complete reset of the color picker’s internal state (dt_iop_color_picker_reset) and a delay (g_idle_add) in the interactive button’s state change to prevent re-entry into the GTK signal chain.

If this doesn’t work for you, I can always revert the changes with git reset --hard HEAD~1. 😉

Friendly regards,
Christian

@jenshannoschwalm

Copy link
Copy Markdown
Collaborator

Let me show you an example:

  1. Base image
Bildschirmfoto vom 2026-06-26 18-31-31
  1. Modify by using color equalizer (but you could choose any other module changing hue for this ...)
Bildschirmfoto vom 2026-06-26 18-32-25
  1. Now you see two greenish parts of the image and you want the lower part to shift to yellow. The old picker at least told you
Bildschirmfoto vom 2026-06-26 18-36-57

we are processing "red" at this location. (Yes the little color dot is wrong too :-)

Maybe it's just too hot today and i should stop thinking here but it seems to me we need some sort of information about the "color-of-CE-input".
(There are some minor issues with picker inter-dependencies btw)

@TurboGit

Copy link
Copy Markdown
Member

This does not work on my side. I have clicked on the picker with the +:

image

When I hover over the image and use the mouse wheel it just zoom as if the picker was not selected.

BTW, we need to work on consistency here. The Tone Equalizer does not require a picker. Having different way of interacting with a module for the same kind of action is not good to me.

@Christian-Bouhon

Copy link
Copy Markdown
Contributor Author

Hello,

You're right, it isn't very stable. I've reverted to the previous version, which is consistent with TE.
For this kind of test, I use a test pattern I created in Inkscape; each square has a solid color because, with a gradient, it's difficult to match the exact same color.
Here's a short video.

2026-06-27.13-34-52.mp4

A little fresh air from the Luberon, 🍷
Christian

@jenshannoschwalm

Copy link
Copy Markdown
Collaborator

Please shar your test-image ...

@jenshannoschwalm

Copy link
Copy Markdown
Collaborator

@Christian-Bouhon after commenting in #20626 about dt caching i thought again about your code here (and why i don't like the way of caching at all).

It simply should not be necessary in the vast majority of use cases as a) all you do here only happens if the CE moduly is the active module and b) we already make sure the input data of the active module is available in the pipe cache for CPU and OpenCL code.

So we just need some extra pixelpipe stuff to get access to preview pipe input (and possibly output) data of the active module. that might help in a lot of situations, here for your new feature but also for in-module histograms or stats... I would implement that.

@Christian-Bouhon

Copy link
Copy Markdown
Contributor Author

Please shar your test-image ...
Hello,
You can find them here: https://github.com/Christian-Bouhon/test_chart.
If you prefer TIFF files, I can upload them to Google Drive.
Greetings from the Luberon 🇫🇷
Christian

@Christian-Bouhon

Copy link
Copy Markdown
Contributor Author

@Christian-Bouhon after commenting in #20626 about dt caching i thought again about your code here (and why i don't like the way of caching at all).

It simply should not be necessary in the vast majority of use cases as a) all you do here only happens if the CE moduly is the active module and b) we already make sure the input data of the active module is available in the pipe cache for CPU and OpenCL code.

So we just need some extra pixelpipe stuff to get access to preview pipe input (and possibly output) data of the active module. that might help in a lot of situations, here for your new feature but also for in-module histograms or stats... I would implement that.

Hi,
Thank you very much for your suggestion; I really appreciate that you took the time to look into this. This approach makes perfect sense, but refactoring the Pixelpipe infrastructure to expose the cached input data is far beyond my current skills and my understanding of how darktable works internally. For now, I’d prefer to stick with the current approach, which is functional and, above all, simpler for me.
If you can take care of this, I can only thank you for your help.

Best regards,
Christian

@Christian-Bouhon
Christian-Bouhon marked this pull request as draft June 30, 2026 06:23
@Christian-Bouhon

Copy link
Copy Markdown
Contributor Author

Hello,

I saved this as a draft because I’ve received feedback from users of my experimental fork saying that the module is no longer working as it did before.
https://discuss.pixls.us/t/poc-introduction-to-a-new-experimental-module-3d-colorimetric-film-3dcf/58009/92

There are two possible causes, in order:

  1. Code cleanup for this PR
  2. This latest change.

I’ll look into it and get back to you.

Greetings from the Luberon,
Christian

@jenshannoschwalm

Copy link
Copy Markdown
Collaborator

How does the preview pipe write the cache if processed on GPU?

@Christian-Bouhon

Copy link
Copy Markdown
Contributor Author

How does the preview pipe write the cache if processed on GPU?

Hello,

You're right, it wasn't writing it, that was the bug. With the latest commit, the module is working.
When the preview pipe runs on GPU (process_cl), we read pixout (which holds the original uncorrected HSB) back from device memory to host after process_data, extract the hue channel, and populate preview_hue_buf, same result as the CPU path, just with a synchronous GPU > CPU readback
Greetings from the Luberon,
Christian

@Christian-Bouhon
Christian-Bouhon marked this pull request as ready for review June 30, 2026 18:26
@jenshannoschwalm

Copy link
Copy Markdown
Collaborator

Just being curious; is there a good reason to use data after processing the module? Changing the hue at a certain location results in slightly different access to nodes

@Christian-Bouhon

Copy link
Copy Markdown
Contributor Author

hello,
If I understand the question correctly, and unless I'm mistaken, the original color value is already stored in pixout[k].x before any corrections are applied; the kernel first fills pixout with the raw HSB data, then writes the corrections to a separate buffer. We read pixout immediately after the process_data function; this is the same original hue as the one captured by the CPU path between steps 3 and 5. The post-correction hue is never read.
Have a nice day,
Christian

@jenshannoschwalm

Copy link
Copy Markdown
Collaborator

Indeed, from reading the diffs i spotted it wrongly.

@Christian-Bouhon

Copy link
Copy Markdown
Contributor Author

Hello,
Thanks for the review @TurboGit : all three points are addressed:

  1. Busy cursor rotating on scroll without moving
    the busy/wait cursor
    is gone. Both modules now hide the native cursor whenever a valid reading
    is displayed, whether or not the preview pipe is still recomputing.

  2. No vertical line indicator on the graph
    the color equalizer graph
    now draws a white vertical line at the hue currently under the mouse
    cursor on the image (follows hue_shift, same idea as the tone
    equalizer's exposure line).

  3. Wedge locked at 45°
    the shared dt_draw_correction_cursor() wedge
    now opens up to ±90°, clamped at |correction| = 1.

In addition, the cursor now shows the module input/output colors: the
preview-data service stores the input pixel's HSB (3 components per pixel,
CPU + OpenCL paths), and the outer circle shows the module input color while
the inner circle shows the output color obtained by replaying the exact
process() correction math. The tone equalizer's frame lines also now
sample the actual background (white over dark content, black over bright).

Greetings from the Lubéron, 🌿
Christian

…w-data service

Adds an on-canvas interactive editing mode driven by the hue under the
mouse cursor: hovering the image shows a color indicator and lets the
scroll wheel apply a Gaussian-weighted adjustment (sigma=35 degrees) to
the saturation/hue/brightness nodes of the active channel, Alt+scroll
switches channel tabs (on both the image and the graph), and the graph's
own scroll handling mirrors the same behavior for the node under the
cursor there.

The per-pixel hue buffer needed for this is served by a new shared
service, dt_preview_data_t (src/develop/preview_data.c/.h): resizing,
filling and hashing the preview-pipe buffer under a single GUI lock so
readers never observe a resized-but-unfilled buffer, plus freshness
checks against the pipe's cumulative hash. gui_focus()/mouse_moved() use
it to request a debounced preview reprocess so the indicator works right
after opening the module instead of only after an unrelated trigger.

The on-canvas cursor itself (crosshair, wedge, circles, text readout) is
factored into a shared dt_draw_correction_cursor() helper in
src/gui/draw.h and reused by the tone equalizer, so both modules render
the same cursor design and future modules can adopt it too.
- don't show a busy/wait cursor while the preview pipe recomputes during
  hover (toneequal: drop the busy branch in switch_cursors, gate the
  exposure re-read instead; colorequal: same in _switch_cursors)
- draw a white vertical line on the graph at the hue under the mouse
  cursor, mirroring the tone equalizer's exposure cursor line
- open the wedge cursor up to +-90 degrees (clamped at +-1) instead of
  clamping it to 45 degrees; toneequal pre-scales the correction by 0.5
  so the wedge reaches its full span at +-2 EV
- store the module input HSB (hue, saturation, brightness) instead of
  just the hue in the shared preview buffer (3 components per pixel) and
  show the module input/output colors in the cursor's two circles

This brings the branch in line with the review comment on PR darktable-org#21397 and
adds the in/out colors feature.
Like the color equalizer's cursor, sample the pixel under the cursor
from the preview pipe backbuf and draw the frame lines (wedge,
crosshair, outlines) white over dark content and black over bright
content.  This replaces _match_color_to_background(), which derived
grey shades from the estimated output exposure instead of the actual
background, so the lines could end up mid-grey.

The circles keep their before/after luminance grey shades.

@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.

Works for me, thanks!

A nice addition and the generic preview_data could be used in some other modules at some point. Nice!

@TurboGit
TurboGit merged commit 8a26f35 into darktable-org:master Aug 22, 2026
6 checks passed
@TurboGit

Copy link
Copy Markdown
Member

Last point, this needs a release note entry. TIA.

@TurboGit TurboGit added the documentation: pending a documentation work is required label Aug 22, 2026
@Christian-Bouhon
Christian-Bouhon deleted the colorequal branch August 22, 2026 11:57
Christian-Bouhon added a commit to Christian-Bouhon/darktable that referenced this pull request Aug 23, 2026
_switch_cursors() and switch_cursors() restored the default cursor with
a raw gdk_window_set_cursor() on the toplevel, while hiding it through
dt_control_change_cursor("none").  Going through the helper for both
halves brings back three things the raw call skips:

- The lock set by dt_control_forbid_change_cursor().  Shortcut-mapping
  mode and help mode (src/libs/tools/global_toolbox.c) hold that lock
  for as long as the mode is active, apply their own cursor to the same
  toplevel, and still forward motion events to the centre canvas -- so
  with the tone equalizer focused, moving the pointer over the canvas
  replaced their cursor with the plain arrow.  dt_gui_cursor_set_busy()
  takes the same lock.
- The -d input tracing in dt_control_cursor_debug(), which is called
  from the helpers only.
- The GTK3/GTK4 compatibility boundary.  gdk_window_set_cursor() has no
  GTK4 equivalent; these were its last uses outside src/gui/gtk.c.

Where the lock is not held the two forms are equivalent: on GTK3
dt_control_change_cursor() ends up calling gdk_cursor_new_from_name()
and gdk_window_set_cursor() on the same toplevel window.

For toneequal.c this restores what was there before 8a26f35
(darktable-org#21397); colorequal.c copied the pattern from it in the same commit.
The behaviour that commit intended -- no wait cursor while hovering,
whatever the pipe is doing -- is unchanged.

Built and verified at runtime on X11/GTK3.

Suggested-by: a darktable contributor
@Christian-Bouhon

Christian-Bouhon commented Aug 23, 2026

Copy link
Copy Markdown
Contributor Author

Last point, this needs a release note entry. TIA.

@TurboGit
Hello,
Thank you for your trust; I will draft a release note.
I made another small change to better modularize the graph so that it retains the same visual appearance for future use.
I also implemented a suggestion from kofa for the GTK4 migration project. #21950
You can find the code here : master...Christian-Bouhon:darktable:draw
I suggest we create a new PR.

Greetings from the Luberon,
Christian

piratenpanda pushed a commit to piratenpanda/darktable that referenced this pull request Aug 23, 2026
…image (darktable-org#21397)

* colorequal: interactive hue-under-cursor editing with a shared preview-data service

Adds an on-canvas interactive editing mode driven by the hue under the
mouse cursor: hovering the image shows a color indicator and lets the
scroll wheel apply a Gaussian-weighted adjustment (sigma=35 degrees) to
the saturation/hue/brightness nodes of the active channel, Alt+scroll
switches channel tabs (on both the image and the graph), and the graph's
own scroll handling mirrors the same behavior for the node under the
cursor there.

The per-pixel hue buffer needed for this is served by a new shared
service, dt_preview_data_t (src/develop/preview_data.c/.h): resizing,
filling and hashing the preview-pipe buffer under a single GUI lock so
readers never observe a resized-but-unfilled buffer, plus freshness
checks against the pipe's cumulative hash. gui_focus()/mouse_moved() use
it to request a debounced preview reprocess so the indicator works right
after opening the module instead of only after an unrelated trigger.

The on-canvas cursor itself (crosshair, wedge, circles, text readout) is
factored into a shared dt_draw_correction_cursor() helper in
src/gui/draw.h and reused by the tone equalizer, so both modules render
the same cursor design and future modules can adopt it too.

* colorequal: address review feedback and show in/out colors under cursor

- don't show a busy/wait cursor while the preview pipe recomputes during
  hover (toneequal: drop the busy branch in switch_cursors, gate the
  exposure re-read instead; colorequal: same in _switch_cursors)
- draw a white vertical line on the graph at the hue under the mouse
  cursor, mirroring the tone equalizer's exposure cursor line
- open the wedge cursor up to +-90 degrees (clamped at +-1) instead of
  clamping it to 45 degrees; toneequal pre-scales the correction by 0.5
  so the wedge reaches its full span at +-2 EV
- store the module input HSB (hue, saturation, brightness) instead of
  just the hue in the shared preview buffer (3 components per pixel) and
  show the module input/output colors in the cursor's two circles

This brings the branch in line with the review comment on PR darktable-org#21397 and
adds the in/out colors feature.

* Restoring the code after a rebase

* toneequal: derive cursor frame color from the sampled background

Like the color equalizer's cursor, sample the pixel under the cursor
from the preview pipe backbuf and draw the frame lines (wedge,
crosshair, outlines) white over dark content and black over bright
content.  This replaces _match_color_to_background(), which derived
grey shades from the estimated output exposure instead of the actual
background, so the lines could end up mid-grey.

The circles keep their before/after luminance grey shades.
deekayhd pushed a commit to deekayhd/darktable that referenced this pull request Aug 30, 2026
…image (darktable-org#21397)

* colorequal: interactive hue-under-cursor editing with a shared preview-data service

Adds an on-canvas interactive editing mode driven by the hue under the
mouse cursor: hovering the image shows a color indicator and lets the
scroll wheel apply a Gaussian-weighted adjustment (sigma=35 degrees) to
the saturation/hue/brightness nodes of the active channel, Alt+scroll
switches channel tabs (on both the image and the graph), and the graph's
own scroll handling mirrors the same behavior for the node under the
cursor there.

The per-pixel hue buffer needed for this is served by a new shared
service, dt_preview_data_t (src/develop/preview_data.c/.h): resizing,
filling and hashing the preview-pipe buffer under a single GUI lock so
readers never observe a resized-but-unfilled buffer, plus freshness
checks against the pipe's cumulative hash. gui_focus()/mouse_moved() use
it to request a debounced preview reprocess so the indicator works right
after opening the module instead of only after an unrelated trigger.

The on-canvas cursor itself (crosshair, wedge, circles, text readout) is
factored into a shared dt_draw_correction_cursor() helper in
src/gui/draw.h and reused by the tone equalizer, so both modules render
the same cursor design and future modules can adopt it too.

* colorequal: address review feedback and show in/out colors under cursor

- don't show a busy/wait cursor while the preview pipe recomputes during
  hover (toneequal: drop the busy branch in switch_cursors, gate the
  exposure re-read instead; colorequal: same in _switch_cursors)
- draw a white vertical line on the graph at the hue under the mouse
  cursor, mirroring the tone equalizer's exposure cursor line
- open the wedge cursor up to +-90 degrees (clamped at +-1) instead of
  clamping it to 45 degrees; toneequal pre-scales the correction by 0.5
  so the wedge reaches its full span at +-2 EV
- store the module input HSB (hue, saturation, brightness) instead of
  just the hue in the shared preview buffer (3 components per pixel) and
  show the module input/output colors in the cursor's two circles

This brings the branch in line with the review comment on PR darktable-org#21397 and
adds the in/out colors feature.

* Restoring the code after a rebase

* toneequal: derive cursor frame color from the sampled background

Like the color equalizer's cursor, sample the pixel under the cursor
from the preview pipe backbuf and draw the frame lines (wedge,
crosshair, outlines) white over dark content and black over bright
content.  This replaces _match_color_to_background(), which derived
grey shades from the estimated output exposure instead of the actual
background, so the lines could end up mid-grey.

The circles keep their before/after luminance grey shades.
deekayhd pushed a commit to deekayhd/darktable that referenced this pull request Aug 30, 2026
_switch_cursors() and switch_cursors() restored the default cursor with
a raw gdk_window_set_cursor() on the toplevel, while hiding it through
dt_control_change_cursor("none").  Going through the helper for both
halves brings back three things the raw call skips:

- The lock set by dt_control_forbid_change_cursor().  Shortcut-mapping
  mode and help mode (src/libs/tools/global_toolbox.c) hold that lock
  for as long as the mode is active, apply their own cursor to the same
  toplevel, and still forward motion events to the centre canvas -- so
  with the tone equalizer focused, moving the pointer over the canvas
  replaced their cursor with the plain arrow.  dt_gui_cursor_set_busy()
  takes the same lock.
- The -d input tracing in dt_control_cursor_debug(), which is called
  from the helpers only.
- The GTK3/GTK4 compatibility boundary.  gdk_window_set_cursor() has no
  GTK4 equivalent; these were its last uses outside src/gui/gtk.c.

Where the lock is not held the two forms are equivalent: on GTK3
dt_control_change_cursor() ends up calling gdk_cursor_new_from_name()
and gdk_window_set_cursor() on the same toplevel window.

For toneequal.c this restores what was there before 8a26f35
(darktable-org#21397); colorequal.c copied the pattern from it in the same commit.
The behaviour that commit intended -- no wait cursor while hovering,
whatever the pipe is doing -- is unchanged.

Built and verified at runtime on X11/GTK3.

Suggested-by: a darktable contributor
@Christian-Bouhon

Copy link
Copy Markdown
Contributor Author

Last point, this needs a release note entry. TIA.

Hello,
@TurboGit
Please find my suggested text below,

Module: Color Equalizer

Added an interactive editing mode allowing users to correct a color by selecting it directly on the photo with the mouse wheel, just as was already possible with the "tone equalizer" module.

User-facing changes

  • Enhanced interactivity in colorequal: added a dynamic pointer/crosshair on image hover. Hovering over the photo highlights the corresponding hue, saturation, or chromaticity band in the module, making it easier to precisely target colors.
  • UI harmonization: aligned colorequal's hover and sampling behavior with that of toneequal.

Technical improvements

  • Shared core service (src/develop/preview_data.h / .c): extracted from the toneequal module, management of the under-cursor buffer (allocation, pipe freshness hash, thread-safe read/write access) is now handled by a generic develop-engine service (dt_preview_data_*). The mapping from cursor coordinates to buffer pixels, which is specific to each module's position in the pipe, remains handled by each module.
  • Modularity: simplified source code and a reusable API made available for other modules that need live tracking on the image.

@Phemisters Phemisters added documentation: complete needed documentation is merged in dtdocs and removed documentation: pending a documentation work is required labels Aug 31, 2026
TurboGit pushed a commit that referenced this pull request Sep 2, 2026
…ith busy_mutex

dt_preview_data_is_fresh() (introduced in 8a26f35 / #21397, the
shared preview-data service for interactive editing) walks
dev->preview_pipe->nodes and hashes the matching piece from the GUI
thread while holding only the module's own gui_lock. pipe->nodes is
actually protected by pipe->busy_mutex: dt_dev_pixelpipe_cleanup_nodes()
frees every piece under that lock whenever a history change rebuilds
the pipe topology (DT_DEV_PIPE_REMOVE in dt_dev_pixelpipe_change), and
that can run concurrently on the pipe-processing thread. A caller that
walks pipe->nodes off the GUI thread without busy_mutex can therefore
dereference a piece that gets freed out from under it -- reproduced as
a segfault dereferencing a freed piece->module in
_dev_pixelpipe_cache_basichash(), from a module's preview-pipe-finished
GUI callback calling dt_preview_data_is_fresh().

colorequal.c's own interactive hue-editing mode (also from #21397)
calls dt_preview_data_is_fresh() from mouse-hover/scroll handlers and
is exposed to the same race in principle, just from a narrower window
that makes it harder to trigger in practice.

Take pipe->busy_mutex around the walk. It must be a trylock, not a
blocking lock: a module's process() runs under busy_mutex for the
whole pipe run (dt_dev_pixelpipe_process) and can itself call
dt_preview_data_store() (colorequal.c does, twice), which takes this
same module's gui_lock -- the opposite order -- so a blocking lock
here would AB-BA deadlock against it. A pipe that's currently busy
can't have fresh data for us anyway, so treating "busy" as "not
fresh" costs nothing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpGKm49b4w1L51YETXyGpU
Cjhemmer added a commit to Cjhemmer/darktable-WindowsPrinting that referenced this pull request Sep 2, 2026
Remove unused quality setting dropdown

borders: fix memory corruption on non-positive coordinate deltas

Commit 257e632 widened the loop index variable in set_pixels() and
copy_pixels() from int to size_t. In dt_iop_copy_image_with_border(),
these functions are called with slice lengths computed from coordinate
differences such as fl_right - image_right.

Under boundary conditions (e.g. scaled thumbnail generation or
tiling), the difference can be negative. With a signed int counter,
0 < npixels evaluated to false and safely skipped the loop. With size_t,
signed-to-unsigned conversion caused the loop to iterate
18446744073709551615 times, corrupting heap memory and causing a SIGSEGV.

Revert the loop variables back to int, and clamp image_right and
image_bottom to roi_out bounds in dt_iop_setup_binfo.

Keep a path's feather when the shape is resized

Scroll-resizing a path morphs a cached baseline of its nodes and swaps
the result in whole, borders included. The feather branch of the same
scroll handler edits every node's border in place without dropping that
cache, so the next resize restored the borders the baseline was captured
with and the feather collapsed.

Invalidate the resize state there, as _path_modify_property already does
for DT_MASKS_PROPERTY_FEATHER. Reproduced by resizing a path with the
wheel, raising the feather with shift+scroll, then resizing again;
circles and ellipses are unaffected as they have no baseline to go stale.

Fixes darktable-org#22052

RELEASE_NOTES.md: path feather lost on scroll resize

Add protection against memory allocation failure

remove mention of Intel Macs from README (darktable-org#22114)

* remove mention of Intel Macs from README

* update README for the nightly builds

RELEASE_NOTES.md: clean-up the changed dependencies.

borders: disable frame line controls when border geometry prevents it

The frame line width is proportional to the minimum border width across
all four sides. When border size is 0 or when horizontal/vertical
offsets are set to extreme values (0.0 or 1.0), the minimum border
width collapses to 0, making the frame line invisible.

Disable frame line width, offset, and color controls when geometry
prevents the frame line from rendering, and update their tooltips to
explain the required adjustment.

borders: fix asymmetric frame line centering and rounding precision

Frame line boundary coordinates previously subtracted 1 to represent
inclusive pixel indices, but were assigned directly to half-open interval
endpoints (fl_right, border_right, fl_bot, border_bot). This caused the
inner border on the right and bottom sides to be 1 pixel narrower and the
outer border 1 pixel wider, noticeably shifting the frame line off-center
at smaller border sizes and downscaled preview resolutions.
Define the bottom-right frame coordinates as exclusive upper bounds,
matching CPU slice lengths and OpenCL bounding box expectations, and
use roundf() on fractional offset and size products.

src/tests/integration: Update sub-module.

Update expected output after the border precision fixes.

RFC: add AGENTS.md (darktable-org#21924)

* Add AGENTS.md with conventions for AI coding agents

AI-assisted contributions arrive with a recurring set of problems the
existing docs do not speak to: commit series where only the final commit
compiles, which leaves git bisect useless, and patches written by
inferring an API instead of reading the page that documents it.

The conventions are not new. They are collected from the Developer's
guide, dev-doc/ and the existing history into the file name that most
coding assistants read by default. Two wiki corrections are folded in:
.clang-format and tools/beautify_style.sh no longer exist, having been
removed in 46b054c and b734b01.

Related: darktable-org#21881

* Add CLAUDE.md importing AGENTS.md

Claude Code reads CLAUDE.md and does not pick up AGENTS.md on its own.
The `@AGENTS.md` import keeps the conventions in one file rather than
duplicating them, and a one-line pointer works where a symlink would not,
such as checkouts with core.symlinks disabled.

* AGENTS.md: require a release notes entry with each PR

Maintainers now hold a PR until RELEASE_NOTES.md carries an entry for
it. Record which changes qualify and which do not, so an agent settles
the question while writing the PR rather than at review time.

* Support personal agent instructions in AGENTS.local.md

Per-project personal instructions have no cross-platform home. Agents
offer a global user layer and a committed project layer, but nothing
scoped to a single checkout, so carrying the same personal rules across
several agents means one file per agent, each excluded by hand.

AGENTS.md now points at an optional AGENTS.local.md, written as prose
rather than an import directive so that agents which do not expand `@`
still act on it, and .gitignore carries the file so nobody has to edit
their own exclude list.

gui: prevent resize handles from starting graph drags

Resizable drawing areas share their widget with the resize wrapper. A press on the resize handle could therefore start the graph gesture as well and change interactive graph parameters while resizing.\n\nRun the resize click and motion controllers in the capture phase. Handle presses are claimed before graph gestures, while presses outside the handle are denied so normal graph interaction remains available.\n\nFixes darktable-org#22106

gui: refresh resize indicators after child motion

preview_data: guard the pipe-nodes walk in dt_preview_data_is_fresh with busy_mutex

dt_preview_data_is_fresh() (introduced in 8a26f35 / darktable-org#21397, the
shared preview-data service for interactive editing) walks
dev->preview_pipe->nodes and hashes the matching piece from the GUI
thread while holding only the module's own gui_lock. pipe->nodes is
actually protected by pipe->busy_mutex: dt_dev_pixelpipe_cleanup_nodes()
frees every piece under that lock whenever a history change rebuilds
the pipe topology (DT_DEV_PIPE_REMOVE in dt_dev_pixelpipe_change), and
that can run concurrently on the pipe-processing thread. A caller that
walks pipe->nodes off the GUI thread without busy_mutex can therefore
dereference a piece that gets freed out from under it -- reproduced as
a segfault dereferencing a freed piece->module in
_dev_pixelpipe_cache_basichash(), from a module's preview-pipe-finished
GUI callback calling dt_preview_data_is_fresh().

colorequal.c's own interactive hue-editing mode (also from darktable-org#21397)
calls dt_preview_data_is_fresh() from mouse-hover/scroll handlers and
is exposed to the same race in principle, just from a narrower window
that makes it harder to trigger in practice.

Take pipe->busy_mutex around the walk. It must be a trylock, not a
blocking lock: a module's process() runs under busy_mutex for the
whole pipe run (dt_dev_pixelpipe_process) and can itself call
dt_preview_data_store() (colorequal.c does, twice), which takes this
same module's gui_lock -- the opposite order -- so a blocking lock
here would AB-BA deadlock against it. A pipe that's currently busy
can't have fresh data for us anyway, so treating "busy" as "not
fresh" costs nothing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpGKm49b4w1L51YETXyGpU

RELEASE_NOTES.md: ColorEQ interactive editing mode.

Introduce `dt_history_hash_unset_mipmap()`

Sets the image mipmap hash to zero, used to invalidate that mipmap.

Invalidate watermark cachelines for tag/metadata change

As reported a metadata or tag change leaves an invalid cacheline, the suggested fix is
good and the performance penalty is neglectable as those changes are rare and the module
has a high iop_order. Also ensure recalculation of lighttable mipmaps.

While being in lighttable view we don't have the iop_order ready so we simply clear the
darkroom pipe caches for the next run.

Fixes darktable-org#22069

Bump libheif version to the latest for AppImage build
Cjhemmer added a commit to Cjhemmer/darktable-WindowsPrinting that referenced this pull request Sep 3, 2026
Remove unused quality setting dropdown

borders: fix memory corruption on non-positive coordinate deltas

Commit 257e632 widened the loop index variable in set_pixels() and
copy_pixels() from int to size_t. In dt_iop_copy_image_with_border(),
these functions are called with slice lengths computed from coordinate
differences such as fl_right - image_right.

Under boundary conditions (e.g. scaled thumbnail generation or
tiling), the difference can be negative. With a signed int counter,
0 < npixels evaluated to false and safely skipped the loop. With size_t,
signed-to-unsigned conversion caused the loop to iterate
18446744073709551615 times, corrupting heap memory and causing a SIGSEGV.

Revert the loop variables back to int, and clamp image_right and
image_bottom to roi_out bounds in dt_iop_setup_binfo.

Keep a path's feather when the shape is resized

Scroll-resizing a path morphs a cached baseline of its nodes and swaps
the result in whole, borders included. The feather branch of the same
scroll handler edits every node's border in place without dropping that
cache, so the next resize restored the borders the baseline was captured
with and the feather collapsed.

Invalidate the resize state there, as _path_modify_property already does
for DT_MASKS_PROPERTY_FEATHER. Reproduced by resizing a path with the
wheel, raising the feather with shift+scroll, then resizing again;
circles and ellipses are unaffected as they have no baseline to go stale.

Fixes darktable-org#22052

RELEASE_NOTES.md: path feather lost on scroll resize

Add protection against memory allocation failure

remove mention of Intel Macs from README (darktable-org#22114)

* remove mention of Intel Macs from README

* update README for the nightly builds

RELEASE_NOTES.md: clean-up the changed dependencies.

borders: disable frame line controls when border geometry prevents it

The frame line width is proportional to the minimum border width across
all four sides. When border size is 0 or when horizontal/vertical
offsets are set to extreme values (0.0 or 1.0), the minimum border
width collapses to 0, making the frame line invisible.

Disable frame line width, offset, and color controls when geometry
prevents the frame line from rendering, and update their tooltips to
explain the required adjustment.

borders: fix asymmetric frame line centering and rounding precision

Frame line boundary coordinates previously subtracted 1 to represent
inclusive pixel indices, but were assigned directly to half-open interval
endpoints (fl_right, border_right, fl_bot, border_bot). This caused the
inner border on the right and bottom sides to be 1 pixel narrower and the
outer border 1 pixel wider, noticeably shifting the frame line off-center
at smaller border sizes and downscaled preview resolutions.
Define the bottom-right frame coordinates as exclusive upper bounds,
matching CPU slice lengths and OpenCL bounding box expectations, and
use roundf() on fractional offset and size products.

src/tests/integration: Update sub-module.

Update expected output after the border precision fixes.

RFC: add AGENTS.md (darktable-org#21924)

* Add AGENTS.md with conventions for AI coding agents

AI-assisted contributions arrive with a recurring set of problems the
existing docs do not speak to: commit series where only the final commit
compiles, which leaves git bisect useless, and patches written by
inferring an API instead of reading the page that documents it.

The conventions are not new. They are collected from the Developer's
guide, dev-doc/ and the existing history into the file name that most
coding assistants read by default. Two wiki corrections are folded in:
.clang-format and tools/beautify_style.sh no longer exist, having been
removed in 46b054c and b734b01.

Related: darktable-org#21881

* Add CLAUDE.md importing AGENTS.md

Claude Code reads CLAUDE.md and does not pick up AGENTS.md on its own.
The `@AGENTS.md` import keeps the conventions in one file rather than
duplicating them, and a one-line pointer works where a symlink would not,
such as checkouts with core.symlinks disabled.

* AGENTS.md: require a release notes entry with each PR

Maintainers now hold a PR until RELEASE_NOTES.md carries an entry for
it. Record which changes qualify and which do not, so an agent settles
the question while writing the PR rather than at review time.

* Support personal agent instructions in AGENTS.local.md

Per-project personal instructions have no cross-platform home. Agents
offer a global user layer and a committed project layer, but nothing
scoped to a single checkout, so carrying the same personal rules across
several agents means one file per agent, each excluded by hand.

AGENTS.md now points at an optional AGENTS.local.md, written as prose
rather than an import directive so that agents which do not expand `@`
still act on it, and .gitignore carries the file so nobody has to edit
their own exclude list.

gui: prevent resize handles from starting graph drags

Resizable drawing areas share their widget with the resize wrapper. A press on the resize handle could therefore start the graph gesture as well and change interactive graph parameters while resizing.\n\nRun the resize click and motion controllers in the capture phase. Handle presses are claimed before graph gestures, while presses outside the handle are denied so normal graph interaction remains available.\n\nFixes darktable-org#22106

gui: refresh resize indicators after child motion

preview_data: guard the pipe-nodes walk in dt_preview_data_is_fresh with busy_mutex

dt_preview_data_is_fresh() (introduced in 8a26f35 / darktable-org#21397, the
shared preview-data service for interactive editing) walks
dev->preview_pipe->nodes and hashes the matching piece from the GUI
thread while holding only the module's own gui_lock. pipe->nodes is
actually protected by pipe->busy_mutex: dt_dev_pixelpipe_cleanup_nodes()
frees every piece under that lock whenever a history change rebuilds
the pipe topology (DT_DEV_PIPE_REMOVE in dt_dev_pixelpipe_change), and
that can run concurrently on the pipe-processing thread. A caller that
walks pipe->nodes off the GUI thread without busy_mutex can therefore
dereference a piece that gets freed out from under it -- reproduced as
a segfault dereferencing a freed piece->module in
_dev_pixelpipe_cache_basichash(), from a module's preview-pipe-finished
GUI callback calling dt_preview_data_is_fresh().

colorequal.c's own interactive hue-editing mode (also from darktable-org#21397)
calls dt_preview_data_is_fresh() from mouse-hover/scroll handlers and
is exposed to the same race in principle, just from a narrower window
that makes it harder to trigger in practice.

Take pipe->busy_mutex around the walk. It must be a trylock, not a
blocking lock: a module's process() runs under busy_mutex for the
whole pipe run (dt_dev_pixelpipe_process) and can itself call
dt_preview_data_store() (colorequal.c does, twice), which takes this
same module's gui_lock -- the opposite order -- so a blocking lock
here would AB-BA deadlock against it. A pipe that's currently busy
can't have fresh data for us anyway, so treating "busy" as "not
fresh" costs nothing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpGKm49b4w1L51YETXyGpU

RELEASE_NOTES.md: ColorEQ interactive editing mode.

Introduce `dt_history_hash_unset_mipmap()`

Sets the image mipmap hash to zero, used to invalidate that mipmap.

Invalidate watermark cachelines for tag/metadata change

As reported a metadata or tag change leaves an invalid cacheline, the suggested fix is
good and the performance penalty is neglectable as those changes are rare and the module
has a high iop_order. Also ensure recalculation of lighttable mipmaps.

While being in lighttable view we don't have the iop_order ready so we simply clear the
darkroom pipe caches for the next run.

Fixes darktable-org#22069

Bump libheif version to the latest for AppImage build
schyffel pushed a commit to schyffel/darktable that referenced this pull request Sep 3, 2026
…image (darktable-org#21397)

* colorequal: interactive hue-under-cursor editing with a shared preview-data service

Adds an on-canvas interactive editing mode driven by the hue under the
mouse cursor: hovering the image shows a color indicator and lets the
scroll wheel apply a Gaussian-weighted adjustment (sigma=35 degrees) to
the saturation/hue/brightness nodes of the active channel, Alt+scroll
switches channel tabs (on both the image and the graph), and the graph's
own scroll handling mirrors the same behavior for the node under the
cursor there.

The per-pixel hue buffer needed for this is served by a new shared
service, dt_preview_data_t (src/develop/preview_data.c/.h): resizing,
filling and hashing the preview-pipe buffer under a single GUI lock so
readers never observe a resized-but-unfilled buffer, plus freshness
checks against the pipe's cumulative hash. gui_focus()/mouse_moved() use
it to request a debounced preview reprocess so the indicator works right
after opening the module instead of only after an unrelated trigger.

The on-canvas cursor itself (crosshair, wedge, circles, text readout) is
factored into a shared dt_draw_correction_cursor() helper in
src/gui/draw.h and reused by the tone equalizer, so both modules render
the same cursor design and future modules can adopt it too.

* colorequal: address review feedback and show in/out colors under cursor

- don't show a busy/wait cursor while the preview pipe recomputes during
  hover (toneequal: drop the busy branch in switch_cursors, gate the
  exposure re-read instead; colorequal: same in _switch_cursors)
- draw a white vertical line on the graph at the hue under the mouse
  cursor, mirroring the tone equalizer's exposure cursor line
- open the wedge cursor up to +-90 degrees (clamped at +-1) instead of
  clamping it to 45 degrees; toneequal pre-scales the correction by 0.5
  so the wedge reaches its full span at +-2 EV
- store the module input HSB (hue, saturation, brightness) instead of
  just the hue in the shared preview buffer (3 components per pixel) and
  show the module input/output colors in the cursor's two circles

This brings the branch in line with the review comment on PR darktable-org#21397 and
adds the in/out colors feature.

* Restoring the code after a rebase

* toneequal: derive cursor frame color from the sampled background

Like the color equalizer's cursor, sample the pixel under the cursor
from the preview pipe backbuf and draw the frame lines (wedge,
crosshair, outlines) white over dark content and black over bright
content.  This replaces _match_color_to_background(), which derived
grey shades from the estimated output exposure instead of the actual
background, so the lines could end up mid-grey.

The circles keep their before/after luminance grey shades.
schyffel pushed a commit to schyffel/darktable that referenced this pull request Sep 3, 2026
_switch_cursors() and switch_cursors() restored the default cursor with
a raw gdk_window_set_cursor() on the toplevel, while hiding it through
dt_control_change_cursor("none").  Going through the helper for both
halves brings back three things the raw call skips:

- The lock set by dt_control_forbid_change_cursor().  Shortcut-mapping
  mode and help mode (src/libs/tools/global_toolbox.c) hold that lock
  for as long as the mode is active, apply their own cursor to the same
  toplevel, and still forward motion events to the centre canvas -- so
  with the tone equalizer focused, moving the pointer over the canvas
  replaced their cursor with the plain arrow.  dt_gui_cursor_set_busy()
  takes the same lock.
- The -d input tracing in dt_control_cursor_debug(), which is called
  from the helpers only.
- The GTK3/GTK4 compatibility boundary.  gdk_window_set_cursor() has no
  GTK4 equivalent; these were its last uses outside src/gui/gtk.c.

Where the lock is not held the two forms are equivalent: on GTK3
dt_control_change_cursor() ends up calling gdk_cursor_new_from_name()
and gdk_window_set_cursor() on the same toplevel window.

For toneequal.c this restores what was there before 8a26f35
(darktable-org#21397); colorequal.c copied the pattern from it in the same commit.
The behaviour that commit intended -- no wait cursor while hovering,
whatever the pipe is doing -- is unchanged.

Built and verified at runtime on X11/GTK3.

Suggested-by: a darktable contributor
schyffel pushed a commit to schyffel/darktable that referenced this pull request Sep 3, 2026
…ith busy_mutex

dt_preview_data_is_fresh() (introduced in 8a26f35 / darktable-org#21397, the
shared preview-data service for interactive editing) walks
dev->preview_pipe->nodes and hashes the matching piece from the GUI
thread while holding only the module's own gui_lock. pipe->nodes is
actually protected by pipe->busy_mutex: dt_dev_pixelpipe_cleanup_nodes()
frees every piece under that lock whenever a history change rebuilds
the pipe topology (DT_DEV_PIPE_REMOVE in dt_dev_pixelpipe_change), and
that can run concurrently on the pipe-processing thread. A caller that
walks pipe->nodes off the GUI thread without busy_mutex can therefore
dereference a piece that gets freed out from under it -- reproduced as
a segfault dereferencing a freed piece->module in
_dev_pixelpipe_cache_basichash(), from a module's preview-pipe-finished
GUI callback calling dt_preview_data_is_fresh().

colorequal.c's own interactive hue-editing mode (also from darktable-org#21397)
calls dt_preview_data_is_fresh() from mouse-hover/scroll handlers and
is exposed to the same race in principle, just from a narrower window
that makes it harder to trigger in practice.

Take pipe->busy_mutex around the walk. It must be a trylock, not a
blocking lock: a module's process() runs under busy_mutex for the
whole pipe run (dt_dev_pixelpipe_process) and can itself call
dt_preview_data_store() (colorequal.c does, twice), which takes this
same module's gui_lock -- the opposite order -- so a blocking lock
here would AB-BA deadlock against it. A pipe that's currently busy
can't have fresh data for us anyway, so treating "busy" as "not
fresh" costs nothing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpGKm49b4w1L51YETXyGpU
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation: complete needed documentation is merged in dtdocs feature: new new features to add priority: low core features work as expected, only secondary/optional features don't scope: image processing correcting pixels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants