Image Segmentation
PyTorch
English
painting-vision-robotics-kit
semantic-segmentation
robotics
edge-ai
construction-ai
autonomous-painting
wall-painting-robot
paint-coverage-estimation
building-facade
drywall
skirting-detection
window-detection
lidar
depth-validation
deeplabv3
mobilenetv3
Eval Results (legacy)
File size: 10,670 Bytes
43526cf | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 | #!/usr/bin/env python3
"""Validate vision masks against depth/LiDAR as an independent geometric check.
A segmentation model can hallucinate a wall where there is none, or miss a
protruding pipe near a corner. A depth sensor sees the same scene in metric 3D,
so it can independently confirm or refute the vision output:
* ``fit_plane`` finds the dominant wall plane by RANSAC, so we know the scene is
a planar wall and how well it fits;
* ``metric_scale_mm_per_pixel`` derives the true wall scale from depth, replacing
the guessed ``--mm-per-unit`` the robot planner otherwise needs;
* the signed residual to the plane separates **protrusions** (closer than the
wall: pipes, vents, frames) from **recesses** (farther: windows, doors);
* ``validate`` cross-checks those against the vision masks and reports the
geometry the model missed, plus recess/window agreement.
Works with a depth image (``.npy`` float metres, or 16-bit PNG) or a dense
unprojected point cloud. numpy + scipy only. Depth can come from a LiDAR,
structured-light, or stereo source.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import numpy as np
def load_depth(path, unit="m"):
path = Path(path)
if path.suffix == ".npy":
depth = np.load(path).astype(np.float32)
else:
from PIL import Image
depth = np.asarray(Image.open(path)).astype(np.float32)
return depth * (1.0 if unit == "m" else 0.001)
def unproject(depth, fx, fy, cx, cy):
"""Depth map -> camera-frame XYZ in metres."""
height, width = depth.shape
ys, xs = np.mgrid[0:height, 0:width]
z = depth
x = (xs - cx) * z / fx
y = (ys - cy) * z / fy
return np.stack([x, y, z], axis=-1).astype(np.float32)
def fit_plane(points, distance_threshold=0.02, iterations=300, seed=0, refit=True):
"""RANSAC plane fit; returns ``(normal, offset, inliers)`` with plane = n·p + d.
The normal is oriented so the camera origin sits on the negative side
(``offset <= 0``), which makes the residual sign meaningful: positive is
farther than the wall (a recess), negative is closer (a protrusion).
"""
points = np.asarray(points, dtype=np.float64).reshape(-1, 3)
points = points[np.isfinite(points).all(axis=1)]
if len(points) < 3:
raise ValueError("need at least 3 finite points to fit a plane")
rng = np.random.default_rng(seed)
best_normal = best_offset = None
best_inliers = np.zeros(len(points), dtype=bool)
for _ in range(max(1, iterations)):
sample = points[rng.choice(len(points), 3, replace=False)]
normal = np.cross(sample[1] - sample[0], sample[2] - sample[0])
norm = np.linalg.norm(normal)
if norm < 1e-9:
continue
normal /= norm
offset = -float(normal @ sample[0])
inliers = np.abs(points @ normal + offset) < distance_threshold
if inliers.sum() > best_inliers.sum():
best_inliers, best_normal, best_offset = inliers, normal, offset
if best_normal is None:
raise ValueError("failed to find a plane (degenerate depth)")
if refit:
inlier_points = points[best_inliers]
centroid = inlier_points.mean(axis=0)
# Smallest eigenvector of the 3x3 covariance is the plane normal. Do NOT
# call np.linalg.svd on the (N, 3) points directly: with full_matrices=True
# it allocates an N x N array and explodes on large clouds.
centered = inlier_points - centroid
_, eigenvectors = np.linalg.eigh(centered.T @ centered)
best_normal = eigenvectors[:, 0]
best_offset = -float(best_normal @ centroid)
best_inliers = np.abs(points @ best_normal + best_offset) < distance_threshold
if best_offset > 0:
best_normal, best_offset = -best_normal, -best_offset
return best_normal, float(best_offset), best_inliers
def plane_residuals(points, normal, offset):
return np.asarray(points, dtype=np.float64) @ np.asarray(normal) + offset
def metric_scale_mm_per_pixel(points, fx, fy):
"""Approximate physical millimetres per pixel on the observed surface."""
points = np.asarray(points, dtype=np.float64).reshape(-1, 3)
depth = points[:, 2]
depth = depth[depth > 0]
if depth.size == 0:
return {"x": None, "y": None}
return {"x": round(float(np.median(depth) * 1000.0 / fx), 3),
"y": round(float(np.median(depth) * 1000.0 / fy), 3)}
def _mask_agreement(left, right):
union = int((left | right).sum())
return round(int((left & right).sum()) / union, 4) if union else 1.0
def validate(depth, intrinsics, wall_mask=None, window_mask=None, keepout_mask=None,
plane_distance_threshold=0.02, protrusion_threshold=0.05, recess_threshold=0.05, seed=0):
"""Compare depth geometry with vision masks; returns ``(report, layers)``."""
fx, fy, cx, cy = intrinsics
points = unproject(depth, fx, fy, cx, cy)
valid = np.isfinite(points).all(axis=-1) & (depth > 0)
if valid.sum() < 3:
raise ValueError("depth is empty or all-invalid")
normal, offset, inliers = fit_plane(points[valid], distance_threshold=plane_distance_threshold, seed=seed)
residual = np.full(depth.shape, np.nan, dtype=np.float32)
residual[valid] = plane_residuals(points[valid], normal, offset)
protrusions = valid & (residual < -protrusion_threshold)
recesses = valid & (residual > recess_threshold)
inlier_map = valid & (np.abs(residual) <= plane_distance_threshold)
report = {
"points": int(valid.sum()),
"plane": {"normal": [round(float(v), 5) for v in normal], "offset": round(float(offset), 5),
"inlier_fraction": round(float(inliers.sum()) / int(valid.sum()), 4)},
"metric_scale_mm_per_pixel": metric_scale_mm_per_pixel(points[valid], fx, fy),
"protrusion_pixels": int(protrusions.sum()),
"recess_pixels": int(recesses.sum()),
"wall_fraction_on_plane": None,
"missed_protrusions_pixels": None,
"missed_recesses_pixels": None,
"recess_vs_window_iou": None,
"warnings": [],
}
if wall_mask is not None:
wall = np.asarray(wall_mask, bool) & valid
if wall.any():
report["wall_fraction_on_plane"] = round(float(inlier_map[wall].mean()), 4)
if report["wall_fraction_on_plane"] < 0.6:
report["warnings"].append(
f"only {report['wall_fraction_on_plane']:.0%} of predicted wall pixels lie on the depth "
f"wall plane; the vision wall may be wrong or the scene is not planar")
if keepout_mask is not None:
keepout = np.asarray(keepout_mask, bool)
report["missed_protrusions_pixels"] = int((protrusions & ~keepout).sum())
report["missed_recesses_pixels"] = int((recesses & ~keepout).sum())
if report["missed_protrusions_pixels"] > 0.002 * int(valid.sum()):
report["warnings"].append("depth shows protrusions (obstacles) that the vision keep-out does not cover")
if report["missed_recesses_pixels"] > 0.002 * int(valid.sum()):
report["warnings"].append("depth shows recesses (windows/doors) that the vision keep-out does not cover")
if window_mask is not None:
report["recess_vs_window_iou"] = _mask_agreement(recesses, np.asarray(window_mask, bool))
return report, {"residual": residual, "normal": normal, "offset": offset, "valid": valid,
"protrusions": protrusions, "recesses": recesses, "inliers": inlier_map}
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--depth", type=Path, required=True, help="depth map (.npy metres or 16-bit PNG)")
parser.add_argument("--depth-unit", choices=("m", "mm"), default="m", help="unit of an image depth map")
parser.add_argument("--intrinsics", required=True, metavar="fx,fy,cx,cy")
parser.add_argument("--mask", type=Path, help="semantic mask PNG to derive wall/window keep-out from")
parser.add_argument("--wall-mask", type=Path, help="binary wall mask PNG (overrides --mask)")
parser.add_argument("--window-mask", type=Path, help="binary window mask PNG")
parser.add_argument("--keepout-mask", type=Path, help="binary keep-out mask PNG")
parser.add_argument("--protrusion-threshold", type=float, default=0.05, help="metres closer than the wall")
parser.add_argument("--recess-threshold", type=float, default=0.05, help="metres farther than the wall")
parser.add_argument("--report", type=Path, help="write the validation report JSON")
parser.add_argument("--overlay", type=Path, help="write a residual overlay PNG")
args = parser.parse_args()
try:
intrinsics = [float(v) for v in args.intrinsics.split(",")]
if len(intrinsics) != 4:
raise ValueError
except ValueError:
parser.error("--intrinsics must be four numbers: fx,fy,cx,cy")
depth = load_depth(args.depth, args.depth_unit)
from PIL import Image
def load_binary(path):
return np.asarray(Image.open(path).convert("L")) > 0
wall_mask = window_mask = keepout_mask = None
if args.wall_mask:
wall_mask = load_binary(args.wall_mask)
elif args.mask:
semantic = np.asarray(Image.open(args.mask))
wall_mask = np.isin(semantic, (1, 2, 3))
window_mask = semantic == 8
if args.window_mask:
window_mask = load_binary(args.window_mask)
if args.keepout_mask:
keepout_mask = load_binary(args.keepout_mask)
report, layers = validate(depth, intrinsics, wall_mask, window_mask, keepout_mask,
protrusion_threshold=args.protrusion_threshold, recess_threshold=args.recess_threshold)
rendered = json.dumps(report, indent=2)
print(rendered)
if args.report:
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(rendered + "\n", encoding="utf-8")
if args.overlay:
residual = layers["residual"]
finite = residual[np.isfinite(residual)]
span = float(np.percentile(np.abs(finite), 98)) if finite.size else 1.0
normalized = np.zeros(depth.shape, dtype=np.uint8)
clipped = np.clip(residual / max(span, 1e-6), -1, 1)
normalized[np.isfinite(residual)] = ((clipped[np.isfinite(residual)] + 1) * 127).astype(np.uint8)
args.overlay.parent.mkdir(parents=True, exist_ok=True)
Image.fromarray(normalized).save(args.overlay)
print(f"overlay: {args.overlay}")
if __name__ == "__main__":
main()
|