Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ composite. MatteLoop is the whole path from that clip to that loop:
- **Preview a single frame** to judge the cutout before committing to a render.
- **Trim, crop and resize** the finished cut, and watch it loop on screen until
it is right.
- **Mark exclusion regions** over burned-in overlays or other unwanted
foreground; those areas are made transparent after segmentation. Turn on
**Blank before the model** when an excluded bystander is distorting the
matte; this re-segments the clip and uses a separate cached cut set.
- **Render** a lossless transparent animated WebP.
- **Choose or clear the output directory** in the inspector's Output section.
- **Replace the open clip** by dropping another video onto the window, or with
Expand Down
Binary file modified assets/screenshots/main-window.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/screenshots/model-manager.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/screenshots/transform-expanded.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1,589 changes: 1,589 additions & 0 deletions docs/designs/exclusion-regions.md

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions docs/v1-scope.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ Nothing else gates V1. When that sentence is true end to end, V1 is done.
| Output | Lossless animated WebP, plus the still-image single-frame case. |
| Timeline | Filmstrip, playhead, IN/OUT range handles, exact time/frame readout. |
| Crop | Visual rectangle with handles, plus numeric fields. |
| Exclusion regions | Several user-drawn rectangles over the source frame, forced to alpha 0 after segmentation so a burned-in overlay does not survive into the cutout. **Scope reopened 2026-09-12 (issue #80):** an optional global switch also blanks those regions before the model; effective boxes are part of the cut key, so changing them re-segments while post-only edits keep the stored cut set. |
| Preview | `Preview Frame` through the exact render pipeline, with the stale-result contract. |
| Jobs | Exclusive modal job dialog with truthful stage/progress and working cancellation. |
| Accessibility | Full keyboard reachability, correct tab order, accessible names and values on **standard** widgets, no colour-only status. |
Expand All @@ -77,6 +78,12 @@ Nothing else gates V1. When that sentence is true end to end, V1 is done.
`Delete outdated` as the agreed handling for a rembg namespace move (issue
#22). Bulk re-download returned in #29 with confirmation, cancellation, batch
progress and per-model deletion after verification.
- **Exclusion regions carry across a source change, 2026-09-12:** the
rectangles are a parameter, not a per-source value, because the use case is
many clips of one stream sharing an overlay layout. They are clipped — not
slid — to each newly loaded source, so what the canvas paints and what the
renderer masks are the same rectangle by construction. Sliding, which is what
`clamp_crop` does for a crop, would move an exclusion onto wanted content.
- **Cut workspace location, 2026-09-10:** durable cut sets and disposable
scratch now live under MatteLoop's user cache at
`<platformdirs.user_cache_dir("matteloop")>/workspace/`, rather than inside
Expand Down
Binary file modified resources/matteloop_de.qm
Binary file not shown.
287 changes: 207 additions & 80 deletions resources/matteloop_de.ts

Large diffs are not rendered by default.

Binary file modified resources/matteloop_en.qm
Binary file not shown.
287 changes: 207 additions & 80 deletions resources/matteloop_en.ts

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions scripts/guardrails-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"long_functions": 1
},
"src/matteloop/core/state.py": {
"lines": 882,
"lines": 883,
"long_functions": 1
},
"src/matteloop/core/webp.py": {
Expand All @@ -24,7 +24,7 @@
"long_functions": 1
},
"src/matteloop/jobs/render.py": {
"lines": 2886,
"lines": 2889,
"long_functions": 11
},
"src/matteloop/jobs/segmentation_host.py": {
Expand Down Expand Up @@ -88,7 +88,7 @@
"long_functions": 1
},
"src/matteloop/ui/inspector.py": {
"lines": 897,
"lines": 904,
"long_functions": 0
},
"src/matteloop/ui/timeline.py": {
Expand Down
3 changes: 3 additions & 0 deletions scripts/screenshots.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from PySide6.QtWidgets import QApplication, QStyleFactory, QWidget

from matteloop.core.parameters import ParameterState
from matteloop.core.specs import CropSpec
from matteloop.core.state import (
AppState,
PreviewRequested,
Expand Down Expand Up @@ -123,6 +124,8 @@ def _state(metadata: object, source_frame: QImage, result_frame: QImage) -> AppS
parameters=ParameterState(
model_id=_MODEL_ID,
output_directory=_OUTPUT_DIRECTORY,
exclusions=(CropSpec(560, 32, 150, 88),),
exclusions_before_model=True,
)
),
SourceLoadRequested(_SOURCE_ID, _LOAD_REQUEST_ID),
Expand Down
24 changes: 19 additions & 5 deletions src/matteloop/core/crop.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import math
from dataclasses import replace
from fractions import Fraction

from matteloop.core.geometry import (
Expand Down Expand Up @@ -255,15 +256,23 @@ def _orientation_transform(
source_width: int, source_height: int, rotation: int, pixel_aspect: float
) -> MediaTransform:
_validate_dimensions(source_width, source_height)
return MediaTransform(
transform = MediaTransform(
source_size=SizeF(source_width, source_height),
viewport=SizeF(
source_height if rotation in {90, 270} else source_width,
source_width * pixel_aspect if rotation in {90, 270} else source_height,
),
viewport=SizeF(source_width, source_height),
rotation=rotation,
pixel_aspect=pixel_aspect,
)
display_width = max(
1,
_round_fraction(Fraction(source_width) * Fraction(str(pixel_aspect))),
)
return replace(
transform,
viewport=SizeF(
source_height if rotation in {90, 270} else display_width,
display_width if rotation in {90, 270} else source_height,
),
)


def _orientation_transform_from_media(transform: MediaTransform) -> MediaTransform:
Expand All @@ -279,6 +288,11 @@ def _rounded_delta(value: float) -> int:
return math.floor(value + 0.5) if value >= 0 else math.ceil(value - 0.5)


def _round_fraction(value: Fraction) -> int:
quotient, remainder = divmod(value.numerator, value.denominator)
return quotient + int(remainder * 2 >= value.denominator)


def _validate_dimensions(source_width: int, source_height: int) -> None:
if (
not isinstance(source_width, int)
Expand Down
118 changes: 118 additions & 0 deletions src/matteloop/core/exclusion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""Map and apply post-segmentation exclusion regions.

All cut pixels enter framing through three producers:

* ``_produce_cut_frame`` (sites 1, 2)
* ``WorkspacePort.read_cut`` (sites 3, 4)
* ``FrameReader.read`` (sites 5, 6)

The six consumers are:

1. preview (``render.py:913-960``): ``exclude_alpha(cut, boxes)`` once,
right after ``_produce_cut_frame``; bounds, both ``apply_framing`` calls,
and both ``_immutable_rgba`` images see it.
2. render loop (``render.py:1108-1112``): stage first, then mask in place.
3. ``_rebuild_union`` (``render.py:1338``): ``exclude_alpha(image, boxes)``
after ``read_cut``.
4. encoder (``render.py:1386``): ``stage_encoder_frames`` passes boxes to
``_stage_frame``, which masks before ``apply_framing``.
5. UI facts (``ui/transform_stage.py:710-720``): ``_iter_stored_frames``
masks before yielding.
6. result player (``ui/result_player.py:480-490``): ``FrameLoadWorker`` and
``_load_one_frame`` mask before ``apply_framing``.

``union_alpha_bounds_or_none`` keeps a genuinely empty alpha union distinct
from malformed or mismatched framing inputs.
"""

from __future__ import annotations

from collections.abc import Iterable
from decimal import Decimal

from PIL import Image

from matteloop.core.errors import ErrorCode, ValidationError
from matteloop.core.geometry import PixelBounds, alpha_bounds
from matteloop.core.specs import CropSpec

MODEL_FILL_ID = "imagenet-mean"
MODEL_FILL_RGB = (124, 116, 104)


def cut_exclusions(
exclusions: tuple[CropSpec, ...], crop: CropSpec
) -> tuple[PixelBounds, ...]:
"""Map oriented-source regions into the crop-sized cut, clipped and canonical."""
boxes: set[tuple[int, int, int, int]] = set()
for region in exclusions:
left = max(region.x, crop.x) - crop.x
top = max(region.y, crop.y) - crop.y
right = min(region.x + region.width, crop.x + crop.width) - crop.x
bottom = min(region.y + region.height, crop.y + crop.height) - crop.y
if right > left and bottom > top:
boxes.add((left, top, right, bottom))
return tuple(PixelBounds(*box) for box in sorted(boxes))


def blank_model_input(
image: Image.Image, boxes: tuple[PixelBounds, ...]
) -> None:
"""Fill model-excluded RGB pixels while preserving their alpha channel."""
if not boxes:
return
alpha = image.getchannel("A")
try:
for box in boxes:
image.paste(
MODEL_FILL_RGB,
(box.left, box.top, box.right, box.bottom),
)
image.putalpha(alpha)
finally:
alpha.close()


def exclude_alpha(image: Image.Image, boxes: tuple[PixelBounds, ...]) -> None:
"""Zero RGBA inside each box, in place."""
for box in boxes:
image.paste((0, 0, 0, 0), (box.left, box.top, box.right, box.bottom))


def union_alpha_bounds_or_none(
frames: Iterable[Image.Image], threshold_percent: Decimal | float | int
) -> PixelBounds | None:
"""Return a valid alpha union, or ``None`` only when it is empty."""
try:
iterator = iter(frames)
except TypeError:
raise ValidationError(
ErrorCode.INVALID_FRAMING,
"framing",
"framing requires an iterable of RGBA frames",
) from None

union: PixelBounds | None = None
expected_size: tuple[int, int] | None = None
for image in iterator:
bounds = alpha_bounds(image, threshold_percent)
if expected_size is None:
expected_size = image.size
elif image.size != expected_size:
raise ValidationError(
ErrorCode.INVALID_FRAMING,
"framing",
"all frames must have identical source canvas dimensions",
)
if bounds is None:
continue
if union is None:
union = bounds
else:
union = PixelBounds(
min(union.left, bounds.left),
min(union.top, bounds.top),
max(union.right, bounds.right),
max(union.bottom, bounds.bottom),
)
return union
69 changes: 52 additions & 17 deletions src/matteloop/core/fingerprints.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from typing import BinaryIO, Protocol, cast

from matteloop.core.errors import AppError, ErrorCode, ValidationError
from matteloop.core.exclusion import MODEL_FILL_ID, cut_exclusions
from matteloop.core.specs import (
CropSpec,
FramingSpec,
Expand Down Expand Up @@ -154,8 +155,9 @@ def preview_fingerprint(
"model_id": request.segmentation.model_id,
"model_weight_sha256": model_weight_sha256,
**_edge_settings(request),
**_model_exclusions(request),
},
"framing": _framing(request.framing),
"framing": _framing(request),
"pipeline_schema_version": pipeline_schema_version,
"orientation_color_version": orientation_color_version,
"rembg_version": rembg_version,
Expand Down Expand Up @@ -217,6 +219,7 @@ def cut_cache_key_inputs(
"pipeline_schema_version": pipeline_schema_version,
"orientation_color_version": orientation_color_version,
"edge_settings": _edge_settings(request),
**_model_exclusions(request),
}
)

Expand All @@ -240,7 +243,7 @@ def render_fingerprint(request: RenderRequest, *, cut_key: str) -> str:
{
**_schema("render"),
"cut_key": cut_key,
"framing": _framing(request.framing),
"framing": _framing(request),
"max_bytes": request.output.max_bytes,
}
)
Expand All @@ -249,14 +252,55 @@ def render_fingerprint(request: RenderRequest, *, cut_key: str) -> str:
def union_fingerprint(request: RenderRequest, *, cut_key: str) -> str:
"""Identify the cut bytes and threshold consumed by range-union analysis."""
request = _validated_request(request)
return cut_union_fingerprint(request.framing, request.crop, cut_key=cut_key)


def cut_union_fingerprint(
framing: FramingSpec, crop: CropSpec, *, cut_key: str
) -> str:
"""Identify cut-union inputs, including effective exclusions when present."""
cut_key = _validated_sha256(cut_key, "cut_key")
return _canonical_hash(
{
**_schema("cut-union"),
"cut_key": cut_key,
"alpha_threshold": _canonical_decimal(request.framing.alpha_threshold),
payload: dict[str, object] = {
**_schema("cut-union"),
"cut_key": cut_key,
"alpha_threshold": _canonical_decimal(framing.alpha_threshold),
}
boxes = cut_exclusions(framing.exclusions, crop)
if boxes:
payload["exclusions"] = [
[box.left, box.top, box.right, box.bottom] for box in boxes
]
return _canonical_hash(payload)


def _framing(request: RenderRequest) -> dict[str, object]:
framing = request.framing
payload: dict[str, object] = {
"trim": framing.trim,
"alpha_threshold": _canonical_decimal(framing.alpha_threshold),
"padding": framing.padding,
"stretch_x": _canonical_decimal(framing.stretch_x),
}
boxes = cut_exclusions(framing.exclusions, request.crop)
if boxes:
payload["exclusions"] = [
[box.left, box.top, box.right, box.bottom] for box in boxes
]
return payload


def _model_exclusions(request: RenderRequest) -> dict[str, object]:
boxes = cut_exclusions(request.segmentation.exclusions, request.crop)
if not boxes:
return {}
return {
"model_exclusions": {
"fill": MODEL_FILL_ID,
"boxes": tuple(
(box.left, box.top, box.right, box.bottom) for box in boxes
),
}
)
}


def _update_digest(
Expand Down Expand Up @@ -339,15 +383,6 @@ def _crop(crop: CropSpec) -> dict[str, int]:
return {"x": crop.x, "y": crop.y, "width": crop.width, "height": crop.height}


def _framing(framing: FramingSpec) -> dict[str, object]:
return {
"trim": framing.trim,
"alpha_threshold": _canonical_decimal(framing.alpha_threshold),
"padding": framing.padding,
"stretch_x": _canonical_decimal(framing.stretch_x),
}


def _edge_settings(request: RenderRequest) -> dict[str, object]:
matting = request.segmentation.alpha_matting
return {
Expand Down
Loading
Loading