Skip to content

masks: flexi masks, one panel to rule them all - #22553

Open
masterpiga wants to merge 6 commits into
darktable-org:masterfrom
masterpiga:upstream-flexi-model
Open

masterpiga wants to merge 6 commits into
darktable-org:masterfrom
masterpiga:upstream-flexi-model

Conversation

@masterpiga

@masterpiga masterpiga commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Context

In #21905, @TurboGit suggested that the set of
commits that upstream the work on Flexi masks is split into two PRs.
#22542 (merged) was the first, and this is the 2nd one.

Summary

Until now a module's mask was one of drawn, parametric, raster, or drawn
and parametric, each with its own controls. Drawn shapes were combined in
a flat list in the mask manager. Parametric channels could only be ANDed,
a raster mask replaced the whole mask, and the kinds could not be mixed.

This PR makes the mask a tree of groups, edited in a single panel. Drawn
shapes, parametric channels, raster masks from other modules and AI
objects are all elements of groups. Each group combines its elements with
one operator and has its own opacity, inversion and refinement, which
each element can also have. The panel replaces the mask modes and the
mask manager. It can sit in the module, in a utility module or docked
beside the image, and it mirrors hover and selection with the canvas.
Existing edits are converted when they are loaded. The data model is
described in dev-doc/masks_data_model.md.

Commits

  1. masks v7 format. Storage for the tree: new group point fields,
    the parametric and raster form types, and the state bits. Blobs are
    read with a per-version stride, so v6 data is never read past its end.
  2. flexi mask engine. The group fold, the new form types, per-group
    and per-element refinement, the drawn-mask cache and the classic to
    flexi migration. It is compiled but not yet used. It also fixes a read
    before the start of the buffer in dt_iop_copy_image_roi.
  3. migration verification tools. --harvest-masks exports a
    library's mask configurations as anonymous JSON, and --verify-masks
    replays them headless before and after migration. This lets users hand
    over reproducers.
  4. bauhaus, dtgtk. Widget support the panel needs: gradient slider
    sizing from CSS, bauhaus popup placement and preview, and new glyphs.
  5. replace the classic masks. Turns the switch on
    (DEVELOP_BLEND_VERSION 15), adds the panel, its three homes and the
    layout presets, and removes the mask manager. Also adds a mask lock,
    preferences, the panel's CSS (themable, see
    dev-doc/flexi_masks/styling.md) and the tests.
  6. RELEASE_NOTES.md.

Compatibility

The conversion is one way: older darktable versions cannot read an edit
saved by this one with its masks intact. The release notes say so and
recommend a backup.

Testing

  • the unit suites for the panel's model layer, migration, caching,
    persistence and styling pass
  • the new pixel suite (src/tests/masking/flexi) passes 46/46 on a
    macOS Release build
  • the harvest checks (--check-masks, --roundtrip-masks,
    --styleapply-masks, --persist-masks, --undo-masks,
    --lock-masks) were run on harvested user libraries

Results of classic masks migrations

0 migration failures in 8203 distinct configuration shapes
→ the failure rate is below 0.037% (1 in 2,738) at 95% confidence.

The confidence interval is one-sided Clopper-Pearson. With zero
observed failures that degenerates to the rule of three, a bound of about 3/n.

Each "shape" is a complete, distinct mask setup:

  • which module it is (exposure, color balance rgb, ...)
  • what kind of mask it uses: drawn shapes, parametric sliders, or both
  • which drawn shapes are in it (circle, ellipse, path, brush, gradient)
  • how the shapes are combined (union, intersection, difference, ...)
  • whether the mask is inverted, etc.
contributed libraries 15
harvested edits 63157
distinct configuration shapes 8203
migration failures 0
classic-GPU outliers 43
shapes proving nothing (inert/skipped) 30

Classic-GPU outliers are counted separately on purpose: there the CPU
renders classic and migrated identically and only the classic GPU
render disagrees, which is a pre-existing divergences between the
OpenCL/CPU paths that migration exposes rather than causes.

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.

AI assistance

Co-authored with Claude and Gemini.

Flexi masks store a module's mask as a tree of groups. This adds the
storage it needs, without anything that reads it yet: nothing sets
DEVELOP_MASK_FLEXI or creates the new form types, and the classic
group renderer ignores every new field, so every mask renders as it
did with masks v6.

