Skip to content

✨ feat(exclusion): mask burned-in overlay regions - #166

Draft
smb wants to merge 21 commits into
mainfrom
feat/issue-80-exclusion-regions
Draft

smb wants to merge 21 commits into
mainfrom
feat/issue-80-exclusion-regions

Conversation

@smb

@smb smb commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

Closes #80.

A burned-in overlay — a composited Twitch chat box, an alert box, a webcam
frame, a bystander at the frame edge — is read as foreground and survives into
the cutout. Crop cannot remove it when it sits in front of the subject, and
the issue records that a largest-component filter was measured on a real clip
to keep the overlay at one sample and delete the subject's raised hand at two
others.

This adds exclusion regions: several rectangles the user drags over the source
frame, forced to alpha 0 after segmentation.

The MatteLoop main window with an exclusion region drawn on the source canvas

Why after segmentation, not before

The crop is part of the cut key (core/fingerprints.py), so masking the input
would re-segment the whole clip on every drag — minutes per adjustment. And
rembg models judge the frame globally, so blanking a region changes the model's
reading of everything else. Zeroing alpha afterwards is deterministic, and it
reuses the stored cut set: Render and Rebuild never re-segment because of a
region. The preview does re-run the model when it is refreshed, because only
the displayed image is retained; that limit is stated rather than papered over.

How it is put together

  • One seam. core/exclusion.py holds the only mapping (cut_exclusions,
    which translates by the crop origin, clips to the crop, sorts and
    de-duplicates) and the only zeroing (exclude_alpha). Regions are stored in
    oriented source pixels; stored cut frames are crop-sized, so the translation
    exists once rather than at each call site.
  • Six consumers. Preview bounds, the render loop's union, the rebuilt
    union, encoder staging, the Transform group's framed size, and the result
    player. The first draft of the design listed five and missed the player —
    which would have looped the overlay the encoder removes. The module docstring
    enumerates all six and the three producers behind them.
  • Stage before masking. The render loop writes the cut frame, then masks in
    place, so the stored set stays unmasked and reusable under any regions and no
    second full-resolution RGBA buffer is allocated.
  • The identities carry the effective boxes only when non-empty, so every
    cut set and union record written by 0.4.1 stays a byte-identical hit for a
    user with no regions. Updating invalidates nothing.
  • cached_union replaces three checks that had drifted apart. Two compared
    the fingerprint and the threshold; the UI's _resolve_union compared only the
    threshold and never read metadata.fingerprint. That was wrong before this
    branch — a region merely made it reachable.
  • Trim degrades instead of aborting. If nothing survives the regions, the
    render frames the untrimmed canvas and records a note. The empty-mask signal
    is distinct from INVALID_FRAMING, which also covers a non-RGBA frame and
    mismatched canvas sizes, so a corrupt frame still fails loudly.
  • Regions carry across a source change and are clipped — intersected, not
    slid — to each new source, so the rectangle the canvas paints and the
    rectangle the renderer masks are the same one. Sliding, which is what
    clamp_crop does to a crop, would move an exclusion onto wanted content.
  • The canvas uses a mode switch, not an extended hit-test list: in region
    mode the crop has no hit targets, in crop mode the regions are painted but
    inert, so a region lying over a crop handle is never ambiguous. Edits are
    dispatched once on release, never per pointer move — _schedule_facts
    starts a QThread with no debounce and each dispatch rewrites about twelve
    QSettings keys, so a 200-sample drag would otherwise mean 200 threads and 200
    cut scans.

Review trail

The design (docs/designs/exclusion-regions.md) was written, independently
reviewed, rejected, and revised; the implementation was then independently
reviewed and rejected again. Both rounds are worth knowing about:

  • The design review found the empty-union catch would have swallowed corrupt
    frames, that one dispatch per pointer move was a worker storm, and that
    persisted regions would be painted where they do not apply.
  • The implementation review found that the reducer discarded raw edits instead
    of merely skipping recomputation, so Clear silently did nothing for a
    region lying outside the crop; that source-load clipping never reached
    persistence, making the saved value depend on whether the user happened to
    touch another control; and that five tests asserted something other than
    their name
    and passed with their production code reverted. The Clear test
    is the instructive one: it checked only that a command was emitted and never
    sent it through the reducer, so it stayed green while the feature was broken.

Both rounds are fixed in this branch, each with a test that fails without its
fix.

Depends on #165

That branch fixes the oriented-to-widget mapping, which this feature needs for
its painted-equals-masked guarantee. It is in this history until #165 lands;
rebase onto main afterwards.

Guardrail baseline

Two entries raised by hand, each for exactly the lines this change adds, rather
than with --update — which would also have recorded unrelated drift in four
modules that shrank since the baseline was written:

  • core/state.py 882 to 883: the one SourceLoaded line applying the clip.
  • ui/inspector.py 897 to 904: import, construction, signal, apply, the form
    row, and two tab-order entries.

jobs/render.py is unchanged at 2886 lines: the four new call sites are offset
by the three union checks collapsing into one predicate.

Verification

uv run ruff check .                          All checks passed!
uv run mypy src                              no issues found in 123 source files
uv run mypy --platform linux src             no issues found in 123 source files
uv run mypy --platform win32 src             no issues found in 123 source files
uv run python scripts/check_guardrails.py    Guardrail check passed
QT_QPA_PLATFORM=offscreen uv run pytest -q   2086 passed, 4 skipped in 51.91s

Translation catalogues: 535 strings, 535 finished, 0 unfinished in both
languages. README and screenshots regenerated.

smb added 8 commits September 12, 2026 10:38
- 🐛 Defects:
  - `_orientation_transform` built its viewport with the raw source width
    for rotation 0 and 180 and applied `pixel_aspect` only in the 90/270
    branch, while `MediaTransform.oriented_display_size` applies it to the
    width for 0 and 180. `MediaTransform.scale` then fitted against a box
    the mapping did not use, so `oriented_rect_to_source_rect` returned a
    rectangle outside the frame: the oriented `CropSpec(8, 0, 8, 8)` on a
    16x8 source at pixel aspect 2 mapped to `x=8 y=-4 w=8 h=16`, twice the
    height of an 8 px frame and starting above it. Six of sixteen
    rotation/pixel-aspect combinations were wrong, every one of them a 0 or
    180 rotation with a pixel aspect other than 1 — so the crop rectangle
    the user drags sat somewhere other than the region that was cut on an
    anamorphic source without rotation.
  - The viewport keeps the oriented display axes from
    `MediaTransform.oriented_display_size` but rounds the pixel-aspect width
    with the decoder's exact rule. Pixel aspect 1 is unchanged, which is what
    the existing crop, staging and controller tests pin.
  - Fractional display widths now use the decoder's round-half-up extent, so
    a 720x480 source at pixel aspect 32/27 maps against 853 decoded pixels
    rather than an 853.3333-pixel canvas.
