Spaces:
Running on T4
Running on T4
Create masks.py
Browse files- 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
|