- the group point gains a refinement, a name, a group opacity and a
  preset note, appended to the struct (16 to 240 bytes). The v6 to v7
  step sets the group opacity to 1.0; zero is neutral for the rest
- new form types DT_MASKS_PARAMETRIC and DT_MASKS_RASTER, with their
  point structs in blend.h, and DEVELOP_MASK_FLEXI for mask_mode
- new state bits for group markers, group operators and modifiers and
  per-element flags, with static asserts that the roles never overlap

A blob stores each point at the size of the masks version that wrote
it. The masks history loader and the XMP format 2 importer now step
through it with dt_masks_point_stride and zero-fill what an older
point lacks; reading a v6 blob at the v7 size would run past its end.
Every place that builds a group point now zeroes it, so no
indeterminate byte reaches a blob.

Older darktable versions cannot read a v7 group blob.

The data model is described in dev-doc/masks_data_model.md.
@masterpiga

Copy link
Copy Markdown
Collaborator Author

@TurboGit Commit 8e0d9a5 includes the tools that I used to verify that classic masks can be migrated successfully. I am including it for completeness, but probably we do not want to upstream it.

@masterpiga masterpiga added this to the 5.8 milestone Oct 9, 2026
@masterpiga masterpiga added priority: low core features work as expected, only secondary/optional features don't difficulty: hard big changes across different parts of the code base scope: UI user interface and interactions scope: image processing correcting pixels gtk4 labels Oct 9, 2026
@TurboGit

TurboGit commented Oct 9, 2026

Copy link
Copy Markdown
Member

@masterpiga : Just a note that I will be mostly away from my computer for the week-end.

@masterpiga
masterpiga force-pushed the upstream-flexi-model branch from d571c5f to 98047ea Compare October 9, 2026 17:40
A flexi mask is a tree of groups (see dev-doc/masks_data_model.md).
This adds everything that renders and converts one, with nothing that
creates one yet: DEVELOP_BLEND_VERSION stays 14, nothing sets
DEVELOP_MASK_FLEXI, and the migration is compiled but not called, so
every mask still renders through the classic fold.

- group.c: the flexi fold, _group_get_mask_roi_flexi, selected by
  DEVELOP_MASK_FLEXI. Each group folds its visible members in list
  order with its one operator, then applies its refinement, inversion
  and opacity. The combiners are exported for reuse, and a per-thread
  depth guard stops a cyclic tree from recursing without end
- parametric.c, raster.c: the new form types, a blendif channel and
  another module's raster mask as group elements
- masks.c: group markers and the tree operations on them, including
  the conversion of a classic list into one-operator groups and the
  simplification of the result; marker-aware id allocation, cleanup
  and hashing, with group opacity and refinement in the mask hash
- blend.c: per-element and per-group refinement, the drawn-mask cache
  keyed on the pipe's own forms, and dt_develop_blend_legacy_params_ext
  carrying the history row a conversion will be written back under
- pixelpipe, imageop: the refinement bypass snapshot, raster-mask users
  inside flexi groups, and pruning of stale raster consumers
- migrate_legacy.c: the classic to flexi conversion, and develop.c's
  plumbing for the forms it synthesizes while history loads

Also fixes dt_iop_copy_image_roi, whose per-line fast path read before
the start of the input buffer for a negative RoI offset.
The migration has to render every existing mask the same, and no set
of invented test cases covers what users actually built. These tools
let a user hand over a reproducer, and let anyone re-run the check on
it. They do nothing unless their flag is given.

- --harvest-masks FILE exports every mask configuration in a library
  as JSON, opening the library strictly read-only before startup can
  lock or upgrade it. --harvest-masks-xmp DIR FILE does the same from
  the XMP sidecars under DIR. The output holds numbers and module
  names only: no file names, shape or group names, EXIF or image data
- --verify-masks FILE replays each harvested mask on a generated probe
  image, before and after migration, and reports the differences in
  FILE.report.json. It needs no GUI, so it runs headless and in CI