- 🧪 Tests:
  - Two matrices over rotation {0, 90, 180, 270} and pixel aspect
    {1.0, 2.0, 1.5, 0.75} in `tests/core/test_crop.py`: the mapped
    rectangle stays inside the source frame and round-trips back through the
    display geometry, and `oriented_point_from_widget` round-trips through
    `build_crop_geometry`. Twelve of the new cases fail without the fix. The
    existing rotation-270/pixel-aspect-2 case is one of the ten combinations
    that already passed, which is why the gap survived.
  - A four-rotation fractional-aspect matrix asserts the viewport against
    `_display_dimensions` itself rather than a written-out 853, so it fails
    if either side's rounding drifts from the other.
- 🔧 Guardrails: third consecutive `fix:` touching `core/crop.py`, agreed
  with the maintainer rather than taken silently. It is not a hardening
  spiral: `a296a3b` bounded `fit_crop_aspect`, `cc9ec01` changed
  `clamp_crop`, and this changes `_orientation_transform` — three
  independent defects in three functions, and this one removes the
  duplicated viewport expression instead of adding a guard. The round-half-up
  rule does remain in two layers, because `core` may not import `jobs`; the
  fractional-aspect test is what holds the two copies together.

Trigger: measured `oriented_rect_to_source_rect` over a synthetic 16x8
source at pixel aspect 2, rotation 0 — it returned a source rectangle at
y=-4 with height 16; a 720x480 source at pixel aspect 32/27 produced a
decoded width of 853 while the canvas extent was 853.3333.

Refs #164
- 📝 Design for user-drawn exclusion rectangles that force alpha 0 after
  segmentation, so a burned-in overlay does not survive into the cutout:
  - `FramingSpec.exclusions: tuple[CropSpec, ...]`, stored in oriented
    source pixels and mapped into cut space once, in a new
    `core/exclusion.py`. A region outside the crop produces no box and
    stales nothing.
  - The effective cut-space boxes enter the render, preview and union
    fingerprints only when non-empty, so cut sets and union metadata
    written by 0.4.1 stay byte-identical hits for users without regions.
  - Six consumers read cut alpha for framing; the enumeration and the
    three producers behind them are recorded, including the result
    player, which the first draft missed.
  - `_resolve_union` never reads `metadata.fingerprint` while the two
    render-side checks do — one predicate replaces all three.
  - Trim degrades to the untrimmed canvas when no pixel survives the
    regions, rather than aborting the job.
  - Regions are carried across a source change and clipped, not slid, to
    the new frame, so what is painted and what is masked are the same
    rectangle by construction.
- 📝 Records a prerequisite defect found while verifying the design: the
  oriented viewport in `core/crop.py` omits the pixel aspect for rotation
  0 and 180, which mispaints the crop rectangle on anamorphic sources.
  Scoped out as its own fix.

Refs #80
- 📝 Scope:
  - Records exclusion regions as in scope: several user-drawn rectangles
    over the source frame, forced to alpha 0 after segmentation. No
    existing setting addressed a burned-in overlay — `Crop` takes the
    subject with it when the overlay sits in front of the chest, and a
    largest-component filter was measured on a real clip to keep the
    overlay at one sample and delete the subject's raised hand at two
    others.
  - Records the deviation that the rectangles are a parameter carried
    across a source change rather than a per-source value, and that they
    are clipped rather than slid to each new source, so the painted and
    the masked rectangle are the same by construction.
- 📝 G8 makes this file authoritative, so the entry lands before any
  implementation rather than as a tidy-up after it.

Refs #80
- ✨ Features:
  - `FramingSpec.exclusions` carries user-drawn rectangles in oriented
    source pixels. `core/exclusion.py` is the only place that maps them:
    `cut_exclusions` translates by the crop origin, clips to the crop and
    returns a sorted, de-duplicated tuple, so a region outside the crop
    yields no box at all and two orderings of the same regions are the
    same request. `exclude_alpha` is the only place that zeroes.
  - The effective cut-space boxes enter the render, preview and union
    identities only when non-empty, so every cut set and union record
    written by 0.4.1 stays a byte-identical hit for a user with no
    regions — the update does not invalidate a single cached cut.
- 🔄 Refactor:
  - `cached_union` replaces three checks that had drifted apart:
    `PreviewService._matching_union` and `RenderService._rebuild_union`
    compared the fingerprint and the threshold, while the UI's
    `_resolve_union` compared only the threshold and never read
    `metadata.fingerprint` at all.
  - `RenderService` stages each cut *before* `exclude_alpha` mutates it,
    so the stored set stays unmasked and reusable under any regions and
    no second full-resolution RGBA owner is allocated.
  - `_iter_rebuild_frames` and the encoder's exclusion handling live in
    `jobs/transform_stage.py` rather than the render orchestrator, and
    `stage_encoder_frames` is split into named helpers — it was 66 lines
    against the 60-line budget. `jobs/render.py` is unchanged at 2886
    lines, so the frozen module does not grow.
- 🐛 Degradation:
  - `union_alpha_bounds_or_none` returns `None` only for a genuinely
    empty mask; a non-RGBA frame or mismatched canvas sizes still raise,
    because `INVALID_FRAMING` covers all three and catching it would have
    reported corrupt frames as an empty selection. Trim then frames the
    untrimmed canvas and records a note instead of aborting the render.
- 🧪 Tests:
  - `test_identities_without_exclusions_match_the_previous_schema` pins
    the 0.4.1 compatibility above; it fails if the exclusions key is
    emitted unconditionally.
  - `test_render_stores_unexcluded_cuts_but_unions_and_encodes_without_
    the_region` pins stage-then-mask, and
    `test_render_peak_rgba_owner_count_is_unchanged_by_exclusions` pins
    that it costs no extra owner.
  - `test_union_alpha_bounds_or_none_propagates_invalid_frame_errors`
    pins that the degradation does not swallow corrupt frames, and
    `test_render_still_refuses_a_trimmed_box_below_the_minimum_dimension`
    keeps the minimum-dimension refusal that the empty-mask change could
    have taken with it.
  - `test_cut_exclusions_is_order_independent_and_deduplicated`,
    `test_rebuild_with_a_region_misses_the_cached_union_and_recomputes_it`
    and `test_stage_encoder_frames_zeroes_excluded_boxes_before_framing`
    cover the remaining seam behaviour.

