Zhen Ye Claude Opus 4.6 (1M context) commited on
Commit
9f46868
·
1 Parent(s): 157bd4f

feat(inspection): add RLE mask encode/decode module

Browse files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Files changed (2) hide show
  1. inspection/masks.py +103 -0
  2. tests/test_inspection_masks.py +79 -0
inspection/masks.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """RLE (Run-Length Encoding) for binary segmentation masks.
2
+
3
+ Uses a pure-Python COCO-compatible RLE implementation so we do not
4
+ require pycocotools (which has C build dependencies that complicate
5
+ Docker builds). The encoding is column-major (Fortran order) to
6
+ match COCO convention.
7
+ """
8
+
9
+ import logging
10
+ from typing import Dict, List
11
+
12
+ import numpy as np
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def rle_encode(mask: np.ndarray) -> Dict:
18
+ """Encode a binary mask as COCO-format RLE.
19
+
20
+ Args:
21
+ mask: HxW boolean or uint8 numpy array.
22
+
23
+ Returns:
24
+ Dict with 'counts' (list of run lengths) and 'size' [H, W].
25
+ """
26
+ h, w = mask.shape[:2]
27
+ # Flatten in column-major (Fortran) order per COCO convention
28
+ flat = mask.astype(np.uint8).ravel(order="F")
29
+
30
+ # Compute run lengths
31
+ counts: List[int] = []
32
+ prev = 0
33
+ run = 0
34
+ for val in flat:
35
+ if val == prev:
36
+ run += 1
37
+ else:
38
+ counts.append(run)
39
+ run = 1
40
+ prev = val
41
+ counts.append(run)
42
+
43
+ # Ensure counts starts with a run of 0s (COCO convention)
44
+ if len(counts) > 0 and flat[0] == 1:
45
+ counts.insert(0, 0)
46
+
47
+ return {"counts": counts, "size": [h, w]}
48
+
49
+
50
+ def rle_decode(rle: Dict) -> np.ndarray:
51
+ """Decode a COCO-format RLE to a binary mask.
52
+
53
+ Args:
54
+ rle: Dict with 'counts' (list of run lengths) and 'size' [H, W].
55
+
56
+ Returns:
57
+ HxW boolean numpy array.
58
+ """
59
+ h, w = rle["size"]
60
+ counts = rle["counts"]
61
+
62
+ flat = np.zeros(h * w, dtype=np.uint8)
63
+ pos = 0
64
+ val = 0 # Starts with 0s (background)
65
+ for run in counts:
66
+ flat[pos : pos + run] = val
67
+ pos += run
68
+ val = 1 - val
69
+
70
+ mask = flat.reshape((h, w), order="F").astype(bool)
71
+ return mask
72
+
73
+
74
+ def mask_area(rle: Dict) -> int:
75
+ """Count the number of True pixels from an RLE-encoded mask.
76
+
77
+ Args:
78
+ rle: COCO-format RLE dict.
79
+
80
+ Returns:
81
+ Number of foreground pixels.
82
+ """
83
+ counts = rle["counts"]
84
+ # Odd-indexed runs (0-based) are foreground (value=1)
85
+ return sum(counts[i] for i in range(1, len(counts), 2))
86
+
87
+
88
+ def mask_to_png_bytes(mask: np.ndarray) -> bytes:
89
+ """Encode a binary mask as a single-channel PNG (white on black).
90
+
91
+ Args:
92
+ mask: HxW boolean numpy array.
93
+
94
+ Returns:
95
+ PNG bytes.
96
+ """
97
+ import cv2
98
+
99
+ img = (mask.astype(np.uint8)) * 255
100
+ success, buffer = cv2.imencode(".png", img)
101
+ if not success:
102
+ raise RuntimeError("Failed to encode mask as PNG")
103
+ return buffer.tobytes()
tests/test_inspection_masks.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import pytest
3
+
4
+
5
+ def test_rle_encode_returns_dict():
6
+ """RLE encode should return a dict with 'counts' and 'size' keys."""
7
+ from inspection.masks import rle_encode
8
+
9
+ mask = np.zeros((100, 200), dtype=bool)
10
+ mask[20:40, 50:100] = True
11
+ rle = rle_encode(mask)
12
+
13
+ assert isinstance(rle, dict)
14
+ assert "counts" in rle
15
+ assert "size" in rle
16
+ assert rle["size"] == [100, 200]
17
+
18
+
19
+ def test_rle_roundtrip():
20
+ """Encoding then decoding should reproduce the original mask."""
21
+ from inspection.masks import rle_encode, rle_decode
22
+
23
+ mask = np.zeros((64, 64), dtype=bool)
24
+ mask[10:30, 20:50] = True
25
+
26
+ rle = rle_encode(mask)
27
+ decoded = rle_decode(rle)
28
+
29
+ assert decoded.shape == mask.shape
30
+ assert decoded.dtype == bool
31
+ assert np.array_equal(mask, decoded)
32
+
33
+
34
+ def test_rle_empty_mask():
35
+ """An all-False mask should encode and decode correctly."""
36
+ from inspection.masks import rle_encode, rle_decode
37
+
38
+ mask = np.zeros((50, 50), dtype=bool)
39
+ rle = rle_encode(mask)
40
+ decoded = rle_decode(rle)
41
+ assert not decoded.any()
42
+
43
+
44
+ def test_rle_full_mask():
45
+ """An all-True mask should encode and decode correctly."""
46
+ from inspection.masks import rle_encode, rle_decode
47
+
48
+ mask = np.ones((50, 50), dtype=bool)
49
+ rle = rle_encode(mask)
50
+ decoded = rle_decode(rle)
51
+ assert decoded.all()
52
+
53
+
54
+ def test_rle_encode_compact():
55
+ """RLE of a simple mask should be much smaller than the raw mask."""
56
+ from inspection.masks import rle_encode
57
+ import json
58
+
59
+ mask = np.zeros((720, 1280), dtype=bool)
60
+ mask[100:200, 300:500] = True # Small rectangle
61
+
62
+ rle = rle_encode(mask)
63
+ rle_size = len(json.dumps(rle))
64
+ raw_size = mask.nbytes
65
+
66
+ assert rle_size < raw_size / 100 # At least 100x compression
67
+
68
+
69
+ def test_mask_area():
70
+ """mask_area should count the number of True pixels."""
71
+ from inspection.masks import mask_area
72
+
73
+ rle = {"counts": None, "size": [100, 100]} # placeholder
74
+ mask = np.zeros((100, 100), dtype=bool)
75
+ mask[10:20, 10:20] = True # 100 pixels
76
+
77
+ from inspection.masks import rle_encode
78
+ rle = rle_encode(mask)
79
+ assert mask_area(rle) == 100