test_probe_image checks that the probe image covers the whole range of
every blendif channel, also under windows the size of a drawn mask,
and has the edges and texture that feathering and detail masks read:
a mask rendering to zero on the probe would pass the check vacuously.
What the masks panel needs from the shared widgets, ahead of the panel:

- gradientslider: marker size and bar height come from CSS, so a theme
  can size the sliders; markers are drawn as one shape set, a caller
  can keep one marker picked out while it edits that marker elsewhere,
  and dtgtk_gradient_slider_multivalue_set_value_pushing sets a marker
  the way a drag moves it, pushing a neighbor it crosses along
- bauhaus: a popup can be pinned to a caller-chosen rectangle
  (dt_bauhaus_widget_set_popup_position), and a static popup can
  report the value under the pointer for a preview without changing
  the slider. A slider without a label centers its baseline in the
  height it has. A slider's popup gets room past both ends of its
  range, so a click there sets min or max instead of closing the popup
- paint: glyphs for import, solo, solo edit, the mask lock, the masks
  panel, and the screen (smooth union) and product operators; the
  existing operator glyphs are redrawn to match them, and the union and
  intersection glyphs are renamed maximum and minimum, after the
  operations they draw
A module's mask was chosen as one of drawn, parametric, raster or drawn
and parametric, each with its own controls in the blending section, and
drawn shapes were combined in a flat list edited in the mask manager.
Parametric channels could only be ANDed, a raster mask replaced the
whole mask, and the three kinds could not be combined with each other.

The mask is now a tree of groups (dev-doc/masks_data_model.md), edited
in one panel: shapes, parametric channels, raster masks and AI objects
are elements of groups, and every group folds its elements with one
operator and has its own opacity, inversion and refinement.

- the switch: dt_develop_blend_legacy_params_ext runs the migration,
  and DEVELOP_BLEND_VERSION goes to 15, so every older edit is
  converted to flexi when it is loaded, and written back as flexi.
  Conversion is one way: older darktable versions cannot read the
  result
- the panel replaces the mask modes in blend_gui.c, and the mask
  manager (libs/masks.c) is removed. It lives in the module's blending
  section, in a utility module (libs/masks_flexi_host.c) or in a panel
  over the canvas (gui/gtk.c, masks_gui_panel_host.c), and a darkroom
  toolbar button shows or hides it. Built-in group layout presets come
  from data/masks_group_presets.json, with notes for novice users
- the canvas follows the panel: hover and selection are mirrored both
  ways, solo and solo edit narrow what is drawn or edited, an AI object
  moves as one unit unless the user steps into it, and deleting a shape
  or changing its opacity on the canvas does what the panel does
- a mask can be locked, and a locked mask survives reset, presets,
  styles and paste. The lock takes over a reserved field of the blend
  params, which every legacy conversion clears
- dtgtk/expander: only a module's own expander takes the scroll
  target, so folding the panel's sections and groups does not scroll
  the module, and a focused module no longer scrolls back to its
  header whenever it grows
- preferences for the panel under plugins/darkroom/masks/ and
  plugins/darkroom/blend/, the panel's CSS, and its styling contract
  for themes in dev-doc/flexi_masks/styling.md
- tests: unit suites for the panel's model layer, migration, caching,
  persistence and styling; a pixel suite of classic edits in
  src/tests/masking/flexi rendered through the migrated load path; and
  --check-masks, --roundtrip-masks, --styleapply-masks, --persist-masks,
  --undo-masks and --lock-masks, which run a harvest through the
  database trip, style application, panel edits, undo and the lock
@wpferguson

Copy link
Copy Markdown
Member

@masterpiga what gets exposed to shortcuts? My thought is how much control is available from Lua using dt.gui.action() calls.

@andriiryzhkov

andriiryzhkov commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

I posted this on pixls.us, but moving it here now, mostly unchanged.

I want to say again that the revamp is great work, and I'd really like to use it. But in its current state the UI still feels too far from the rest of darktable and is hard on my eyes, so I couldn't use it day to day. I sorted the list by priority: I think the critical ones should be fixed before merge, the rest can wait, and the last one is just a suggestion.