Steps 2 and 3 of `docs/designs/exclusion-regions.md`; no UI yet.

Refs #80
- ✨ Features:
  - `ParameterState.exclusions` with `ExclusionsChanged`, persisted under
    `parameters/exclusions`. The reducer compares *effective* cut-space
    boxes rather than raw tuples, so nudging a region that lies entirely
    outside the crop leaves the preview valid.
  - `clip_exclusions` intersects carried regions with each newly loaded
    source and drops those that fall outside it. Intersection, not the
    inward slide `clamp_crop` applies to a crop: sliding an exclusion
    would move it onto wanted content. After the clip, the rectangle the
    canvas would paint and the rectangle the renderer masks are the same
    one.
  - The Transform group and the result player now honour regions, which
    are consumer sites 5 and 6 of the design's six. Without site 6 the
    looping player would show the overlay the encoder removed.
- 🐛 Defects:
  - `_resolve_union` accepted cached union metadata on a matching alpha
    threshold alone and never read `metadata.fingerprint`, while the two
    render-side checks read both. It now shares the single `cached_union`
    predicate, so the Transform group's framed size no longer trusts a
    union recorded under different inputs. This was wrong before
    exclusions existed; a region merely made it reachable.
- ⚙️ Guardrails:
  - `core/state.py` 882 to 883 lines: the one `SourceLoaded` line that
    applies the clip. Raised in the baseline by hand for that entry alone
    rather than with `--update`, which would also have recorded unrelated
    drift in four modules that shrank since the baseline was written.
  - `ui/transform_stage.py` is 750 lines against the 800 budget.
- 🧪 Tests:
  - `test_loading_a_smaller_source_clips_carried_regions_and_drops_those_
    outside` pins the clip, and
    `test_moving_a_region_outside_the_crop_does_not_invalidate_the_preview`
    pins that the reducer compares effective boxes.
  - `test_facts_ignore_cached_union_metadata_whose_fingerprint_does_not_
    match` fails on the previous threshold-only check.
  - `test_frame_load_worker_masks_exclusion_boxes_before_framing` and
    `test_region_change_recomputes_facts_and_reloads_player_frames_without
    _the_region` pin sites 5 and 6.
  - `test_malformed_exclusions_preference_falls_back_to_empty_regions`
    pins that a damaged settings value degrades instead of raising.

Steps 4 and 5 of `docs/designs/exclusion-regions.md`. Regions still cannot
be drawn; the canvas and its controls are steps 6 and 7.

Refs #80
- ✨ UI:
  - `CropCanvas.set_exclusion_edit` makes region editing a mode switch: crop mode has no region targets and region mode has no crop targets, so overlaps do not become an N×9 priority list in `build_crop_geometry`.
  - `mouseMoveEvent` keeps the in-progress region local; `mouseReleaseEvent` emits exactly one `ExclusionsChanged`, turning a 200-sample drag into one dispatch and one facts worker instead of 200 dispatches, threads, and about 2,400 settings writes.
  - Arrow keys nudge by 1 source pixel, Shift by 10, Delete/Backspace removes the selected region, and Escape abandons a drag or clears selection; `Inspector.tab_widgets` keeps Edit before Clear in the keyboard route.
  - `ExclusionControls` keeps Edit, Clear, and the textual `%n region(s)` count in the inspector. The visible Edit label brings the row minimum to 122 px for the 322 px viewport while descriptive accessible names, tooltip, and count text remain intact.
- 🧪 Tests:
  - `test_a_region_drag_dispatches_exactly_once_on_release` pins no command during 20 moves and one command on release; the rubber-band, 8×8 discard, mode isolation, selection, move, delete, keyboard, exit, and accessible-bounds tests pin the remaining canvas affordances.
  - `test_exclusion_controls_toggle_edit_mode_and_clear_regions` pins the three controls, their accessible names, the tooltip, clear behavior, and disabled state while editing is unavailable.
- 🌐 Catalogues:
  - `pyside6-lupdate` extracted 535 source texts in both catalogues; `pyside6-lrelease` reports 535 finished and 0 unfinished for English and German, with `Edit` written as `Bearbeiten` in German.

Refs #80
✨ Feature
- ✨ Seed one exclusion region through the screenshot script’s existing state seam.

📝 Docs
- 📝 Describe exclusion regions beside the cleanup controls.
- 🖼️ Regenerate all tracked README screenshots from the real widgets.

Refs #80
- 🐛 Defects:
  - `_reduce_exclusions` returned the original state whenever the old and
    new regions produced identical effective cut boxes, which threw the
    raw tuple away with the recomputation. A region lying outside the
    current crop could therefore not be cleared at all — Clear emitted
    `ExclusionsChanged(())`, both effective sets were empty, and the
    reducer ignored it. Deleting one of two effectively identical regions
    and reordering regions failed the same way, and a region moved outside
    the crop vanished on release although the design requires it to stay
    painted. Every valid tuple is now stored; the effective boxes decide
    only whether the preview is invalidated.
  - With `crop is None` the tuple is stored without invalidation, because
    there is no preview to invalidate.
  - `SourceLoaded` clipped carried regions into `state.parameters`, but
    settings were written only through `_dispatch_parameter`, so the
    clipped value never reached disk: quitting without touching another
    control restored the old region on restart, while touching any
    unrelated control wrote the clipped tuple and made the loss permanent.
    `_source_loaded` now persists the reduced parameters, so what is saved
    no longer depends on what else the user happened to change.
- 🧪 Tests:
  - Five tests asserted something other than their name and passed with
    their production code reverted. The worst is the one that should have
    caught the defect above: `test_exclusion_controls_toggle_edit_mode_and
    _clear_regions` checked only that Clear emitted a command and never
    sent it through the reducer, so it stayed green while Clear did
    nothing. It now routes Clear through the reducer and asserts the tuple
    is empty.
  - `test_region_mode_gives_the_crop_no_hit_targets` clicked without
    moving, so it would have passed with crop dragging still enabled; it
    now drags. `test_leaving_region_mode_restores_crop_handles_and_keeps
    _painting_regions` checked geometry keys that exist in both modes; it
    now checks rendered pixels.
    `test_render_peak_rgba_owner_count_is_unchanged_by_exclusions` had no
    unexcluded baseline; it now compares one and checks the stored cuts,
    the output alpha and the union bounds.
    `test_malformed_exclusions_preference_falls_back_to_empty_regions`
    passed even if the setting was never read, because the default is
    already empty; it now proves the setting is read first.
  - A new test crosses settings, `SourceLoaded` clipping and restart, the
    path the persistence defect lived on.
  - The gesture contract is now asserted on every path that can mutate a
    region, not only the drag: resize and rubber band emit once on release
    and never per pointer move, and keyboard nudge, delete and Clear each
    emit once.

Trigger: repro — draw an exclusion region outside the current crop, press
Clear, and observe that nothing happens.

Refs #80
Comment on lines +1378 to 1381
if request.framing.trim and union is None:
notes.append("trim skipped: no pixel above the alpha threshold "
+ "survives the exclusion regions")
plan = framing_plan((manifest.width, manifest.height), union, request.framing)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Quality: Empty-union trim note misattributes cause to exclusion regions

_encode_snapshot appends the note "trim skipped: no pixel above the alpha threshold survives the exclusion regions" whenever request.framing.trim and union is None, but that condition is also reached when the segmentation itself produces nothing above the alpha threshold and there are no exclusion regions at all. In that case the render now degrades to an untrimmed canvas (previously framing_plan raised INVALID_FRAMING) and records a note that wrongly blames exclusion regions, obscuring an actually-empty or misconfigured segmentation. Consider wording the note conditionally on whether any exclusions were applied (e.g. only mention exclusions when request.framing.exclusions is non-empty), so a genuinely empty mask is reported for what it is.

Was this helpful? React with 👍 / 👎

- 🐛 Defects:
  - A gesture kept its own region index while the canvas cleared only the
    selection when the tuple shrank, so releasing a drag after the region
    under it was deleted recreated that region at index 0 and raised
    `IndexError` at index 1. `_reconcile_exclusion_state` now cancels a
    drag or rubber band when the source, the index or the region's value
    changes underneath it. Selection lives in the canvas while the regions
    live in the reducer, and this is the seam where the two disagreed.
  - Region painting and presses were gated on `model.crop_enabled`, so
    unchecking Crop hid the regions and made Edit inert while it stayed
    checked — and the hidden regions still removed pixels from the output.
    Regions now have their own activation rule.
  - `TransformStageController` compared raw framing tuples, so moving a
    region that lies entirely outside the crop rescheduled the facts worker
    and reloaded every frame of the result player: up to 300 PNGs re-read
    for an edit with no effect on a single pixel. `_effective_framing
    _changed` compares the mapped cut boxes while the raw framing is still
    stored, the same distinction the reducer makes.
  - `action_availability` left exclusions out of its comparison although
    `ParametersReset` clears them, so Reset stayed disabled after drawing a
    region and only woke up once an unrelated parameter changed.
- ⌨️ Accessibility:
  - A keyboard-only user could not create a region, could not select one,
    and therefore could not delete an individual region — full keyboard
    reachability is a V1 scope commitment, so this was a scope defect
    rather than a rough edge. Insert, N and Space create, Tab and Backtab
    cycle the selection, Delete removes the selected region.
  - A nudge compared the candidate against the *crop* rather than the
    region being edited, so a region that happened to coincide with the
    crop could not be moved that last pixel.
  - The canvas announced "Crop bounds" while in region mode; it now
    announces the mode and the selected region.
- 🔄 Refactor:
  - The region mode moved out of `ui/crop_canvas.py` into a new
    `ui/exclusion_canvas.py`, leaving 410 and 594 lines rather than one
    module pressing against the 800-line budget.
- 🧪 Tests:
  - Three seams had no test crossing them, each with a production edit that
    would have broken behaviour while the suite stayed green: deleting the
    `main_window` signal connection left Edit checked while dragging still
    edited the crop; swapping `request.crop` for an origin-zero crop of the
    same size misplaced every exclusion, which no fixture caught because
    they all used origin-zero crops; and writing a union fingerprint
    computed with empty exclusions kept the pixels right while missing the
    cache forever after. One test each now crosses them.
  - `test_region_state_change_cancels_an_in_flight_drag` pins deletion,
    replacement and reindexing under a live gesture;
    `test_region_change_outside_the_cut_does_not_recompute_or_reload_frames`
    pins that an ineffective edit costs nothing;
    `test_arrow_key_can_move_a_region_onto_the_crop_bounds` pins the final
    pixel; `test_region_mode_keyboard_creates_a_region` and
    `test_region_mode_keyboard_cycles_selection_and_deletes_it` pin the
    keyboard path end to end.
- 📝 The German catalogue carries the new announcement strings; 0 unfinished
  in both languages.

Trigger: repro — press and hold on a region to start a drag, Tab to the
inspector's Clear and activate it with Space, then release the mouse.

Refs #80
@smb

smb commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

A third independent review ran over the branch and requested changes; dda9e09 answers it. Two reviews had already passed over this code, so these are the defects that survived both.

A cleared region could come back, or crash. A gesture kept its own region index while the canvas cleared only the selection when the tuple shrank. Press and hold on a region to start a drag, Tab to Clear and activate it with Space, then release: at index 0 the deleted region was recreated, at index 1 it raised IndexError. Selection lives in the canvas while the regions live in the reducer, and that is the seam; _reconcile_exclusion_state now cancels an in-flight gesture when the source, the index or the region's value changes underneath it.

Unchecking Crop disabled exclusion editing while Edit stayed checked. Region painting and presses were gated on model.crop_enabled, so the regions vanished from the canvas and Edit did nothing — while the hidden regions still removed pixels from the output. Regions now have their own activation rule.

A keyboard-only user could not create, select or individually delete a region. Full keyboard reachability is a V1 scope commitment, so this was a scope defect rather than a rough edge. Insert, N and Space create; Tab and Backtab cycle; Delete removes. The canvas also announced "Crop bounds" in region mode and now announces the mode and the selected region.

