Skip to content

Edge treatment "Decontaminate colors" fails every preview and render: the segmentation result is read-only #40

Closed

Description

@sensitron

Context

Selecting Edge treatment → Decontaminate colors makes every preview and every render fail. The
default edge treatment is unaffected, which is presumably why this has gone unnoticed.

Found on main's behaviour during manual testing on Windows, 2026-09-04. Unrelated to the transform
stage in #25 / #27 — the failing code is untouched by that branch.

The error does reach the user, in the banner and in the log:

Preview failed: preview failed: assignment destination is read-only

Current State (verified 2026-09-04)

Two deliberate decisions contradict each other:

Where What it does
src/matteloop/jobs/segmentation_host.py:505 output.setflags(write=False) — the segmentation result is frozen before it is handed back
src/matteloop/jobs/render.py:1581 _decontaminate_edge_colors_in_place(segmented) — writes into that array for this edge mode only

Traceback, abbreviated:

File "src/matteloop/jobs/render.py", line 1554, in _produce_cut_frame
    _decontaminate_edge_colors_in_place(segmented)
File "src/matteloop/jobs/render.py", line 1574, in _decontaminate_edge_colors_in_place
    rgba[alpha == 0, :3] = 0
ValueError: assignment destination is read-only

_produce_cut_frame is the single decode/crop/segment path, so this hits preview and render alike.
The freeze is not an accident: the array is a copy out of the shared-memory slot the child process
writes into, and making it read-only is a reasonable guard against later mutation aliasing that
buffer.

Why no test caught it

The fake segmentation port in tests/jobs/test_preview.py:237 returns a freshly allocated
np.zeros((128, 128, 4), dtype=np.uint8) — writable. The real host returns a frozen array. Every
test that exercises DECONTAMINATE_COLORS therefore passes on a value the production path never
produces, and the divergence sits precisely on the property the code breaks on.

Files referencing the mode (tests/core/test_fingerprints.py, tests/core/test_parameters.py,
tests/jobs/test_preview.py, tests/ui/test_parameter_persistence.py,
tests/ui/test_parameter_requests.py) all cover plumbing — that the mode is stored, fingerprinted
and requested — not that it can run.

Proposed Change

The fix is a decision, not a one-liner, because both sides are intentional:

  1. Copy before writing — have _produce_cut_frame work on a writable copy when the mode is
    active. Keeps the host's guarantee intact; costs one full-frame copy per frame, only for this
    mode.
  2. Return the array writable — drop setflags(write=False) and rely on the copy already made
    at segmentation_host.py:509. Cheaper, but gives up a guard that presumably exists for a reason;
    whoever added it should say whether that reason still holds.
  3. Make the postprocess pure — return a new array instead of mutating, and drop _in_place from
    the name. Clearest contract, touches the most call sites.

(1) looks like the smallest correct change, but the choice belongs to whoever wrote the freeze.

Whichever is chosen, the fake needs to stop being more permissive than the real port: a fake that
returns a read-only array would have failed on the first run. That is the change that keeps this
class of bug from recurring.

Acceptance Criteria

  1. A preview with EdgeMode.DECONTAMINATE_COLORS on a real segmentation host produces a frame
    instead of raising.
  2. A render with that mode completes and its output differs from the default mode's output on a
    frame with translucent edges (i.e. the postprocess actually ran).
  3. The segmentation fake used in tests returns a read-only array, matching the host, and the
    existing tests still pass against it.
  4. uv run ruff check . && uv run mypy src && QT_QPA_PLATFORM=offscreen uv run pytest -q green,
    plus uv run python scripts/check_guardrails.py.

Notes

How the mode got selected in the first place is worth recording: the "Edge treatment" combo box sits
in the middle of the inspector, and until #27 the mouse wheel changed the value of any field the
pointer passed over while scrolling the panel. The setting was almost certainly never chosen
deliberately — the same session also ended up with 11 fps and a 0.4 % alpha threshold the same way.

Activity

  1. self-assigned this
    on Sep 4, 2026
  2. sensitron commented on Sep 5, 2026

    @sensitron
    CollaboratorAuthor

    #54 fixes the crash. Leaving this open: two of the four acceptance criteria
    could not be checked on the machine the fix was built on.

    Covered by #54

    • _produce_cut_frame decontaminates a writable copy (variant 1). The host's
      setflags(write=False) stays — that array is a copy out of the shared-memory
      slot the child writes into, so the postprocess is the side that yields.
    • AC 3: every segmentation fake in the suite now returns a read-only array via
      frozen_segmentation_result, and the existing tests pass against it. This is
      the part that keeps the class of bug from recurring — the fakes were more
      permissive than the real port on exactly the property the code broke on.
    • AC 4: ruff, mypy, scripts/check_guardrails.py and pytest green
      (1783 passed, 4 skipped).

    The trigger was
    tests/jobs/test_preview.py::test_decontaminate_is_deterministic_local_rgba_postprocess,
    failing with ValueError: assignment destination is read-only at
    render.py:1603. Both it and the new render-level test were confirmed to fail
    on the unfixed code and pass with the fix.

    Still open — needs Windows, real weights, real footage

    • AC 1: a preview with EdgeMode.DECONTAMINATE_COLORS on a real segmentation
      host produces a frame instead of raising.
    • AC 2: a render with that mode completes and its output differs from the
      default mode's on a frame with translucent edges.

    These were deliberately not attempted rather than approximated. Note that the
    green windows CI job does not cover them: it runs the same suite against
    the same fakes, without model weights or video. AC 2 is covered in spirit by a
    new test (two renders of one frozen frame under STANDARD and
    DECONTAMINATE_COLORS must differ, and the segmenter's array must come back
    unwritten and still read-only), but that too runs against a fake.

    Whoever does the manual check: the mode is reachable via Edge treatment in the
    inspector, and a frame with hair or motion blur against a contrasting background
    is the one that shows the difference.

  3. sensitron commented on Sep 5, 2026

    @sensitron
    CollaboratorAuthor

    Manual verification on Windows — AC 1 and AC 2

    AC 3 and AC 4 were already covered. AC 1 and AC 2 need real weights and real footage, so
    they were run by hand on Windows against main at b9a2535.

    The green windows CI job does not cover AC 1 or AC 2. It runs the same suite against the
    same fakes, with no weights and no video. The check mark proves nothing here — which is why
    this was done by hand.

    Environment

    commit b9a2535 (main), matteloop 0.2.1
    OS / Python Windows 11 10.0.26200 / 3.13.14
    ONNX Runtime onnxruntime-directml 1.24.4
    available providers ('DmlExecutionProvider', 'CPUExecutionProvider')
    provider actually used CPUExecutionProvider
    model birefnet-portrait, cache namespace 2.0.75, 972 666 916 B on disk (matches the manifest)
    source assets/demo/golden-retriever.mp4, 752×416, 145 frames, 24 fps
    hardware i7-8850H, Intel UHD 630 + NVIDIA Quadro P2000 (4 GB)

    DirectML was deliberately not used. On this 4 GB GPU, 1024×1024 BiRefNet models fail inside
    session.run() with DXGI_ERROR_DEVICE_HUNG (measured 2026-09-03). That is an unrelated
    defect and would only have obscured this check. The DirectML path for this mode therefore
    remains unverified.

    Precondition: the freeze is still in place

    Before anything else, one frame was pushed through a real SegmentationClient and the
    returned array inspected directly:

    probe result shape=(32, 32, 4) dtype=uint8 writeable=False
    

    This matters: if the host had started handing back writable arrays, both criteria below would
    pass for the wrong reason and prove nothing. segmentation_host.py's
    output.setflags(write=False) is intact, so the fix is genuinely being exercised against the
    condition that broke it.

    AC 1 — preview with DECONTAMINATE_COLORS on a real host: PASSED

    A real PreviewService.preview() against a real SegmentationClient subprocess with the real
    birefnet-portrait weights produced a frame. No exception, no banner.

    Independently confirmed in the GUI: the maintainer loaded the demo clip, set Edge treatment to
    "Decontaminate colors" and triggered Preview Frame — the frame rendered. (Noted separately
    because it is an observation at the screen, not a measurement; the app writes nothing at INFO
    level that would corroborate it after the fact.)

    AC 2 — render differs from the default mode: PASSED

    Two real RenderService.render() calls over the same cut, identical in every field except
    edge_mode, with regenerate=True and separate output directories.

    standard decontaminate
    output size 660 480 B 687 862 B
    sha256 c2938bb6e7210ea0… 3a0d9baf47aa7429…
    frames 8 8
    rebuilt False False
    real segment() calls 8 8

    Not byte-identical, and segmentation demonstrably ran fresh in both renders rather than being
    served from a cut cache.

    The pixel evidence is more specific than "the files differ". Decoding both WebPs and splitting
    the difference by alpha class over all 8 frames:

    pixel class pixels RGB changed max RGB delta
    semi-transparent (0 < a < 255) 369 381 319 810 (86.6 %) 254
    fully transparent (a == 0) 2 039 430 67 343 255
    fully opaque (a == 255) 93 845 0 0

    Three things follow:

    1. The alpha channel is byte-identical in every frame. Both renders segmented identically,
      so the RGB difference is attributable to the postprocess alone and not to segmentation
      noise. That is the control that makes this comparison meaningful.
    2. Almost 87 % of the translucent edge pixels changed — exactly the population AC 2 names.
    3. No opaque pixel changed, so the postprocess is not bleeding into the interior. The
      a == 0 group is the rgba[alpha == 0, :3] = 0 zeroing, invisible on its own but the
      reason the black premultiplication stops darkening the edge.

    Composited over a light background the difference is also visible by eye: the standard render
    carries a grey fringe along the silhouette that the decontaminated one does not. Side-by-side
    crops and an amplified difference map were produced but are not attached — the numbers above
    are the evidence, the pictures only illustrate them. Available on request.

    What this does not cover

    • The headless harness drives PreviewService / RenderService directly. The Qt UI path is
      covered only by the single manual preview click above; no GUI render was done.
    • CPU only. DirectML is untested for this mode on this machine, for the reason given above.
    • AC 3 and AC 4 were verified on Linux and were not re-run here.

    Two side findings, unrelated to the fix but worth recording

    1. uv sync --reinstall-package matteloop can silently disarm ONNX Runtime. A stray
      onnxruntime 1.29.0 was installed next to onnxruntime-directml 1.24.4; both own the same
      onnxruntime import package. Removing the former took the shared files with it, after which
      matteloop --providers reported:

      onnxruntime device: unavailable (module 'onnxruntime' has no attribute 'get_device')
      onnxruntime available providers: unavailable (module 'onnxruntime' has no attribute 'get_available_providers')
      

      uv sync --reinstall-package onnxruntime-directml repairs it. Note that --providers
      degrades to a text line here rather than failing loudly — the same shape as Segmentation is unreachable on main: the rembg version gate rejects the installed runtime #22: a broken
      runtime that still looks like a working install.

    2. The wheel-scroll values from Edge treatment "Decontaminate colors" fails every preview and render: the segmentation result is read-only #40's Notes are still persisted in
      HKCU\Software\MatteLoop\MatteLoop\parameters on this machine: fps = 11,
      alpha_threshold = 0.4, stretch_x = 1.2, padding = 1. They survive restarts and nothing
      resets them, so a user who hit the scroll bug once keeps those settings indefinitely.

    Also: validating the harness pulled u2netp (4.5 MB) into the model cache under 2.0.75.

    Not closing this — whether it is done is the maintainer's call.

  4. sensitron commented on Sep 5, 2026

    @sensitron
    CollaboratorAuthor

    The two side findings from the verification above are now tracked separately: #55 (--providers reports a dead ONNX Runtime as ordinary output and exits 0) and #56 (inspector parameters persist with no reset, so the pre-#27 wheel-scroll damage is permanent). Neither blocks this issue.

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

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions