Coverage for collectra/gui/segmentation.py: 92.16%
51 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-16 09:56 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-16 09:56 +0000
1"""Validate and atomically persist circular mask brush strokes."""
3import math
4import os
5import tempfile
6from pathlib import Path
8import numpy as np
9from PIL import Image, ImageDraw
12def paint_mask(item, strokes, orientation):
13 from collectra.types.images import ImageSegmentation
15 if not isinstance(item, ImageSegmentation):
16 raise ValueError("Only ImageSegmentation masks can be painted")
17 if orientation != item.orientation.to_string():
18 raise ValueError(
19 "The image orientation changed; reopen the segmentation before painting"
20 )
21 if not isinstance(strokes, list) or not strokes:
22 raise ValueError("No mask strokes supplied")
23 pixels = item._mask_image().point(lambda value: 1 if value else 0)
24 pixels = pixels.rotate(item.orientation.to_degree(), expand=True)
25 before = np.asarray(pixels).copy()
26 draw = ImageDraw.Draw(pixels)
27 for stroke in strokes:
28 if stroke.get("tool") not in {"brush", "eraser"}:
29 raise ValueError("Unknown mask tool")
30 radius = stroke.get("radius")
31 if (
32 type(radius) not in (float, int)
33 or not math.isfinite(radius)
34 or not 0.5 <= radius <= 4096
35 ):
36 raise ValueError("Invalid brush radius")
37 points = stroke.get("points")
38 if not isinstance(points, list) or not points or len(points) > 100000:
39 raise ValueError("Invalid brush points")
40 for point in points:
41 if (
42 not isinstance(point, list)
43 or len(point) != 2
44 or any(
45 type(v) not in (int, float) or not math.isfinite(v) for v in point
46 )
47 ):
48 raise ValueError("Invalid brush point")
49 if (
50 not -radius <= point[0] <= pixels.width + radius
51 or not -radius <= point[1] <= pixels.height + radius
52 ):
53 raise ValueError("Brush point is outside the source image")
54 fill = 1 if stroke["tool"] == "brush" else 0
55 if len(points) > 1:
56 draw.line(
57 [tuple(p) for p in points], fill=fill, width=max(1, round(radius * 2))
58 )
59 for x, y in points:
60 draw.ellipse((x - radius, y - radius, x + radius, y + radius), fill=fill)
61 if pixels.getbbox() is None:
62 raise ValueError(
63 "A segmentation mask cannot be empty; leave at least one pixel"
64 )
65 if np.array_equal(before, np.asarray(pixels)):
66 return False
67 pixels = pixels.rotate(-item.orientation.to_degree(), expand=True)
68 path = Path(item._mask_path)
69 descriptor, temporary = tempfile.mkstemp(
70 prefix=f".{path.stem}-", suffix=".png", dir=path.parent
71 )
72 try:
73 with os.fdopen(descriptor, "wb") as stream:
74 pixels.save(stream, format="PNG")
75 os.chmod(temporary, path.stat().st_mode & 0o777)
76 os.replace(temporary, path)
77 finally:
78 Path(temporary).unlink(missing_ok=True)
79 return True