An edit with no effect on a single pixel reloaded the whole result animation. TransformStageController compared raw framing tuples, so moving a region entirely outside the crop rescheduled the facts worker and re-read up to 300 PNGs. It now compares the mapped cut boxes while still storing the raw framing — the same distinction the reducer makes.

Reset stayed disabled after drawing a region, because action_availability left exclusions out of its comparison although ParametersReset clears them. And a keyboard nudge compared the candidate against the crop rather than the region being edited, so a region coinciding with the crop could not move that last pixel.

Three seams had no test crossing them, each with a production edit that would have broken behaviour while the suite stayed green: deleting the main_window signal connection left Edit checked while dragging still edited the crop; swapping request.crop for an origin-zero crop of the same size misplaced every exclusion, which no fixture caught because they all used origin-zero crops; and writing a union fingerprint computed with empty exclusions kept the pixels right while missing the cache forever after. One test each now crosses them.

The region mode moved out of ui/crop_canvas.py into a new ui/exclusion_canvas.py — 410 and 594 lines, rather than one module pressing against the 800-line budget. No guardrail baseline entry was added for it.

Verification: ruff, mypy on all three platforms, guardrails, 2100 passed, 4 skipped. Catalogues 536 strings, 0 unfinished in both languages.

Still to come on this branch: a right-click context menu on the source frame, so a region can be created where the user is looking rather than only from the inspector toggle.

Comment thread src/matteloop/ui/exclusion_canvas.py Outdated
smb added 12 commits September 12, 2026 13:29
- ✨ Features:
  - Right-clicking the source frame now offers the region actions where the
    user is looking, instead of only through the inspector's Edit toggle.
    `ExclusionCanvas.contextMenuEvent` builds the menu from what lies under
    the pointer: *Exclude this area* over empty canvas, *Remove this
    region* over a region, plus *Remove all regions* whenever any exist and
    a checkable *Edit exclusion regions* that mirrors the inspector.
  - `_create_exclusion` places a region sized at 20% of the source frame,
    clamped to it, selects it and enters region mode. The action creates a
    default-sized region rather than starting a rubber band, because
    beginning a drag gesture from a menu is unreliable; the handles that
    already exist resize it.
  - Every entry is one `ExclusionsChanged` through the reducer, never a
    direct canvas mutation, so the menu cannot drift from the inspector.
  - The menu is absent while editing is unavailable, rather than offering
    entries the reducer would silently drop — this is the first context
    menu in the application, so it sets the pattern.
  - `contextMenuEvent` carries Shift+F10 and the Menu key, and the keyboard
    path opens at the focused region rather than at the widget origin.
- 🔄 The two menu shapes are built by one path with the entries in the same
  order in both. A region under the pointer implies at least one exists, so
  a single condition covers what were two duplicated branches that also
  placed *Remove all regions* differently in each.
- 🧪 Tests:
  - `test_context_menu_lists_region_actions_for_empty_and_occupied_canvas
    _areas` pins both menu shapes and the checked mode state.
  - `test_context_menu_excludes_a_clamped_default_region_with_one_dispatch`
    pins one dispatch, the clamp, the selection and the mode change;
    `test_context_menu_removes_the_region_under_the_pointer` and
    `test_context_menu_removes_all_regions_with_one_dispatch` pin targeted
    and bulk deletion at one dispatch each.
  - `test_keyboard_context_menu_uses_the_focused_region_position` pins the
    keyboard route and a position that is not the widget origin;
    `test_context_menu_offers_no_actions_when_editing_is_unavailable` pins
    the capability gate;
    `test_source_context_menu_edit_action_keeps_inspector_mode_in_sync`
    pins that both surfaces agree.
  - A `QAction` carries no accessible-name API — assistive technology reads
    the action's text — so a dynamic property of that name was a no-op that
    looked like accessibility work. It is gone, and the test that asserted
    it now asserts the text is present instead.
- 📝 Decision 6 of `docs/designs/exclusion-regions.md` records the menu, why
  it creates a default-sized region, and that the inspector row remains the
  discovery and keyboard surface. Catalogues carry the four new strings,
  540 total, 0 unfinished in both languages.

Refs #80
- 🐛 Defects:\n  - Region creation now cancels a different in-flight drag before the new selection can be nudged or released.\n  - Bracket keys cycle regions without entering Qt's focus-traversal path, and crop handles stay hidden when Crop is disabled.\n- 🧪 Tests:\n  - The drag regression covers N, a reducer presentation update, an arrow nudge and the later mouse release.\n  - The keyboard regression uses a real window with a second focusable control and checks the Crop-off rendering.\n\nTrigger: repro — drag an existing region, press N, nudge the new region and release; with another focusable widget present, Tab moves focus instead of selecting a region.\n\nRefs #80
- 🐛 Defects:\n  - Context-menu actions now revalidate editability when triggered, and removal resolves the captured CropSpec instead of a stale tuple index.\n  - Keyboard context menus pass the selected region directly, so overlapping regions share one deletion target with the Delete key.\n  - Open menus are deleted after dismissal, including the no-action path.\n- 🔄 Structure:\n  - Context-menu behavior moves to ;  stays below the 800-line budget.\n- 📝 Corrections:\n  -  claimed every context-menu entry emits ; Edit changes UI-local mode and emits none, so that commit record is corrected here.\n- 🧪 Tests:\n  - Keyboard-menu tests use Qt's Menu key, pin the focused position, and cover overlapping deletion.\n  - Regression tests cover stale permissions, stale targets and four open-and-dismiss cycles.\n\nTrigger: repro — retain a menu, change editability or replace its regions, then trigger the old action; open a keyboard menu over overlapping regions and remove the selection; repeat open and dismiss four times.\n\nRefs #80
- 📝 Corrections:
  - The checkable Edit action changes UI-local mode and emits no ExclusionsChanged; only region mutations dispatch the reducer command.
  - This explicitly corrects the overstated every-entry claim in 7500ee8 without rewriting the pushed-history commit.

Refs #80
- 🐛 Defects:
  - Match retained remove actions to the captured CropSpec object, so an equal duplicate that disappeared cannot delete its survivor.
- 🧪 Tests:
  - Cover the duplicate-target race and dismiss four real QMenu.exec openings.

Trigger: repro — retain a remove action for one of two equal-valued regions, replace it with the other, and open and dismiss four context menus.

Refs #80
- 🐛 Defects:
  - Paint each region's `cut_exclusions` intersection with the existing solid red treatment and fade the inert remainder with a dashed outline.
  - Keep full-region geometry for handles and announce partial or wholly ineffective selections.
- 🧪 Tests:
  - Render and compare effective, ineffective, inside, and outside canvas regions; assert the painted fragment is reconstructed from `cut_exclusions`.
  - Cover truthful accessibility announcements for partial and wholly outside regions.
- 📝 Docs:
  - Record the effective/inert painting rule in Decision 6 and regenerate English/German Qt catalogues.

Trigger: repro — draw a region across the crop edge over a bystander's face; the result shows only a narrow edge bite while most of the marked face remains.
Refs #80
- 🐛 Defects:
  - Removed `compact_field` from the count label and placed it on a full-width line, keeping “1 Region” visible at 322 px.
  - Connected canvas selection to a readout of stored oriented-source coordinates and classified crop overhang through `cut_exclusions`; no selection clears stale values.
- 🧪 Tests:
  - Added one behavior test for count width and three for selected, empty, and overhanging readouts.
- 📝 Catalogues:
  - Regenerated English/German sources and binaries with 547 finished entries and 0 unfinished entries each.

Trigger: repro — at the 322 px inspector viewport, the Exclusions row painted “1 Region” as “1 Re”.
Refs #80
- 🐛 The selected-region readout was assembled from a stem plus the
  appended fragments `"; entirely outside crop"` and
  `"; partly outside crop"`. A fragment carries no grammar of its own, so
  it forces every language into English word order and cannot be
  translated at all by one that has to lead with the qualifier or
  restructure the clause. German happens to append too, which is why it
  looked correct.
- 🛠 Each case is now one complete literal sentence chosen by
  `_selection_template`, matching how the canvas announcement added in
  `70293e0` already did it. `_crop_relation` names the three cases from
  the same `cut_exclusions` mapping the renderer uses, so the readout
  cannot disagree with the painting.
- 📝 Catalogues regenerated: 547 strings, 547 finished, 0 unfinished in
  both languages; the two fragments are gone rather than left obsolete.

Trigger: review of the strings added in `2cf9c8a` — three catalogue
entries for one sentence.

Refs #80
- 📝 Decision 7 adds regions that blank the frame *before* segmentation, so
  a bystander at the frame edge is never read as a person at all. Zeroing
  alpha afterwards removes them from the output but leaves the model
  treating them as foreground, which distorts the matte of the wanted
  subject — the post path cannot fix that.
  - One global switch rather than a flag per region: about 60 lines
    against about 200, and the request-level shape is identical, so it can
    grow into a per-region flag later without a migration.
  - Pre-model is a superset of post, never an alternative: with the switch
    on, a region acts in both stages.
  - The boxes enter the cut cache key only when non-empty, so a user with
    no pre-model region keeps every cut set stored by 0.4.1 byte for byte.
    Verified case by case against the literal key pinned in
    `tests/core/test_fingerprints.py`.
  - The fill identifier lives inside the key's value, so changing the fill
    later invalidates only pre-model sets.
- 📝 Records a trap found while verifying it: `_validate_cache_inputs`
  accepts only exactly known keys, so without a validator change every
  manifest carrying a pre-model region would be rejected as invalid and
  the cache would silently never be reused.
- 📝 The fill is to be measured, not asserted. A hard black rectangle is
  itself an edge and a model may read an edge as an object, which would
  make the feature worse than not using it. Six candidates, a paired
  harness against a real model, a metric, a rejection rule, and
  `imagenet-mean` as the fallback — the one candidate with a
  model-independent argument, being the normalisation zero of twelve of
  the thirteen models.
- 📝 Marks Decisions 1 to 6 as implemented; the document still claimed no
  implementation existed while fifteen commits had landed.

Refs #80
- ✨ Features:
  - `blank_model_input` fills model boxes with `imagenet-mean` `(124, 116, 104)` while preserving alpha; `_produce_cut_frame` blanks before segmentation and zeroes the result afterward.
  - `_model_exclusions` emits immutable cut-key `boxes` only for effective non-empty regions, preserving the canonical no-region key; `_validate_cache_inputs` accepts exactly the optional manifest schema.
  - `ExclusionsBeforeModelChanged` selects segmentation invalidation, while effective empty regions remain inert; `request_for_workspace` reverses manifest cut boxes into source-space `CropSpec` values.
- 🧪 Tests:
  - `test_segmentation_spec_rejects_exclusions_that_are_not_crop_specs` pins segmentation validation; `test_blank_model_input_fills_each_box_and_nothing_else` and `test_blank_model_input_with_no_boxes_leaves_the_image_byte_identical` pin fill scope and empty-path identity.
  - `test_cut_key_without_model_exclusions_matches_the_canonical_schema`, `test_cut_key_ignores_a_model_exclusion_outside_the_crop_or_clipped_to_nothing`, `test_cut_key_tracks_model_exclusion_boxes_and_the_fill_identifier`, `test_cut_key_inputs_with_model_exclusions_are_recursively_immutable`, `test_preview_identity_tracks_model_exclusions`, and `test_render_and_union_identities_ignore_model_exclusions` pin fingerprint identity and immutability.
  - `test_manifest_accepts_model_exclusions_and_reproduces_the_cut_key`, `test_manifest_rejects_malformed_model_exclusion_boxes`, and `test_manifest_without_model_exclusions_still_validates` pin manifest compatibility.
  - `test_exclusions_before_model_invalidates_the_preview_as_segmentation`, `test_exclusions_before_model_with_no_effective_region_does_not_invalidate`, `test_region_change_invalidates_as_segmentation_while_the_switch_is_on`, `test_parameters_reset_clears_exclusions_before_model`, and `test_exclusions_before_model_settings_round_trip_and_malformed_value_fall_back` pin reducer behavior.
  - `test_render_blanks_model_exclusions_and_zeroes_stored_cut`, `test_render_with_model_exclusions_reads_the_original_pixels_outside_the_box`, `test_render_with_the_switch_off_sends_the_model_the_unblanked_frame`, `test_render_reuses_the_unblanked_cut_set_after_the_switch_is_turned_off`, `test_render_with_whole_frame_model_exclusion_notes_untrimmed_canvas`, `test_preview_shows_no_filler_pixels_inside_a_model_exclusion`, and `test_rebuild_of_a_model_exclusion_set_reproduces_its_key_from_the_manifest` pin the shared pipeline and cache reuse.

Refs #80
- ✨ Features:
  - `ExclusionControls` dispatches `ExclusionsBeforeModelChanged`, disables the inert switch without regions, and names the re-segmentation cost in the checkbox and count label.
  - `ExclusionCanvas` uses a `BDiagPattern` hatch and 2 px red pen for effective pre-model boxes, and appends the literal accessible announcement suffix.
  - `ParameterPresentation`, `CropPresentation`, preferences, and `_render_request` carry the global flag; `scripts/screenshots.py` captures it in the generated main-window image.
- 🎨 Style:
  - README describes the separate cached cut set, and regenerated English/German catalogues contain all 551 finished messages with the four new translations.
- 🧪 Tests:
  - `test_before_model_checkbox_dispatches_and_is_inert_without_regions` and `test_before_model_count_label_names_the_blank` pin dispatch, inert state, checked state, and count wording.
  - `test_model_exclusions_are_painted_hatched_and_announced` pins visible hatching and the accessible suffix; `test_exclusions_before_model_persists_as_a_bool` pins the QSettings bool.
  - `test_parameter_and_crop_presentations_carry_exclusion_regions` and `test_preview_and_render_requests_share_every_inspector_parameter` pin state projection and switch-dependent request tuples.

Refs #80
- ⚙️ `jobs/render.py` 2886 to 2889 lines. `_produce_cut_frame` gains the two
  halves of the pre-model path: `blank_model_input` on the cropped frame
  before it reaches the model, and `exclude_alpha` on the result, because
  rembg composites the input RGB back into what it returns. Both have to
  sit between the crop and the model call, and both are in that function.
- ⚙️ Raised by hand for this one entry rather than with `--update`, which
  would also record unrelated drift in four modules that shrank since the
  baseline was written.

Refs #80
@smb
smb marked this pull request as draft September 12, 2026 20:38
@smb smb added the deferred Parked: work exists but is on hold, not abandoned label Sep 12, 2026
@smb

smb commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

Parked, not abandoned

Converted to draft and labelled deferred at the maintainer's request: the feature does not yet behave correctly in real use. This comment records where it stands so it can be picked up without re-deriving any of it.

What works and is green

Decisions 1–6 of docs/designs/exclusion-regions.md are implemented: the masking seam, the six consumer sites, the fingerprint and union-cache handling, the reducer and persistence, the canvas region mode with its commit-on-release gesture contract, the inspector controls, the source-frame context menu, the readout, and the faded painting of the part of a region that lies outside the crop. 2153 passed, 4 skipped, ruff and mypy clean on all three platforms.

The unresolved defect that caused the pause

Tested on a real clip, the cutout does not match what the region appears to select. Measured so far:

  • The mapping itself is correct. cut_exclusions for a crop of (152, 45, 832, 1035) and a region overhanging the crop's right edge maps to the rightmost ~12% of the cut, and exclude_alpha zeroes exactly those pixels.
  • Auto-trim (Global trim) was off, so the reframing explanation is ruled out: the output is the cut exactly.
  • The capture path uses the same oriented_point_from_widget conversion as the crop, and clamps to the source, not to the crop.

So the defect is somewhere the above does not cover, and it was not found before the pause. The readout added in 2cf9c8a exists precisely so the stored rectangle can be read off rather than estimated from a screenshot — investigation should start there, with the four numbers of an actual region.

Pre-model regions are new and unproven

Decision 7 (blank the frame before the model, so a bystander is never read as a person) is implemented and unit-tested, but has never been run against a real model. Two things are open by design:

  • The fill is unmeasured. imagenet-mean is the design's fallback, chosen because it is the normalisation zero of twelve of the thirteen models — not a measured winner. Decision 7.3 defines six candidates, a paired harness, a metric and a rejection rule. A hard-edged fill may itself be read as an object, which would make the feature worse than not using it. That measurement has not been run.
  • The interaction between a pre-model and a post-model region on the same frame is designed but unexercised.

Review trail

Four independent review rounds, each of which found real defects the previous one missed: the design was rejected once and revised; the implementation was rejected twice. Findings are recorded in the comments above. The last round's findings are all addressed.

Before resuming

  1. Push the local commits. Eleven commits after 7500ee8 exist only on the maintainer's machine at the time of writing; this PR does not show them.
  2. Read the region's stored numbers from the inspector readout and re-measure the real defect with actual values.
  3. Run the fill measurement from Decision 7.3 before trusting the pre-model path.

Depends on #165, which is not parked and stands on its own.

@sonarqubecloud

Copy link
Copy Markdown

Comment on lines +57 to +67
available = editable and presentation.duration is not None
self.edit_button.setEnabled(available)
self.clear_button.setEnabled(available and count > 0)
self.before_model_checkbox.setEnabled(available and count > 0)
blocked = self.before_model_checkbox.blockSignals(True)
try:
self.before_model_checkbox.setChecked(
presentation.exclusions_before_model
)
finally:
self.before_model_checkbox.blockSignals(blocked)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Edge Case: before-model flag persists silently across a clear-all

When "Blank before the model" is on and the user then removes all regions, apply disables the checkbox (available and count > 0) but leaves exclusions_before_model set to True in state and the box checked-but-greyed. Adding a new region later re-enables the flag, so the next region is silently re-segmented and blanked before the model rather than treated as a normal post-segmentation exclusion — behavior the user did not re-request and could not toggle off during the zero-region window. Consider clearing exclusions_before_model when the region set becomes empty, or keeping the checkbox interactive so the preference can always be turned off.

Was this helpful? React with 👍 / 👎

@gitar-bot

gitar-bot Bot commented Sep 12, 2026

Copy link
Copy Markdown
CI failed: Type-checking errors due to missing arguments in UI crop functions, and a Python segmentation fault during PySide6 UI test execution.

Overview

Two major failures were detected in the CI pipeline: static type-checking errors via mypy and a Python segmentation fault (exit code 139) during GUI/PySide6 test execution.

Failures

Missing Named Arguments in Type Checker (confidence: high)

  • Type: build
  • Affected jobs: 103619804291
  • Related to change: yes
  • Root cause: The mypy type checker found missing 'display_width' named arguments in function calls within src/matteloop/ui/crop_canvas.py and src/matteloop/ui/exclusion_canvas.py.
  • Suggested fix: Provide the required 'display_width' named argument in the corresponding function calls.

