Spaces:
Runtime error
Runtime error
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 filesCo-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- inspection/masks.py +103 -0
- 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
|