Critical

  1. Parametric sliders don't hide when the mask is collapsed. And with "element properties in subpanel" enabled, they stay in the tree, so half of the controls are in the tree and half in the subpanel.
    Pasted image 20261007171717

  2. In embedded mode, the blend mask header looks almost the same as a module header, so it's hard to find. In master it was much easier to spot. I think its background color should be closer to master, so it reads as part of the module.
    flexi_01Pasted image 20261007092216

  3. As a utility module, blend mask stands out in the module list for no good reason. The two-line title doesn't work. Better to reuse the existing pattern, "blend mask • exposure", like "orientation • auto". The buttons should also move out of the header, to their own line inside the module.
    Pasted image 20261007125948

  4. The right end of the sliders ignores the quad: it should be at the green line, and it isn't. The left edge of the blend opacity slider is off too (orange line). Maybe the eye could go into the quad, then the edges would line up.
    Pasted image 20261007093948

  5. Slider markers in the mask panel don't match bauhaus. Bauhaus markers have visible gap between marker and slider line, most markers in mask panel don't. Parametric sliders are a separate widget with their own markers. I know master has the same split, but I don't see why they can't reuse the bauhaus markers: less duplicated code and a consistent look.
    Pasted image 20261007173815

Would be nice to fix

  1. The mask panel background is a bit too dark, and it's not from the theme palette: the colors are hard-coded in darktable.css, so they don't follow the theme. Honestly, I'm not sure we need these dark backgrounds at all. They try to fix the elegant theme's low contrast, but add visual complexity. If you keep them, please derive them from the theme colors.
    Pasted image 20261007174305

Suggestion

  1. The header icons could be grouped better: the group button to the left, the link/copy button to the right. Right now the copy button looks like one more channel after hz.
    Pasted image 20261007174916

PS: Screenshots are from the previous build, but they are still relevant. I acknowledge that rounded corners are now fixed. Thank you for that.

@masterpiga
masterpiga force-pushed the upstream-flexi-model branch from 98047ea to e1aec1a Compare October 9, 2026 19:43
@masterpiga

Copy link
Copy Markdown
Collaborator Author

@wpferguson