Segmentation Fault During UI Tests (confidence: high)

  • Type: test
  • Affected jobs: 103619804280
  • Related to change: yes
  • Root cause: A segmentation fault (SIGSEGV) occurred inside PySide6/Qt bindings at src/matteloop/ui/preview_canvas.py line 127 during test execution, likely caused by improper widget deletion, uninitialized Qt objects, or memory issues in the offscreen test environment.
  • Suggested fix: Inspect src/matteloop/ui/preview_canvas.py around line 127 for dangling references or incorrect Qt object lifecycle management.

Summary

  • Change-related failures: 2 failures (type-checking build failure and a PySide6 test segmentation fault)
  • Infrastructure/flaky failures: 0
  • Recommended action: Fix the missing named arguments in the UI canvases and investigate the PySide6 object lifecycle / segmentation fault in preview_canvas.py.
Code Review 👍 Approved with suggestions 1 resolved / 3 findings

Exclusion regions for masking burned-in overlays after segmentation, with staged masking before encoding to avoid re-segmentation on every adjustment. Six consumers of the exclusion mapping, comprehensive test coverage including two rejection-round fixes, and verified against ruff, mypy, guardrails, and 2086 passing tests.

Consider wording the empty-union trim note conditionally on whether exclusions were applied, so a genuinely empty mask is reported accurately rather than blamed on regions. Also consider clearing the before-model flag when the region set becomes empty, so the flag does not silently persist across a clear-all and re-enable unexpected re-segmentation when a new region is added.

💡 Quality: Empty-union trim note misattributes cause to exclusion regions

📄 src/matteloop/jobs/render.py:1378-1381

_encode_snapshot appends the note "trim skipped: no pixel above the alpha threshold survives the exclusion regions" whenever request.framing.trim and union is None, but that condition is also reached when the segmentation itself produces nothing above the alpha threshold and there are no exclusion regions at all. In that case the render now degrades to an untrimmed canvas (previously framing_plan raised INVALID_FRAMING) and records a note that wrongly blames exclusion regions, obscuring an actually-empty or misconfigured segmentation. Consider wording the note conditionally on whether any exclusions were applied (e.g. only mention exclusions when request.framing.exclusions is non-empty), so a genuinely empty mask is reported for what it is.

💡 Edge Case: before-model flag persists silently across a clear-all

📄 src/matteloop/ui/exclusion_controls.py:57-67 📄 src/matteloop/core/parameters.py:395-409

When "Blank before the model" is on and the user then removes all regions, apply disables the checkbox (available and count > 0) but leaves exclusions_before_model set to True in state and the box checked-but-greyed. Adding a new region later re-enables the flag, so the next region is silently re-segmented and blanked before the model rather than treated as a normal post-segmentation exclusion — behavior the user did not re-request and could not toggle off during the zero-region window. Consider clearing exclusions_before_model when the region set becomes empty, or keeping the checkbox interactive so the preference can always be turned off.

✅ 1 resolved
✅ Bug: Crop resize handles painted over a disabled/inactive crop

📄 src/matteloop/ui/exclusion_canvas.py:108-111
In ExclusionCanvas.paintEvent, _paint_handles/_paint_focus_ring are gated by not self._exclusion_edit or self._selected_exclusion is not None, which ignores self._active. The paint block is now reachable with _active=False and _exclusion_edit=False whenever persisted exclusions exist (the case test_persisted_exclusions_are_painted_when_crop_is_disabled exercises). In that state the crop mask and outline are correctly skipped (if self._active), but the crop resize handles still render — accent-colored squares floating over a disabled crop with no outline, which the base CropCanvas avoids by returning early when inactive. Gate the crop-mode handles on self._active.

🤖 Prompt for agents
Code Review: Exclusion regions for masking burned-in overlays after segmentation, with staged masking before encoding to avoid re-segmentation on every adjustment. Six consumers of the exclusion mapping, comprehensive test coverage including two rejection-round fixes, and verified against ruff, mypy, guardrails, and 2086 passing tests.
  
  Consider wording the empty-union trim note conditionally on whether exclusions were applied, so a genuinely empty mask is reported accurately rather than blamed on regions. Also consider clearing the before-model flag when the region set becomes empty, so the flag does not silently persist across a clear-all and re-enable unexpected re-segmentation when a new region is added.

1. 💡 Quality: Empty-union trim note misattributes cause to exclusion regions
   Files: src/matteloop/jobs/render.py:1378-1381

   `_encode_snapshot` appends the note "trim skipped: no pixel above the alpha threshold survives the exclusion regions" whenever `request.framing.trim and union is None`, but that condition is also reached when the segmentation itself produces nothing above the alpha threshold and there are no exclusion regions at all. In that case the render now degrades to an untrimmed canvas (previously `framing_plan` raised `INVALID_FRAMING`) and records a note that wrongly blames exclusion regions, obscuring an actually-empty or misconfigured segmentation. Consider wording the note conditionally on whether any exclusions were applied (e.g. only mention exclusions when `request.framing.exclusions` is non-empty), so a genuinely empty mask is reported for what it is.

2. 💡 Edge Case: before-model flag persists silently across a clear-all
   Files: src/matteloop/ui/exclusion_controls.py:57-67, src/matteloop/core/parameters.py:395-409

   When "Blank before the model" is on and the user then removes all regions, `apply` disables the checkbox (`available and count > 0`) but leaves `exclusions_before_model` set to True in state and the box checked-but-greyed. Adding a new region later re-enables the flag, so the next region is silently re-segmented and blanked before the model rather than treated as a normal post-segmentation exclusion — behavior the user did not re-request and could not toggle off during the zero-region window. Consider clearing `exclusions_before_model` when the region set becomes empty, or keeping the checkbox interactive so the preference can always be turned off.

Tip

Comment Gitar fix CI or enable auto-apply: gitar auto-apply:on

Options

Auto-apply is off → Gitar will not commit updates to this branch.
Display: compact → Counting what did not apply, without listing it.

Comment with these commands to change the behavior for this request:

Auto-apply Compact
gitar auto-apply:on         
gitar display:verbose         

Important

Your trial ends in 7 days — upgrade now to keep code review, CI analysis, auto-apply, custom automations, and more.

Was this helpful? React with 👍 / 👎 | Gitar

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

Labels

deferred Parked: work exists but is on hold, not abandoned

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A burned-in overlay is segmented as foreground, and no setting can exclude it

1 participant