diff --git a/vectorizing/solvers/color/ColorSolver.py b/vectorizing/solvers/color/ColorSolver.py index a4382af..c91a015 100644 --- a/vectorizing/solvers/color/ColorSolver.py +++ b/vectorizing/solvers/color/ColorSolver.py @@ -9,7 +9,11 @@ from vectorizing.geometry.potrace import potrace_path_to_compound_path from vectorizing.server.timer import Timer -from vectorizing.solvers.color.bitmaps import add_bitmap_rims, create_bitmaps +from vectorizing.solvers.color.bitmaps import ( + add_bitmap_rims, + create_background_bitmap, + create_bitmaps, +) from vectorizing.solvers.color.quantize import quantize from vectorizing.util.limit_size import limit_size @@ -20,8 +24,8 @@ def _trace_mask(bitmap: np.ndarray) -> Path: return potrace_path_to_compound_path(traced) -def trace_bitmap(bitmap: np.ndarray) -> Path: - """Trace detail without speck removal, extending then clipping canvas edges.""" +def trace_bitmap(bitmap: np.ndarray, *, recover_clip: bool = True) -> Path: + """Trace bounded detail, optionally leaving clip recovery to the caller.""" height, width = bitmap.shape # Potrace rounds exposed mask corners. Extending edge labels moves that # rounding outside the viewport instead of leaving transparent canvas corners. @@ -41,6 +45,8 @@ def trace_bitmap(bitmap: np.ndarray) -> Path: try: path = op(path, canvas, PathOp.INTERSECTION) except PathOpsError: + if not recover_clip: + raise # Recovery keeps smooth, bounded vectors and does not repeat the # failing boolean operation. Unpadded tracing can round canvas # corners inward; only this exceptional layer loses edge coverage. @@ -84,12 +90,30 @@ def solve(self) -> list[list[Path] | list[np.ndarray] | int]: self.timer.end_timer() self.timer.start_timer("Bitmap Creation") + background_bitmap = create_background_bitmap(labels, colors, has_background) bitmaps, colors = create_bitmaps(labels, colors, has_background) add_bitmap_rims(bitmaps) self.timer.end_timer() self.timer.start_timer("Bitmap Tracing") compound_paths = [trace_bitmap(bitmap) for bitmap in bitmaps] + if background_bitmap is not None: + try: + background = trace_bitmap(background_bitmap, recover_clip=False) + foreground = [ + op(path, compound_paths[-1], PathOp.DIFFERENCE) + for path in compound_paths[:-1] + ] + except PathOpsError: + # Keep the complete original output if any isolation step fails; + # never publish only the foreground colors processed so far. + logging.getLogger(__name__).warning( + "Background isolation failed; retaining original layers", + exc_info=True, + ) + else: + compound_paths = [background, *foreground] + colors = [colors[-1], *colors[:-1]] self.timer.end_timer() return [compound_paths, colors, self.img.size[0], self.img.size[1]] diff --git a/vectorizing/solvers/color/bitmaps.py b/vectorizing/solvers/color/bitmaps.py index a099366..4a730bd 100644 --- a/vectorizing/solvers/color/bitmaps.py +++ b/vectorizing/solvers/color/bitmaps.py @@ -3,6 +3,40 @@ import cv2 import numpy as np +from vectorizing.solvers.color.quantize import _perimeter + + +def create_background_bitmap( + labels: np.ndarray, + colors: np.ndarray, + has_background: bool, +) -> np.ndarray | None: + """Find a frontmost opaque background and give it a bounded two-pixel rim.""" + # The quantizer's background flag means detected transparency. Preserve that + # classification, including its existing handling of partial input alpha. + if has_background: + return None + border = _perimeter(labels) + background = int(border[0]) + if not np.all(border == background): + return None + used = np.unique(labels) + # Intermediate backgrounds have hidden rims beneath later foreground colors; + # cutting those rims can break coverage. Already-first backgrounds need no fix. + if len(used) < 2 or background != used[-1]: + return None + if ( + np.count_nonzero(np.all(colors[used, :3] == colors[background, :3], axis=1)) + != 1 + ): + return None + return cv2.dilate( + (labels == background).astype(np.uint8), + np.ones((5, 5), dtype=np.uint8), + borderType=cv2.BORDER_CONSTANT, + borderValue=0, + ) + def create_bitmaps( labels: np.ndarray, diff --git a/vectorizing/tests/images/midnight_strike.png b/vectorizing/tests/images/midnight_strike.png new file mode 100644 index 0000000..8e3f0ad Binary files /dev/null and b/vectorizing/tests/images/midnight_strike.png differ diff --git a/vectorizing/tests/test_color_background.py b/vectorizing/tests/test_color_background.py new file mode 100644 index 0000000..9209bfa --- /dev/null +++ b/vectorizing/tests/test_color_background.py @@ -0,0 +1,197 @@ +"""Conservative background detection and all-or-nothing geometry isolation.""" + +from unittest.mock import Mock + +import numpy as np +import pytest +from flask.testing import FlaskClient +from pathops import PathOp, PathOpsError, op +from PIL import Image + +from vectorizing.server.timer import Timer +from vectorizing.solvers.color.bitmaps import ( + add_bitmap_rims, + create_background_bitmap, + create_bitmaps, +) +from vectorizing.solvers.color.ColorSolver import ColorSolver, trace_bitmap +from vectorizing.svg.markup import generate_SVG_markup +from vectorizing.tests.test_color_layers import render_layers, trace_layers + + +@pytest.fixture() +def background_labels() -> tuple[np.ndarray, np.ndarray]: + """Provide two foreground colors surrounded by a frontmost white background.""" + labels = np.full((40, 40), 2, dtype=np.uint16) + labels[5:35, 8:14] = 0 + labels[8:32, 20:36] = 1 + palette = np.array([[20, 80, 200], [230, 130, 20], [255, 255, 255]], np.uint8) + return labels, palette + + +def test_background_mask_is_bounded_and_does_not_mutate_inputs( + background_labels: tuple[np.ndarray, np.ndarray], +) -> None: + """Match an independent square-neighborhood expansion, not full underpainting.""" + labels, palette = background_labels + original_labels, original_palette = labels.copy(), palette.copy() + expected = np.zeros(labels.shape, dtype=np.uint8) + for y, x in np.argwhere(labels == 2): + expected[max(0, y - 2) : y + 3, max(0, x - 2) : x + 3] = 1 + actual = create_background_bitmap(labels, palette, False) + np.testing.assert_array_equal(actual, expected) + assert actual.dtype == np.uint8 + assert actual[20, 28] == 0 + np.testing.assert_array_equal(labels, original_labels) + np.testing.assert_array_equal(palette, original_palette) + + +@pytest.mark.parametrize( + "case", + ["transparent", "mixed-border", "first", "middle", "duplicate", "single"], +) +def test_unsupported_backgrounds_keep_the_original_svg( + background_labels: tuple[np.ndarray, np.ndarray], + monkeypatch: pytest.MonkeyPatch, + case: str, +) -> None: + """Use the pre-isolation tracing sequence as the unchanged-output oracle.""" + labels, palette = background_labels + transparent = case == "transparent" + if case == "mixed-border": + labels[0, 10] = 0 + elif case == "first": + order = np.array([2, 0, 1]) + labels, palette = np.argsort(order)[labels], palette[order] + elif case == "middle": + order = np.array([0, 2, 1]) + labels, palette = np.argsort(order)[labels], palette[order] + elif case == "duplicate": + palette[0] = palette[2] + elif case == "single": + labels.fill(2) + assert create_background_bitmap(labels, palette, transparent) is None + masks, retained = create_bitmaps(labels, palette, transparent) + add_bitmap_rims(masks) + expected = generate_SVG_markup( + [trace_bitmap(mask) for mask in masks], + retained, + 40, + 40, + ) + paths, colors = trace_layers(monkeypatch, labels, palette, transparent) + assert generate_SVG_markup(paths, colors, 40, 40) == expected + + +def test_detection_uses_only_retained_palette_entries( + background_labels: tuple[np.ndarray, np.ndarray], +) -> None: + """Ignore unused higher colors, and recognize an already-first retained color.""" + labels, palette = background_labels + palette = np.vstack([palette, [255, 255, 255]]).astype(np.uint8) + assert create_background_bitmap(labels, palette, False) is not None + labels[labels != 2] = 3 + assert create_background_bitmap(labels, palette, False) is None + + +def test_detection_supports_the_full_palette_range() -> None: + """Keep 64-color labels intact while finding the frontmost border color.""" + palette = np.array([[i, i * 3 % 256, i * 17 % 256] for i in range(64)], np.uint8) + labels = np.full((12, 12), 63, dtype=np.uint16) + labels[2:10, 2:10] = np.arange(64).reshape(8, 8) + mask = create_background_bitmap(labels, palette, False) + assert mask is not None + assert labels.dtype == np.uint16 + assert len(np.unique(labels)) == 64 + + +@pytest.mark.parametrize("size", [1, 2, 3, 5]) +def test_isolated_background_preserves_counters_and_boundedness( + background_labels: tuple[np.ndarray, np.ndarray], + monkeypatch: pytest.MonkeyPatch, + size: int, +) -> None: + """Keep small background-colored counters open without a full background fill.""" + labels, palette = background_labels + labels[12 : 12 + size, 24 : 24 + size] = 2 + paths, colors = trace_layers(monkeypatch, labels, palette, False) + np.testing.assert_array_equal(colors, palette[[2, 0, 1]]) + assert not paths[0].contains((28, 27)) + assert all(not path.contains((24 + size / 2, 12 + size / 2)) for path in paths[1:]) + assert all(0 <= v <= 40 for path in paths for v in path.bounds) + assert render_layers(paths, colors)[:, :, 3].min() >= 240 + + +def test_partial_alpha_uses_existing_transparency_classification( + background_labels: tuple[np.ndarray, np.ndarray], +) -> None: + """Do not introduce a stricter input-alpha rule than the current quantizer.""" + labels, palette = background_labels + rgba = np.dstack([palette[labels], np.full(labels.shape, 255, np.uint8)]) + rgba[0, 0, 3] = 217 + result = ColorSolver(Image.fromarray(rgba), 3, Timer()).solve() + assert isinstance(result, list) + np.testing.assert_array_equal(result[1][0], palette[2]) + + +@pytest.mark.parametrize("failure", ["background-clip", "second-difference"]) +@pytest.mark.parametrize("raw", [False, True]) +def test_isolation_failure_retains_complete_original_output( + background_labels: tuple[np.ndarray, np.ndarray], + client: FlaskClient, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + failure: str, + raw: bool, +) -> None: + """Recover the full original layer set and API response, never partial isolation.""" + labels, palette = background_labels + masks, retained = create_bitmaps(labels, palette, False) + add_bitmap_rims(masks) + expected = generate_SVG_markup( + [trace_bitmap(mask) for mask in masks], + retained, + 40, + 40, + ) + monkeypatch.setattr( + "vectorizing.try_read_image_from_url", + lambda url: Image.fromarray(palette[labels]), + ) + monkeypatch.setattr( + "vectorizing.solvers.color.ColorSolver.quantize", + Mock(return_value=(labels, palette, False)), + ) + # Of the original layers, only the background touches the canvas. Its clip + # succeeds; fail either the new background's clip or a later subtraction. + intersections = [] + differences = [] + + def controlled_op(*args: object, **kwargs: object) -> object: + if args[2] == PathOp.INTERSECTION: + intersections.append(1) + if failure == "background-clip" and len(intersections) == 2: + raise PathOpsError("injected background clip failure") + if args[2] == PathOp.DIFFERENCE: + differences.append(1) + if failure == "second-difference" and len(differences) == 2: + raise PathOpsError("injected subtraction failure") + return op(*args, **kwargs) + + monkeypatch.setattr("vectorizing.solvers.color.ColorSolver.op", controlled_op) + upload = Mock(return_value="original-layers") + monkeypatch.setattr("vectorizing.upload_markup", upload) + response = client.post("/", json={"url": "unused", "solver": 1, "raw": raw}) + assert response.status_code == 200 + if raw: + markup = response.data.decode() + upload.assert_not_called() + else: + upload.assert_called_once() + markup = upload.call_args.args[0] + assert response.get_json()["objectId"] == "original-layers" + assert markup == expected + assert "Background isolation failed; retaining original layers" in caplog.text + assert "retracing without padding" not in caplog.text + assert len(intersections) == 2 + assert len(differences) == (2 if failure == "second-difference" else 0) diff --git a/vectorizing/tests/test_color_layer_ownership.py b/vectorizing/tests/test_color_layer_ownership.py new file mode 100644 index 0000000..d7e6080 --- /dev/null +++ b/vectorizing/tests/test_color_layer_ownership.py @@ -0,0 +1,117 @@ +"""Background deletion must preserve the visible artwork, not every color's rim.""" + +from io import BytesIO +from pathlib import Path + +import numpy as np +import pytest +from cairosvg import svg2png +from defusedxml import ElementTree +from PIL import Image + +from vectorizing.server.timer import Timer +from vectorizing.solvers.color.ColorSolver import ColorSolver +from vectorizing.svg.markup import generate_SVG_markup +from vectorizing.tests.test_color_layers import trace_layers + + +def edit_background(markup: str, opacity: float | None = None) -> bytes: + """Delete or change the first color node in the emitted SVG itself.""" + document = ElementTree.fromstring(markup) + container = document.find("{http://www.w3.org/2000/svg}g") + assert container is not None + background = list(container)[0] + if opacity is None: + container.remove(background) + else: + background.set("opacity", str(opacity)) + return ElementTree.tostring(document) + + +def raster(markup: str | bytes, scale: float = 1) -> Image.Image: + """Render the SVG on a transparent surface at the requested scale.""" + return Image.open(BytesIO(svg2png(bytestring=markup, scale=scale))).convert("RGBA") + + +@pytest.fixture(scope="module", params=[3, 4, 6, 8, 16]) +def customer_svg(request: pytest.FixtureRequest) -> tuple[str, tuple[int, ...]]: + """Solve the customer image once per palette with real quantization and tracing.""" + filename = Path(__file__).parent / "images" / "midnight_strike.png" + with Image.open(filename) as image: + paths, colors, width, height = ColorSolver( + image, + request.param, + Timer(), + ).solve() + # On this fixture the cream background is the last RGB-ordered color, not + # the first foreground color. It must become the first emitted layer. + background = tuple(int(value) for value in colors[0]) + assert background == max(tuple(int(value) for value in color) for color in colors) + assert len(colors) <= request.param + assert (width, height) == (1024, 1024) + return generate_SVG_markup(paths, colors, width, height), (*background, 255) + + +@pytest.mark.parametrize("scale", [1, 2]) +def test_customer_background_deletion_preserves_artwork( + customer_svg: tuple[str, tuple[int, ...]], + scale: int, +) -> None: + """Preserve appearance on the same matte, with opaque assembly and no new seams.""" + markup, color = customer_svg + full = raster(markup, scale) + removed = raster(edit_background(markup), scale) + zero = raster(edit_background(markup, 0), scale) + assert np.asarray(full)[:, :, 3].min() >= 240 + np.testing.assert_array_equal(np.asarray(removed), np.asarray(zero)) + before = Image.new("RGBA", full.size, color) + before.alpha_composite(full) + after = Image.new("RGBA", full.size, color) + after.alpha_composite(removed) + # Source-over rounding is not pixel-exact. A large hidden foreground lip + # produces differences of hundreds, not these few channel levels. + difference = np.abs(np.asarray(before).astype(np.int16) - np.asarray(after)) + assert difference.max() <= 4 + assert "stroke=" not in markup + assert 'shape-rendering="crispEdges"' not in markup + + +@pytest.mark.parametrize("scale", [1, 1.25, 2]) +def test_thin_line_keeps_its_width_after_background_deletion( + monkeypatch: pytest.MonkeyPatch, + scale: float, +) -> None: + """A six-pixel line stays six pixels wide rather than exposing a four-pixel lip.""" + labels = np.full((40, 40), 2, dtype=np.uint16) + labels[5:35, 8:14] = 0 + labels[8:32, 22:36] = 1 + palette = np.array([[20, 80, 200], [230, 130, 20], [255, 255, 255]], np.uint8) + paths, colors = trace_layers(monkeypatch, labels, palette, False) + np.testing.assert_array_equal(colors, palette[[2, 0, 1]]) + markup = generate_SVG_markup(paths, colors, 40, 40) + full = raster(markup, scale) + removed = raster(edit_background(markup), scale) + assert np.asarray(full)[:, :, 3].min() >= 240 + row = np.asarray(removed)[int(20 * scale), : int(20 * scale), 3] + if scale == 1: + assert np.flatnonzero(row > 128).tolist() == list(range(8, 14)) + expected = Image.new("RGBA", full.size, (255, 255, 255, 255)) + expected.alpha_composite(removed) + difference = np.abs(np.asarray(full).astype(np.int16) - np.asarray(expected)) + assert difference.max() <= 4 + + +def test_background_half_opacity_does_not_fade_foreground( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Keep a single editable background color and normal path-opacity behavior.""" + labels = np.full((40, 40), 2, dtype=np.uint16) + labels[10:30, 10:30] = 0 + labels[15:25, 25:35] = 1 + palette = np.array([[20, 80, 200], [230, 130, 20], [255, 255, 255]], np.uint8) + paths, colors = trace_layers(monkeypatch, labels, palette, False) + markup = generate_SVG_markup(paths, colors, 40, 40) + pixels = np.asarray(raster(edit_background(markup, 0.5))) + assert pixels[2, 2, 3] == 128 + np.testing.assert_array_equal(pixels[20, 15], [20, 80, 200, 255]) + np.testing.assert_array_equal(pixels[20, 30], [230, 130, 20, 255])