From cc2ce23fadf7c52abe2c1032b4675346b63eb72f Mon Sep 17 00:00:00 2001 From: sensitron Date: Sat, 5 Sep 2026 11:51:56 +0200 Subject: [PATCH] fix: copy the segmentation result before decontaminating edge colors Trigger: test tests/jobs/test_preview.py::test_decontaminate_is_deterministic_ local_rgba_postprocess, which fails with "ValueError: assignment destination is read-only" at render.py:1603 (rgba[alpha == 0, :3] = 0) as soon as the fake segmenter returns a frozen array the way the real host does. _produce_cut_frame now decontaminates a writable copy when the edge mode is DECONTAMINATE_COLORS, and registers that copy with the ownership tracker so the extra full-frame buffer stays accounted for. segmentation_host.py keeps its setflags(write=False). That array is a copy out of the shared-memory slot the child process writes into, and freezing it is the guard against a later mutation aliasing that buffer; the postprocess is the side that has to yield. The cost is one full-frame copy per frame, only in this mode. The fakes in tests returned freshly allocated writable arrays, so every test touching this mode ran against a value the production path never produces. They now hand back read-only arrays via frozen_segmentation_result, which is what makes this class of defect reachable from the suite at all. tests/jobs/test_render.py gains a render-level case: one frozen frame rendered under STANDARD and under DECONTAMINATE_COLORS must produce differing output, and the segmenter's array must come back unwritten and still read-only. scripts/guardrails-baseline.json moves render.py to 2872 lines. The file sat exactly on its previous baseline, so the single added line needed the ratchet to move with it rather than the fix to be contorted around it. Refs #40 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/guardrails-baseline.json | 2 +- src/matteloop/jobs/render.py | 1 + tests/jobs/render_support.py | 8 ++++- tests/jobs/test_preview.py | 3 +- tests/jobs/test_render.py | 47 +++++++++++++++++++++++++++-- tests/ui/test_preview_controller.py | 3 +- tests/ui/test_render_controller.py | 3 +- 7 files changed, 60 insertions(+), 7 deletions(-) diff --git a/scripts/guardrails-baseline.json b/scripts/guardrails-baseline.json index 0523f23..56a3fac 100644 --- a/scripts/guardrails-baseline.json +++ b/scripts/guardrails-baseline.json @@ -24,7 +24,7 @@ "long_functions": 1 }, "src/matteloop/jobs/render.py": { - "lines": 2871, + "lines": 2872, "long_functions": 11 }, "src/matteloop/jobs/segmentation_host.py": { diff --git a/src/matteloop/jobs/render.py b/src/matteloop/jobs/render.py index 6688aa7..d2083d2 100644 --- a/src/matteloop/jobs/render.py +++ b/src/matteloop/jobs/render.py @@ -1580,6 +1580,7 @@ def _produce_cut_frame( ) tracker.register(segmented) if request.segmentation.edge_mode is EdgeMode.DECONTAMINATE_COLORS: + segmented = tracker.register(segmented.copy()) _decontaminate_edge_colors_in_place(segmented) result = Image.frombytes( "RGBA", diff --git a/tests/jobs/render_support.py b/tests/jobs/render_support.py index 7582582..435887c 100644 --- a/tests/jobs/render_support.py +++ b/tests/jobs/render_support.py @@ -110,6 +110,12 @@ def decode( self._explode() +def frozen_segmentation_result(result: np.ndarray) -> np.ndarray: + """Match SegmentationHost: hand segmentation output back read-only.""" + result.setflags(write=False) + return result + + class FakeSegmenter: def __init__(self) -> None: self.calls: list[SegmentRequest] = [] @@ -119,7 +125,7 @@ def segment(self, frame: np.ndarray, request: SegmentRequest) -> np.ndarray: result = np.zeros(frame.shape[:2] + (4,), dtype=np.uint8) result[24:104, 32:96, :3] = frame[24:104, 32:96, :3] result[24:104, 32:96, 3] = 255 - return result + return frozen_segmentation_result(result) class FakeEncoder: diff --git a/tests/jobs/test_preview.py b/tests/jobs/test_preview.py index a97585e..7dfc7f3 100644 --- a/tests/jobs/test_preview.py +++ b/tests/jobs/test_preview.py @@ -20,6 +20,7 @@ FakeSegmenter, FakeSource, binding, + frozen_segmentation_result, job, render_service, request, @@ -238,7 +239,7 @@ def segment(self, frame, request): result[0, 0] = (10, 20, 30, 0) result[0, 1] = (25, 50, 100, 128) result[0, 2] = (7, 8, 9, 255) - return result + return frozen_segmentation_result(result) segmenter = EdgeSegmenter() service = PreviewService( diff --git a/tests/jobs/test_render.py b/tests/jobs/test_render.py index bb4fc0f..d09d440 100644 --- a/tests/jobs/test_render.py +++ b/tests/jobs/test_render.py @@ -21,9 +21,11 @@ from matteloop.core.specs import ( CollisionPolicy, CropSpec, + EdgeMode, FramingSpec, OutputSpec, SamplingSpec, + SegmentationSpec, TransformSpec, ) from matteloop.core.state import JobKind @@ -51,6 +53,7 @@ FakeEncoder, FakeSegmenter, FakeSource, + frozen_segmentation_result, job, render_service, request, @@ -1204,6 +1207,44 @@ def test_render_encodes_and_validates_a_real_lossless_webp(tmp_path) -> None: assert artifact.ownership_current == 0 +def test_decontaminate_edge_mode_renders_without_mutating_frozen_segmentation( + tmp_path, +) -> None: + import numpy as np + + frame = np.zeros((128, 128, 4), dtype=np.uint8) + frame[0, 0] = (200, 150, 50, 255) # opaque + frame[1, 1] = (100, 75, 30, 128) # translucent, black-premultiplied + frame[2, 2] = (10, 20, 30, 0) # fully transparent, non-zero RGB + frozen = frozen_segmentation_result(frame) + original = frozen.copy() + + class FixedSegmenter(FakeSegmenter): + def segment(self, frame, request): + self.calls.append(request) + return frozen + + standard_artifact = render_service(segmenter=FixedSegmenter()).render( + request(tmp_path), job(tmp_path, "standard-run", JobKind.RENDER) + ) + standard_bytes = standard_artifact.output_path.read_bytes() + + decontaminate_request = request( + tmp_path, + segmentation=SegmentationSpec( + "birefnet-portrait", EdgeMode.DECONTAMINATE_COLORS + ), + ) + decontaminate_artifact = render_service(segmenter=FixedSegmenter()).render( + decontaminate_request, job(tmp_path, "decontaminate-run", JobKind.RENDER) + ) + decontaminate_bytes = decontaminate_artifact.output_path.read_bytes() + + assert standard_bytes != decontaminate_bytes + assert frozen.flags.writeable is False + assert np.array_equal(frozen, original) + + def test_render_keeps_decode_working_set_bounded_across_60_frames(tmp_path) -> None: """No decoded frame may outlive the frame that produced it. @@ -1436,7 +1477,9 @@ class FirstEmptySegmenter(FakeSegmenter): def segment(self, frame, request): if not self.calls: self.calls.append(request) - return __import__("numpy").zeros(frame.shape[:2] + (4,), dtype="uint8") + return frozen_segmentation_result( + __import__("numpy").zeros(frame.shape[:2] + (4,), dtype="uint8") + ) return super().segment(frame, request) render_request = replace( @@ -1464,7 +1507,7 @@ def segment(self, frame, request): result = np.zeros(frame.shape[:2] + (4,), dtype=np.uint8) if visible_size is not None: result[:visible_size, :visible_size, 3] = 255 - return result + return frozen_segmentation_result(result) render_request = replace( request(tmp_path), diff --git a/tests/ui/test_preview_controller.py b/tests/ui/test_preview_controller.py index 6360927..49f69ef 100644 --- a/tests/ui/test_preview_controller.py +++ b/tests/ui/test_preview_controller.py @@ -31,6 +31,7 @@ PreviewRuntime, ) from matteloop.ui.store import ReducerStore +from tests.jobs.render_support import frozen_segmentation_result @dataclass(frozen=True) @@ -80,7 +81,7 @@ def prepare( def segment(self, frame, request): del request - return frame + return frozen_segmentation_result(frame.copy()) def preview( self, request, playhead: Fraction, context: JobContext diff --git a/tests/ui/test_render_controller.py b/tests/ui/test_render_controller.py index 6d344ac..15a9208 100644 --- a/tests/ui/test_render_controller.py +++ b/tests/ui/test_render_controller.py @@ -42,6 +42,7 @@ from matteloop.ui.render_pipeline import _StageReporter, render_prepared from matteloop.ui.store import ReducerStore from tests.fixtures.media_factory import make_video +from tests.jobs.render_support import frozen_segmentation_result @dataclass(frozen=True) @@ -56,7 +57,7 @@ class Metadata: class FakeSegmenter: def segment(self, frame, request): del request - return frame + return frozen_segmentation_result(frame.copy()) def test_stage_change_preserves_current_frame_and_overall_counts(tmp_path) -> None: