Skip to content

feat(retouch): exclude what the brush covers, and size it from the canvas - #1138

Open
s6stkurz wants to merge 2 commits into
marcinz606:mainfrom
s6stkurz:feat/optical-exclusion-brush
Open

s6stkurz wants to merge 2 commits into
marcinz606:mainfrom
s6stkurz:feat/optical-exclusion-brush

Conversation

@s6stkurz

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #1132, which shipped the Optical Removal exclusion band. Two things about it were wrong in use.

The band released too much. A painted stroke was a search area: any connected mark the brush touched anywhere came back in full, so clipping the edge of a long hair released the whole hair. The band is now the cut — a mark crossing its rim keeps the repair on the side the brush missed, so half a hair can be released without the rest.

The band's width was unreachable while painting it. It borrows the heal brush's manual_dust_size, whose slider is hidden unless the Heal or Scratch tool is active — and an exclusion is painted with no tool active at all. So the width was invisible, and Alt+M warned "not available" instead of moving it. The canvas now sizes the brush directly: Alt + wheel, or a pinch while a brush is live.

Right-click behavior is untouched.

This changes a control that already shipped

Anyone who painted exclusions under #1132 gets a different result from the same stored strokes. The strokes stay valid; they release less. That is the point of the change, but it is a behavior change rather than an addition, so it is worth a deliberate second opinion before it lands.

How it works

  • drop_exclusions cuts to exclusion_cover's footprint instead of releasing whole connected components. _touched_components goes with the old semantics.
  • The rim is feathered over _MANUAL_RIM_PX, the distance a manual heal already feathers its own repairs with. The score is a ramp between _IR_SCORE_FLOOR and 1.0, and cutting one at the brush edge prints the edge as a step. The hair mask is binary and takes the footprint unfeathered.
  • A consequence worth stating: a brush narrower than the detector's own pad releases partially rather than fully, because the feather eats a 1.5 px rim from both sides. Large brushes — the case the band exists for — fully release everything but that rim.
  • HEAL_SIZE_MIN / HEAL_SIZE_MAX (2–64 px at the heal reference) are read by both the slider and the canvas clamp, so one range governs every route to the value. The old top end of 16 px could not cover an area of film.
  • Alt + wheel sits downstream of the reverse-scroll preference, so one scroll direction means "more" for both zoom and brush. wheel_notch_delta already normalizes pixelDelta, so Alt + a trackpad two-finger scroll works like Alt + a wheel.
  • A pinch reports a fraction of a scale factor per event, so it accumulates into whole pixels the way slippy_map already does; dropping the fractions would leave a slow pinch unable to move the brush at all.
  • The brush is drawn in the band's amber when a right-click is armed to exclude. Nothing else draws it with no tool active, and a pinch that sizes an invisible brush is a pinch that does nothing visible.

Notes for review

Four judgment calls worth a second opinion:

  1. Pinch stops zooming while a brush is live — the Heal or Scratch tool is active, or Optical Removal is on with the right-click toggle armed. The wheel still zooms in those states, so no context is left without a zoom route, but it is the only trackpad zoom affordance and this takes it in two of them. The narrow gate is deliberate: keying off dust_remove alone would strip pinch-zoom out of ordinary editing, since Optical Removal is on for most normal work.
  2. Brush Size widens to 2–64 px for all three brushes that share the field, not only the exclusion band. Permissive rather than a behavior change — the default stays 6 and every stored edit is already in range — but the heal and scratch brushes can now be driven much wider than before.
  3. The Brush Size slider now shows whenever Optical Removal is on, not only under a live tool. It stays where it is under MANUAL HEAL; moving it somewhere that serves both sections is a layout change I did not want to make unilaterally. This also fixes Alt+M / Alt+Shift+M silently no-opping during exclusion, since those are gated on the same visibility.
  4. No new shortcut-registry entry. manual_size_inc / manual_size_dec already exist for this action; Alt+wheel and pinch are further input routes to it, not new actions. They are documented in the hand-written ## Mouse table in docs/KEYBOARD.md instead. Happy to add registry entries if you would rather every input route be listed there.

Test plan

  • make all — 6052 passed. One pre-existing failure, test_load_tooltip_follows_a_rebinding, which also fails on clean main on macOS (the tooltip renders ⇧⌘L where the test expects the literal Ctrl+Shift+L); untouched by this branch and green on CI's Ubuntu.
  • tests/test_canvas_brush_size.py (new, 12 tests): the plain wheel still zooms; Alt+wheel sizes and does not zoom, both directions; a reversed scroll preference reverses the brush too; the clamp holds at both ends; pinch sizes only in a brush context and zooms outside one; a trackpad pixelDelta scroll drives it like a wheel; the amber circle is drawn only where a right-click would paint.
  • The test that pinned the old semantics is inverted rather than deleted, and now asserts that film outside the brush comes back byte-identical — which is precisely what whole-component release destroyed.
  • drop_exclusions' hair-mask branch gets its first coverage. Every existing call passed None, and it is the branch where whole-component and covered-span differ most, a hair being long and thin.
  • The amber overlay and the released mask are two implementations of one geometry (Qt and OpenCV), and the band is the literal cut now, so a test pins them together: they agree on 96% of their pixels, with an unrelated band scoring 0.
  • Driven against the real widgets on a trackpad: Option + two-finger scroll moves 6 px → 11 px in five gestures and clamps at 2 and 64; a realistic macOS pinch (many small per-frame deltas) moves 6 px → about 19 px over a second of firm pinching.

Docs updated in the same change: the "search area, not a cut" claim in docs/USER_GUIDE.md and docs/PIPELINE.md was the old behavior stated outright, so both are rewritten; plus the Brush Size range and visibility, and two rows in docs/KEYBOARD.md's Mouse table. docs/CHANGELOG.md untouched.

The second commit is two lines make format collapses on any branch that runs it, kept out of the feature diff so they do not read as part of it.

🤖 Generated with Claude Code

s6stkurz and others added 2 commits September 21, 2026 00:28
…nvas

Two things about the exclusion band shipped in marcinz606#1132 were wrong in use.

A painted stroke was a search area: any connected mark the brush touched
anywhere came back in full, so clipping the edge of a long hair released
the whole hair. The band is now the cut. A mark crossing its rim keeps
the repair on the side the brush missed, so half a hair can be released
without the rest. The rim is feathered over the distance a manual heal
already feathers its own, because the score is a ramp and cutting one at
the brush edge prints the edge as a step. The hair mask is binary and
takes the footprint unfeathered. _touched_components goes with the old
semantics.

The band's width was also unreachable while painting it. It borrows the
heal brush's manual_dust_size, whose slider is hidden unless the Heal or
Scratch tool is active, and an exclusion is painted with no tool active
at all -- so the size was invisible, and Alt+M warned "not available"
instead of moving it. The slider now shows whenever Optical Removal is
on, which fixes the keyboard steps with it, and the canvas sizes the
brush directly: Alt with the wheel, or a pinch while a brush is live.
Alt is the modifier the keyboard already uses for this, so the plain
wheel keeps zooming everywhere and no context loses a zoom route. The
Alt branch sits downstream of the reverse-scroll preference, so one
scroll direction means "more" for both. A pinch reports a fraction per
event and is accumulated into whole pixels, or a slow one would never
move the brush.

Brush Size spans 2-64 px now, from one pair of constants the slider and
the canvas clamp both read. A band has to cover an area of film, which
16 px at the heal reference could not. It is the same value all three
brushes share, so the heal and scratch brushes reach it too.

Armed to exclude on a right-click, the brush is drawn in the band's
amber: nothing else draws it with no tool active, and a pinch that sizes
an invisible brush is a pinch that does nothing visible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`make format` collapses both on any branch that runs it, so they arrive
as noise in the next unrelated diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant