hmgill commited on
Commit
4394e58
·
verified ·
1 Parent(s): 4d5e410

Create masks.py

Browse files
Files changed (1) hide show
  1. helpers/masks.py +153 -0
helpers/masks.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Mask repair and the quality gate on what actually reaches Meshy.
3
+
4
+ ## The failure this exists for
5
+
6
+ On a real specimen — a fish in a limestone slab — SAM3 returned a mask with **512
7
+ interior holes covering 157,000 pixels**. It segmented the fossil-bearing half
8
+ cleanly and then shredded the plain matrix half, because bare limestone has almost
9
+ no texture gradient for it to hold on to. The background remover's alpha matte for
10
+ the same photograph had **zero** holes.
11
+
12
+ Meshy reconstructs what it is given. A cutout riddled with holes becomes a mesh
13
+ riddled with holes — and it does not fail, it succeeds, which is worse. Nobody sees
14
+ it until they orbit the model.
15
+
16
+ ## Why hole-filling alone is not the fix
17
+
18
+ Measured on that mask:
19
+
20
+ RMBG alpha (reference) 44.5% coverage, 0 holes
21
+ SAM3 raw 36.7% coverage, 512 holes
22
+ SAM3 + fill 38.0% coverage, 0 holes IoU 0.854
23
+ SAM3 + close(61) + fill 39.1% coverage, 0 holes IoU 0.879
24
+
25
+ Filling closes the enclosed holes, but much of the speckle is connected to the
26
+ outside — SAM3 ate into the boundary rather than perforating the interior. Even a
27
+ 61-pixel closing cannot recover ground SAM3 never claimed. Morphology repairs
28
+ noise; it cannot invent a segmentation.
29
+
30
+ ## So: repair, then judge
31
+
32
+ `repair_mask` fills and closes, because a fossil slab is a solid object and an
33
+ interior hole is always an artifact. `assess_mask` then compares the result against
34
+ the background remover's matte, which is the one independent opinion available. If
35
+ SAM3 has dropped a large share of what the matte called specimen, SAM3 has failed
36
+ on this view and the matte is the better input.
37
+
38
+ The pipeline switches automatically and says so. This is not "trust RMBG over SAM3"
39
+ — SAM3's negative points are what keep the cast shadow out, and it usually wins.
40
+ It is: verify before spending, because a bad cutout produces a confident,
41
+ plausible, wrong mesh.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import cv2
47
+ import numpy as np
48
+
49
+ # If the repaired SAM3 mask retains less than this share of the alpha matte's area,
50
+ # treat it as a failed segmentation rather than a tight one. 0.90 was chosen against
51
+ # the real failure above (0.86 after repair) and a good run (>0.97).
52
+ MIN_ALPHA_RETENTION = 0.90
53
+
54
+ # Interior holes worth worrying about, as a fraction of mask area.
55
+ MAX_HOLE_FRACTION = 0.02
56
+
57
+
58
+ def fill_holes(mask: np.ndarray) -> np.ndarray:
59
+ """Fill enclosed holes.
60
+
61
+ Flood from a corner: whatever the flood cannot reach is enclosed, and on a solid
62
+ specimen enclosed means artifact. Cheaper and more exact than a large closing,
63
+ and it cannot round off genuine boundary detail the way a big kernel does.
64
+ """
65
+ m = mask.astype(bool)
66
+ inv = (~m).astype(np.uint8) * 255
67
+ ff = np.zeros((m.shape[0] + 2, m.shape[1] + 2), np.uint8)
68
+ cv2.floodFill(inv, ff, (0, 0), 0)
69
+ return m | (inv > 0)
70
+
71
+
72
+ def repair_mask(mask: np.ndarray, close_px: int = 15) -> np.ndarray:
73
+ """Close small speckle, then fill enclosed holes, then keep the largest blob.
74
+
75
+ Order matters: closing first bridges pinholes so the fill has something enclosed
76
+ to work on, and the largest-component pass last drops any island the closing
77
+ happened to connect up.
78
+ """
79
+ m = mask.astype(np.uint8)
80
+ if close_px >= 3:
81
+ k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (close_px | 1, close_px | 1))
82
+ m = cv2.morphologyEx(m, cv2.MORPH_CLOSE, k)
83
+ m = fill_holes(m.astype(bool))
84
+
85
+ n, labels, stats, _ = cv2.connectedComponentsWithStats(m.astype(np.uint8), 8)
86
+ if n > 1:
87
+ biggest = 1 + int(np.argmax(stats[1:, cv2.CC_STAT_AREA]))
88
+ m = labels == biggest
89
+ return m
90
+
91
+
92
+ def count_holes(mask: np.ndarray) -> tuple[int, int]:
93
+ """(number of interior holes, total hole area in pixels)."""
94
+ inv = (~mask.astype(bool)).astype(np.uint8)
95
+ n, labels, stats, _ = cv2.connectedComponentsWithStats(inv, 8)
96
+ border = (set(labels[0, :]) | set(labels[-1, :])
97
+ | set(labels[:, 0]) | set(labels[:, -1]))
98
+ interior = [i for i in range(1, n) if i not in border]
99
+ return len(interior), int(sum(stats[i, cv2.CC_STAT_AREA] for i in interior))
100
+
101
+
102
+ def assess_mask(sam3_mask: np.ndarray, alpha_mask: np.ndarray | None) -> dict:
103
+ """
104
+ Judge a repaired SAM3 mask against the background remover's matte.
105
+
106
+ Returns {usable, retention, hole_count, reason}. `usable=False` means SAM3
107
+ dropped too much of the specimen and the alpha-based cutout should be sent to
108
+ Meshy instead.
109
+ """
110
+ holes, hole_px = count_holes(sam3_mask)
111
+ area = int(sam3_mask.sum())
112
+ result = {
113
+ "hole_count": holes,
114
+ "hole_pixels": hole_px,
115
+ "coverage": round(float(sam3_mask.mean()), 4),
116
+ "retention": None,
117
+ "usable": True,
118
+ "reason": "",
119
+ }
120
+
121
+ if area == 0:
122
+ result.update(usable=False, reason="SAM3 returned an empty mask.")
123
+ return result
124
+
125
+ if alpha_mask is None:
126
+ if hole_px > MAX_HOLE_FRACTION * area:
127
+ result.update(usable=False,
128
+ reason=f"{holes} interior holes ({hole_px} px) survived repair "
129
+ f"and there is no alpha matte to fall back to.")
130
+ return result
131
+
132
+ alpha = alpha_mask.astype(bool)
133
+ if alpha.sum() == 0:
134
+ return result
135
+
136
+ # Share of the matte's specimen area that SAM3 kept. Not IoU: SAM3 legitimately
137
+ # trims the matte (the cast shadow, for one), so extra area outside the matte is
138
+ # fine. What matters is how much of the specimen it THREW AWAY.
139
+ retention = float(np.logical_and(sam3_mask, alpha).sum() / alpha.sum())
140
+ result["retention"] = round(retention, 3)
141
+
142
+ if retention < MIN_ALPHA_RETENTION:
143
+ result.update(
144
+ usable=False,
145
+ reason=(
146
+ f"SAM3 kept only {retention:.0%} of the area the background remover "
147
+ f"called specimen (threshold {MIN_ALPHA_RETENTION:.0%}). On a slab "
148
+ f"this usually means it segmented the fossil-bearing part and shed "
149
+ f"the plain matrix, which has little texture for it to hold. The "
150
+ f"background-removal cutout is the better input for this view."
151
+ ),
152
+ )
153
+ return result