Fixes masks jumping around when crop is toggled.#20575
Fixes masks jumping around when crop is toggled.#20575TurboGit merged 1 commit intodarktable-org:masterfrom
Conversation
|
It's impressive, really! Was this done with the help of Claude? |
This was really a team effort. Claude helped a lot, but it was going around in circles. It needed a lot of guidance, and I burned through one week of quota in one day 😭 But it would have taken me days of full time work to fix this by myself, especially because I am not familiar with this part of the code. An LLM can traverse all the relevant code and put debug statements when they are needed in ten seconds. It would take me hours just to find the right bits of code and type the stuff. |
|
Well, three of us have been looking at this issue without any progress. We had some ideas to fix that, don't remember all details, but this was a quite complete and invasive rewrite of the way the mask was computed. A big thanks you for tackling that and for delivering a fix... that I didn't break! |
Yet! 🤣 |
TurboGit
left a comment
There was a problem hiding this comment.
Cannot break it! Masks stable and can be dragged to pixel precision up to zoom 1600.
Just need a release note entry in the fixes section. TIA.
|
@TurboGit Also here, you can remove the release_notes_pending tag. TIA! |
|
Done. |
Hopefully, this fixes issue #20563.
Fix in action:
Screen.Recording.2026-03-18.at.16.14.21.mp4
I don't know what is your regular benchmark for mask stress testing, let's see if @TurboGit or @jenshannoschwalm can break it ⚒️
More details below.
Root cause: two pipes, one pixel apart
darktable runs two independent pixel pipelines simultaneously:
Both apply exactly the same modules. The crop module computes its output size with
integer truncation:
For a 3:1 crop ratio,
d->cxis adjusted so the cropped output is exactly 3× theheight. In the full pipe this might produce
crop_left = 446px; in the preview pipe,working on a 6× smaller image, it might produce
crop_left = 74px. But74 × 6 = 444 ≠ 446— the two pipes are off by 2 input pixels, which is a fractionof a preview pixel after downscaling. This fraction, multiplied by
zoom_scale(~35×),becomes ~35 screen pixels.
The five changes
1.
src/iop/crop.c—_get_crop_offset(): use integer crop offsetsBefore:
_get_crop_offsetcalledmodify_roi_outon a 100× scaled copy ofbuf_into reduce floating-point truncation error, then divided back by 100. Thisproduced a non-integer result (e.g.
446.69) that didn't match what the pipelineactually used (integer
446), causing a ~0.7 px misalignment in every mask overlay.After: Call
modify_roi_outdirectly onbuf_inat 1:1 scale. The result is theexact same integer
roi_out.x/ythe pipeline computed during processing — maskoverlays now subtract the same integer offset the image data was cropped by.
2.
src/develop/develop.c—dt_dev_get_preview_size(): use actual preview dimensionsBefore: Computed the preview canvas size as
full_pipe->processed_width / iscale— a floating-point division of the full-pipe output.
After: Uses
preview_pipe->processed_widthdirectly. These two values differ byup to 1 pixel when crop's integer truncation lands differently across the two pipes.
All mask coordinate code (
dt_masks_get_image_size, hit testing, Cairo overlay) nowuses the same canvas size the preview pipe actually produced.
3.
src/develop/masks.h—dt_masks_get_image_size(): same fix, same reasonThis function is the single place all mask shape code fetches
wd/ht(the canvassize in which mask point coordinates live). It was computing
full.pipe->processed_width / iscalefor the same reason as above. Changed to usepreview_pipe->processed_widthas the primary source, with the old formula as afallback when the preview pipe hasn't processed yet.
4.
src/develop/develop.c—dt_dev_zoom_move(): prevent viewport drift on crop toggleBefore: Any call to
dt_dev_zoom_move(DT_ZOOM_MOVE, 0, 0)— a validation-onlycall with no actual movement — used a 0.5-pixel threshold to decide whether
port->zoom_xneeded updating. When a crop was toggled, the pipeline changed, andthe backward transform of the viewport center could land just over 0.5px from the
stored position, silently updating
zoom_xby a sub-pixel amount. This driftaccumulated and appeared as an image/mask shift.
After: Validation-only calls (zero
x/ywithDT_ZOOM_MOVE) use a 3.0-pixelthreshold, which is large enough to absorb the integer-truncation rounding across the
crop toggle without suppressing real panning.
5.
src/views/darkroom.c— Cairo overlay alignment and hit-test correctionThis is the most complex change, needed because even with the above fixes the ~1
preview pixel difference between full-pipe and preview-pipe viewport centers still
exists structurally (the two pipes are independent and will always differ slightly
after integer operations).
The remaining problem: Mask overlay points are computed by
distort_transformthrough the preview pipe (output space: 0…899 px). The Cairo drawing transform in
expose()was set up using the full-pipe viewport center (zoom_xfromdt_dev_get_viewport_params). At high zoom this 1-pixel difference in image spacebecomes ~35 screen pixels.
zoom_x_vpcomputation: Before drawing,expose()now also transforms theviewport center through the preview pipe to get
zoom_x_vp— the preview-pipeequivalent of
zoom_x.Drawing fix: The main Cairo transform for all overlays (guides, crop frame, color
picker) still uses the full-pipe
zoom_x, so those stay aligned with the imagebitmap. Only inside the mask-specific
cairo_save/restoreblock is an additionalcairo_translate((zoom_x - zoom_x_vp) * wd, ...)applied, shifting the coordinateorigin by the exact sub-pixel difference so mask overlays land correctly on the image.
Hit-test fix: Mouse event handlers (
mouse_moved,button_pressed,button_released,scrolled) compute the mouse position through the full pipe via_get_zoom_pos. Before passing it to any mask event function, they now call_preview_pipe_zoom_correction()to compute the samezoom_x_vp - zoom_x_fulldifference and add it to
pzx/pzy. This makes the mouse coordinate land in thesame preview-pipe space as the mask point coordinates, so anchor selection, dragging,
and segment proximity detection all work correctly.