Action in master In this PR Old shortcuts
mask manager (utility module) blend mask (utility module) carried over
mask manager › shapes › add gradient / path / ellipse / circle / brush <blending> › shapes › add gradient / path / ellipse / circle / brush carried over
mask manager › shapes › add object <blending> › shapes › add AI object carried over
mask manager › properties › opacity, size, hardness, rotation, curvature, compression, cleanup, refine mask boundary, shrink or grow <blending> › properties › same names (act on the shape being drawn, or else on the panel selection) carried over
mask manager › properties › feather <blending> › properties › fade-out border carried over
mask manager › properties › pressure <blending> › properties › brush pressure carried over
mask manager › properties › smoothing <blending> › properties › brush smoothing (the AI object's smoothing is <blending> › properties › smoothing) carried over
<blending> › tools › show and edit mask elements <blending> › tools › edit on canvas carried over
<blending> › mask opacity <blending> › mask brightness (same slider, corrected label) carried over
<blending> › tools › toggle polarity of drawn mask <blending> › masks › invert output of selected group carried over
<blending> › tools › toggle polarity of raster mask <blending> › masks › invert selected element carried over
<blending> › boost factor unchanged (acts on the selected parametric element) unchanged
<blending> › pickers › show color, set range unchanged (act on the selected parametric element) unchanged
<blending> › tools › temporarily switch off blend mask unchanged (button back in the mask header) unchanged
<blending> › mode, opacity, fulcrum, combine masks, details threshold, feathering guide, feathering radius, blurring radius, mask contrast unchanged unchanged
<blending> › tools › display mask and/or color channel, toggle blend order unchanged unchanged
<blending> › masks › off, uniformly, drawn mask, parametric mask, drawn & parametric mask, raster mask removed: replaced by <blending> › masks › mask enabled dropped
<blending> › tools › reset blend mask settings, invert all channel's polarities removed: no single parametric mask any more dropped
<blending> › channel (tabs) removed: channels are added as elements dropped
<blending> › drawn mask (combo) removed: replaced by the panel's import menu dropped
— new: <blending> › masks › mask enabled, lock mask —
— new: <blending> › tools › solo edit the selection —
— new: <blending> › masks › show/hide mask panel, add group above selected group, invert all elements of selected group, invert output of selected group, invert selected element, toggle solo edit, change operator of selected group, bypass/resume current group, preview channel under cursor, sticky opacity, auto-expand selected —
— new: darkroom › blend mask panel (toolbar button) —

"Carried over" means a shortcut saved under the old path is attached to the new action when shortcuts are loaded, and saved under the new path from then on.

@andriiryzhkov

  1. As I already said on Pixls, I need to think a bit about this. The suggestion is sensible, but unlike shapes parametric channels have no presence on canvas, so I think it makes sense to keep the sliders in the main panel. Also, it makes the panel more informative.

  2. Agreed. As for all other CSS tweaks, I would first merge and then we can try out different things more easily and pick the style that we like most.

  3. I disagree. it's useful to have those buttons in the header of the utility panel, and the visual differentiation is also a plus, given how ubiquitous mask editing is. If anything, I would say that the mask panel should not be "just another utility module", and instead be treated differently. For example, if hosted in the utility panel maybe it should go directly under the zoom widget.

  4. I disagree. No slider in the panel has a quad, and the horizontal space is useful for nested groups.

  5. Fixed already (even though I would say that this is a master bug, the markers shouldn't be clipped).

  6. Colors are no longer hardcoded. As for choice of colors, same as for (2), I would say let's merge as it is and fix based on feedback and concrete suggestions.

  7. Ok in principle. Note that there are already spacers between add group and the add shape buttons, and between the parametric channels and the "import" menu. Also note that there are three button layouts, based on panel size, so the proposal would need to be extended to include all three cases.

As mentioned in Pixls, since these are all cosmetic fixes I would rather apply them after the initial merge.

@wpferguson

Copy link
Copy Markdown
Member

Is thee a user guide for this?

My first test

  • made a new instance of exposure module to adjust an area on an image
  • drew the mask and adjusted, adjusted the exposure and got what I want - Yay
  • went to add a parametric mask (g)
    • lost the drawn mask. It was still active looking at the image, but I couldn't return to edit it.
    • kept futzing around trying to get back to the drawn mask and ended up in whole mask with no way to access the drawn or parametic masks, even though they were still active, except by stepping back through the history stack.

Here's the xmp

111EOSR7_2R4A4424.cr3.xmp.txt

@andriiryzhkov

Copy link
Copy Markdown
Collaborator
  1. I disagree. it's useful to have those buttons in the header of the utility panel, and the visual differentiation is also a plus, given how ubiquitous mask editing is.
  1. I disagree. No slider in the panel has a quad, and the horizontal space is useful for nested groups.

This is very frustrating to me. Darktable achieved very fragile but still mature visual consistency. Very few purely open source projects of similar size have it. These and couple other items break it in a core feature. Despite the great idea and huge efforts from your side, new mask panel will not be usable for me in current state.

@masterpiga

masterpiga commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator Author

new mask panel will not be usable for me

That seems a bit strong :) That said, darktable is not mine. I just expressed an opinion, and provided a rationale for it.

Here's the xmp

I am AFK, I will look as soon as I have chance.

EDIT: @wpferguson, at step 15 you have the drawn mask:

image

I can only assume that you have deleted it by mistake, because at step 16 it is no longer there.

@andriiryzhkov

Copy link
Copy Markdown
Collaborator

@masterpiga : Sorry, I didn't want to sound rude. I've shared only personal perspective. You are doing a great job capabilities wise with this revamp. I am sure there will be a plenty of users who will tolerate or even like your UI design approach.

@TurboGit

Copy link
Copy Markdown
Member

If I have read this carefully then the only real controversial point is 3. I have no strong opinion myselg, but I would tend to agree with @andriiryzhkov about the naming, maybe just "mask • exposure". For the buttons I agree that on the header it is ok, but then the renaming of the panel label will take too much horizontal spaces, think about "mask • color balance rgb" + all icons. So if the we rename we will also need to remove the buttons from the header. I see 2 solutions:

  1. Keep as it is on this PR
  2. Change naming and move buttons inside the module

All in all, we need to think more about this as there is no change that will be a win only from my POV.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

difficulty: hard big changes across different parts of the code base gtk4 priority: low core features work as expected, only secondary/optional features don't scope: image processing correcting pixels scope: UI user interface and interactions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants