biptv3 / code /superpoint_ops /superpoint_delivery_bundle.py
YYYYYYUUU's picture
Add core reproduction code (binarization layers, PTv3, superpoint ops, min-repro pack)
7b95dc2 verified
Raw
History Blame Contribute Delete
35.8 kB
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import colorsys
import json
import os
import subprocess
import tempfile
from pathlib import Path
import numpy as np
from scipy.spatial import cKDTree
_RENDER_HELPER = r"""
from __future__ import annotations
import argparse
import json
import os
import sys
try:
import bpy
from mathutils import Vector
except ImportError:
print('This script must run inside Blender.')
sys.exit(1)
def parse_args():
argv = sys.argv
argv = argv[argv.index('--') + 1:] if '--' in argv else []
ap = argparse.ArgumentParser(description='Render NPZ pointcloud assets with Blender Workbench')
ap.add_argument('--asset_npz', type=str, required=True)
ap.add_argument('--out_png', type=str, required=True)
ap.add_argument('--out_blend', type=str, default=None)
ap.add_argument('--background', choices=('white', 'light', 'dark'), default=None)
return ap.parse_args(argv)
def camera_from_coord(coord):
import numpy as np
mn = coord.min(axis=0)
mx = coord.max(axis=0)
center = (mn + mx) * 0.5
diag = float(np.linalg.norm(mx - mn)) + 1e-6
off = Vector((diag * 0.9, -diag * 0.85, diag * 0.55))
loc = Vector((float(center[0]), float(center[1]), float(center[2]))) + off
direction = Vector((float(center[0]), float(center[1]), float(center[2]))) - loc
rot = direction.to_track_quat('-Z', 'Y')
mat = rot.to_matrix().to_4x4()
mat.translation = loc
right = mat.col[0].xyz.copy()
up = mat.col[1].xyz.copy()
return loc, rot, right, up, diag
def build_triangle_mesh(coord, rgb_u8, right, up, diag):
import numpy as np, math
n = int(coord.shape[0])
scale = float(diag) * 0.0042
r = np.array([right.x, right.y, right.z], dtype=np.float64)
u = np.array([up.x, up.y, up.z], dtype=np.float64)
# 用 12 边形替代六边形,减轻“尖尖的”片元边缘
poly_n = 12
poly_offsets = []
for k in range(poly_n):
angle = 2.0 * math.pi * k / poly_n
poly_offsets.append(math.cos(angle) * r + math.sin(angle) * u)
verts_per_pt = poly_n + 1 # center + polygon corners
all_verts = np.empty((n * verts_per_pt, 3), dtype=np.float64)
all_faces = []
for k in range(poly_n):
off = poly_offsets[k] * scale
all_verts[np.arange(n) * verts_per_pt + (k + 1)] = coord + off
all_verts[np.arange(n) * verts_per_pt] = coord # center vertices
for i in range(n):
base = i * verts_per_pt
for k in range(poly_n):
c1 = base + 1 + k
c2 = base + 1 + (k + 1) % poly_n
all_faces.append((base, c1, c2))
mesh = bpy.data.meshes.new('StoryPoints')
mesh.from_pydata([tuple(v) for v in all_verts], [], all_faces)
mesh.update()
attr = mesh.color_attributes.new(name='Col', type='FLOAT_COLOR', domain='POINT')
col = (rgb_u8.astype(np.float32) / 255.0).repeat(verts_per_pt, axis=0)
for i in range(len(attr.data)):
attr.data[i].color = (float(col[i, 0]), float(col[i, 1]), float(col[i, 2]), 1.0)
return mesh
def setup_camera(scene, loc, rot):
bpy.ops.object.camera_add(location=loc)
cam = bpy.context.active_object
cam.rotation_mode = 'QUATERNION'
cam.rotation_quaternion = rot
cam.data.lens = 35
scene.camera = cam
def set_background(scene, mode):
world = scene.world or bpy.data.worlds.new('World')
scene.world = world
world.use_nodes = True
bg = world.node_tree.nodes.get('Background')
if bg is None:
bg = world.node_tree.nodes.new(type='ShaderNodeBackground')
if mode == 'dark':
bg.inputs[0].default_value = (0.02, 0.02, 0.025, 1.0)
elif mode == 'light':
bg.inputs[0].default_value = (0.97, 0.97, 0.975, 1.0)
else:
bg.inputs[0].default_value = (1.0, 1.0, 1.0, 1.0)
bg.inputs[1].default_value = 1.0
def render_png(scene, path, background):
scene.render.engine = 'BLENDER_WORKBENCH'
# Increase native render resolution to soften jagged point boundaries.
scene.render.resolution_x = 2560
scene.render.resolution_y = 1440
scene.render.image_settings.file_format = 'PNG'
scene.render.image_settings.color_mode = 'RGBA'
scene.render.film_transparent = True
scene.view_settings.view_transform = 'Standard'
scene.view_settings.look = 'None'
scene.display.shading.color_type = 'VERTEX'
scene.display.shading.light = 'FLAT'
scene.display.shading.show_cavity = False
scene.display.shading.show_object_outline = False
scene.display.shading.show_specular_highlight = False
scene.render.filepath = path
bpy.ops.render.render(write_still=True)
def main():
import numpy as np
args = parse_args()
asset = np.load(args.asset_npz)
coord = asset['coord'].astype(np.float32)
rgb = asset['rgb'].astype(np.uint8)
meta_path = os.path.splitext(args.asset_npz)[0] + '.json'
meta = {}
if os.path.isfile(meta_path):
meta = json.loads(open(meta_path, 'r', encoding='utf-8').read())
bg = args.background or meta.get('background', 'white')
bpy.ops.wm.read_factory_settings(use_empty=True)
loc, rot, right, up, diag = camera_from_coord(coord)
mesh = build_triangle_mesh(coord, rgb, right, up, diag)
obj = bpy.data.objects.new('StoryObject', mesh)
bpy.context.collection.objects.link(obj)
setup_camera(bpy.context.scene, loc, rot)
set_background(bpy.context.scene, bg)
out_png = os.path.abspath(args.out_png)
os.makedirs(os.path.dirname(out_png) or '.', exist_ok=True)
render_png(bpy.context.scene, out_png, bg)
print('BLENDER_PNG', out_png)
if args.out_blend:
out_blend = os.path.abspath(args.out_blend)
os.makedirs(os.path.dirname(out_blend) or '.', exist_ok=True)
bpy.ops.wm.save_as_mainfile(filepath=out_blend)
print('BLENDER_BLEND', out_blend)
if __name__ == '__main__':
main()
"""
DEFAULT_DATA_ROOT = Path('/mnt/data/AODUOLI/_work_biptv3/pointcept_framework/data/s3dis_official')
DEFAULT_BLENDER_BIN = Path('/mnt/data/AODUOLI/PAMI2026/_tools/blender-4.2.2-linux-x64/blender')
DEFAULT_SEGMENTATOR_CPP = Path('/mnt/data/AODUOLI/PAMI2026/_codex_research/segmentator/csrc/segmentator.cpp')
DEFAULT_SEGMENTATOR_BUILD_DIR = Path('/mnt/data/AODUOLI/PAMI2026/_codex_research/segmentator/csrc/torch_build')
def stable_hash_color(uid: int) -> np.ndarray:
import colorsys
h = (int(uid) * 1103515245 + 12345) & 0x7FFFFFFF
hue = (h & 0xFFFF) / 65536.0
sat = 0.75 + ((h >> 16) & 0xFF) / 255.0 * 0.25 # 0.75-1.00
val = 0.40 + ((h >> 24) & 0x7F) / 127.0 * 0.30 # 0.40-0.70
r, g, b = colorsys.hsv_to_rgb(hue, sat, val)
return np.array([int(r * 255), int(g * 255), int(b * 255)], dtype=np.uint8)
S3DIS_SEGMENT_RGB_U8 = np.array([
[230, 230, 235], # ceiling
[184, 133, 77], # floor
[107, 184, 122], # wall
[89, 140, 217], # beam
[242, 191, 64], # column
[115, 217, 242], # window
[235, 89, 89], # door
[242, 140, 51], # table
[191, 115, 235], # chair
[140, 102, 224], # sofa
[128, 209, 115], # bookcase
[224, 128, 166], # board
[140, 140, 148], # clutter
], dtype=np.uint8)
def _clip_rgb01(rgb) -> tuple[float, float, float]:
return tuple(float(np.clip(c, 0.0, 1.0)) for c in rgb)
def semantic_family_color(segment_id: int, superpoint_id: int, theme: str) -> np.ndarray:
"""Same semantic label -> same fixed semantic palette, with mild intra-class variation."""
sid = int(segment_id)
if 0 <= sid < len(S3DIS_SEGMENT_RGB_U8):
base_rgb = S3DIS_SEGMENT_RGB_U8[sid].astype(np.float32) / 255.0
else:
hue = ((sid * 0.137) % 1.0)
base_rgb = np.array(colorsys.hsv_to_rgb(hue, 0.45, 0.86), dtype=np.float32)
h, s, v = colorsys.rgb_to_hsv(*_clip_rgb01(base_rgb))
jitter = (int(superpoint_id) * 1103515245 + 12345) & 0x7FFFFFFF
if theme == 'dark':
sat = np.clip(s * (0.94 + ((jitter >> 8) & 0xFF) / 255.0 * 0.16), 0.28, 0.88)
val = np.clip(v * (0.92 + ((jitter >> 16) & 0xFF) / 255.0 * 0.16), 0.55, 0.96)
elif theme == 'light':
sat = np.clip(s * (0.90 + ((jitter >> 8) & 0xFF) / 255.0 * 0.12), 0.18, 0.80)
val = np.clip(v * (0.95 + ((jitter >> 16) & 0xFF) / 255.0 * 0.10), 0.68, 0.98)
else:
sat = np.clip(s * (0.92 + ((jitter >> 8) & 0xFF) / 255.0 * 0.12), 0.22, 0.84)
val = np.clip(v * (0.96 + ((jitter >> 16) & 0xFF) / 255.0 * 0.08), 0.74, 0.98)
rgb = colorsys.hsv_to_rgb(h, sat, val)
return np.array([int(c * 255) for c in _clip_rgb01(rgb)], dtype=np.uint8)
def canonical_generate_superpoints(coords: np.ndarray, normals: np.ndarray | None = None, voxel_size: float = 0.12, normal_bins: int = 8) -> np.ndarray:
coords = np.asarray(coords, dtype=np.float32)
coord_min = coords.min(axis=0, keepdims=True)
voxel_coord = np.floor((coords - coord_min) / max(float(voxel_size), 1e-4)).astype(np.int64)
if normals is not None and len(normals) == len(coords):
normals = np.asarray(normals, dtype=np.float32)
normals = normals / (np.linalg.norm(normals, axis=1, keepdims=True) + 1e-8)
normal_q = np.floor((normals + 1.0) * 0.5 * normal_bins).astype(np.int64)
normal_q = np.clip(normal_q, 0, normal_bins)
tokens = np.concatenate([voxel_coord, normal_q], axis=1)
else:
tokens = voxel_coord
_, inverse = np.unique(tokens, axis=0, return_inverse=True)
return inverse.astype(np.int32)
def load_segmentator_module(segmentator_cpp: Path, build_dir: Path, verbose: bool = False):
from torch.utils.cpp_extension import load
segmentator_cpp = Path(segmentator_cpp)
build_dir = Path(build_dir)
if not segmentator_cpp.is_file():
raise FileNotFoundError(f'Segmentator source not found: {segmentator_cpp}')
build_dir.mkdir(parents=True, exist_ok=True)
return load(
name='libsegmentator_dyn',
sources=[str(segmentator_cpp)],
build_directory=str(build_dir),
extra_cflags=['-O3'],
verbose=verbose,
)
def segmentator_generate_superpoints(
coords: np.ndarray,
normals: np.ndarray,
knn_k: int = 50,
k_thresh: float = 0.01,
seg_min_verts: int = 20,
segmentator_cpp: Path = DEFAULT_SEGMENTATOR_CPP,
build_dir: Path = DEFAULT_SEGMENTATOR_BUILD_DIR,
build_verbose: bool = False,
) -> np.ndarray:
import torch
from torch_cluster import knn_graph
coords = np.ascontiguousarray(np.asarray(coords, dtype=np.float32))
normals = np.ascontiguousarray(np.asarray(normals, dtype=np.float32))
if coords.shape != normals.shape:
raise ValueError(f'coords/normals mismatch: {coords.shape} vs {normals.shape}')
normals = normals / (np.linalg.norm(normals, axis=1, keepdims=True) + 1e-8)
seg = load_segmentator_module(segmentator_cpp=segmentator_cpp, build_dir=build_dir, verbose=build_verbose)
pts = torch.from_numpy(coords)
nrm = torch.from_numpy(normals)
edges = knn_graph(pts, k=int(knn_k)).T.contiguous().to(dtype=torch.int64, device='cpu')
labels = seg.segment_point(pts.contiguous(), nrm.contiguous(), edges, float(k_thresh), int(seg_min_verts))
labels = labels.cpu().numpy().reshape(-1).astype(np.int64)
_, inverse = np.unique(labels, return_inverse=True)
return inverse.astype(np.int32)
def sample_indices(n: int, max_points: int | None, rng: np.random.Generator) -> np.ndarray:
if max_points is None or n <= max_points:
return np.arange(n, dtype=np.int64)
idx = rng.choice(n, size=max_points, replace=False)
return np.sort(idx.astype(np.int64))
def knn_boundary_mask(coords: np.ndarray, labels: np.ndarray, k: int, diff_ratio: float) -> np.ndarray:
if len(coords) <= 3:
return np.zeros(len(coords), dtype=bool)
k_eff = min(int(k) + 1, len(coords))
tree = cKDTree(coords)
_, nn = tree.query(coords, k=k_eff)
if nn.ndim == 1:
nn = nn[:, None]
nbr = nn[:, 1:]
if nbr.size == 0:
return np.zeros(len(coords), dtype=bool)
diff = labels[nbr] != labels[:, None]
return diff.mean(axis=1) >= float(diff_ratio)
def knn_shell_mask(coords: np.ndarray, labels: np.ndarray, focus_label: int, k: int = 26) -> np.ndarray:
focus = labels == int(focus_label)
if not np.any(focus):
return np.zeros(len(coords), dtype=bool)
k_eff = min(int(k) + 1, len(coords))
tree = cKDTree(coords)
_, nn = tree.query(coords[focus], k=k_eff)
if nn.ndim == 1:
nn = nn[:, None]
shell = np.zeros(len(coords), dtype=bool)
shell[np.unique(nn[:, 1:].reshape(-1))] = True
shell[focus] = False
return shell
def auto_focus_label(coords: np.ndarray, labels: np.ndarray) -> int:
uniq, inverse, counts = np.unique(labels, return_inverse=True, return_counts=True)
if uniq.size == 1:
return int(uniq[0])
centroids = np.zeros((uniq.size, 3), dtype=np.float64)
np.add.at(centroids, inverse, coords)
centroids /= counts[:, None]
scene_center = coords.mean(axis=0, keepdims=True)
lo = max(120, np.percentile(counts, 60))
hi = max(lo, np.percentile(counts, 95))
candidate = (counts >= lo) & (counts <= hi)
if not candidate.any():
candidate = counts >= np.median(counts)
dist = np.linalg.norm(centroids - scene_center, axis=1)
score = dist / np.log1p(counts)
score = np.where(candidate, score, np.inf)
return int(uniq[int(np.argmin(score))])
def local_focus_indices(coords: np.ndarray, focus_mask: np.ndarray, limit: int) -> np.ndarray:
if not np.any(focus_mask):
return np.arange(min(limit, len(coords)), dtype=np.int64)
center = coords[focus_mask].mean(axis=0)
tree = cKDTree(coords)
_, idx = tree.query(center, k=min(int(limit), len(coords)))
idx = np.atleast_1d(idx).astype(np.int64)
focus_idx = np.flatnonzero(focus_mask)
merged = np.unique(np.concatenate([idx, focus_idx]))
if len(merged) > limit:
d = np.linalg.norm(coords[merged] - center[None, :], axis=1)
merged = merged[np.argsort(d)[:limit]]
return np.sort(merged.astype(np.int64))
def cutaway_indices(
coords: np.ndarray,
max_points: int,
rng: np.random.Generator,
ceiling_ratio: float = 0.86,
wall_depth: float = 0.12,
walls: tuple[str, ...] = ('xmin', 'ymin', 'xmax'),
) -> np.ndarray:
n = len(coords)
if n == 0:
return np.empty((0,), dtype=np.int64)
pmin = coords.min(axis=0)
pmax = coords.max(axis=0)
prange = np.maximum(pmax - pmin, 1e-6)
keep = coords[:, 2] < (pmin[2] + prange[2] * float(ceiling_ratio))
for wall in walls:
if wall == 'xmin':
keep &= coords[:, 0] > (pmin[0] + prange[0] * float(wall_depth))
elif wall == 'xmax':
keep &= coords[:, 0] < (pmax[0] - prange[0] * float(wall_depth))
elif wall == 'ymin':
keep &= coords[:, 1] > (pmin[1] + prange[1] * float(wall_depth))
elif wall == 'ymax':
keep &= coords[:, 1] < (pmax[1] - prange[1] * float(wall_depth))
idx = np.flatnonzero(keep)
if len(idx) == 0:
return sample_indices(n, max_points, rng)
if len(idx) > max_points:
idx = rng.choice(idx, size=max_points, replace=False)
return np.sort(idx.astype(np.int64))
def build_rgb(
mode: str,
superpoint: np.ndarray,
segment: np.ndarray,
sp_boundary: np.ndarray,
seg_boundary: np.ndarray,
focus_label: int,
focus_shell: np.ndarray,
theme: str,
) -> np.ndarray:
rgb = np.zeros((len(superpoint), 3), dtype=np.uint8)
dark = theme == 'dark'
if mode == 'superpoint':
for u in np.unique(superpoint):
rgb[superpoint == u] = stable_hash_color(int(u))
return rgb
if mode == 'semantic_family':
for u in np.unique(superpoint):
mask = superpoint == u
seg_vals = segment[mask].astype(np.int64)
seg_vals = seg_vals[seg_vals >= 0]
dom_seg = int(np.bincount(seg_vals).argmax()) if seg_vals.size else -1
rgb[mask] = semantic_family_color(dom_seg, int(u), theme)
return rgb
if dark:
base = np.array([40, 40, 45], dtype=np.uint8)
neutral = np.array([28, 28, 34], dtype=np.uint8)
focus_bg = np.array([20, 20, 24], dtype=np.uint8)
same_seg = np.array([66, 82, 95], dtype=np.uint8)
elif theme == 'light':
base = np.array([235, 235, 238], dtype=np.uint8)
neutral = np.array([245, 245, 247], dtype=np.uint8)
focus_bg = np.array([248, 248, 250], dtype=np.uint8)
same_seg = np.array([218, 223, 229], dtype=np.uint8)
else:
base = np.array([242, 242, 242], dtype=np.uint8)
neutral = np.array([250, 250, 250], dtype=np.uint8)
focus_bg = np.array([252, 252, 252], dtype=np.uint8)
same_seg = np.array([224, 229, 235], dtype=np.uint8)
orange = np.array([236, 141, 53], dtype=np.uint8)
cyan = np.array([56, 168, 240], dtype=np.uint8)
red = np.array([220, 72, 60], dtype=np.uint8)
shell = np.array([95, 197, 226], dtype=np.uint8)
focus = np.array([236, 96, 67], dtype=np.uint8)
if mode == 'sp_boundary':
rgb[:] = base
rgb[sp_boundary] = orange
return rgb
if mode == 'segment_boundary':
rgb[:] = base
rgb[seg_boundary] = cyan
return rgb
if mode == 'boundary_relation':
both = sp_boundary & seg_boundary
sp_only = sp_boundary & ~seg_boundary
seg_only = ~sp_boundary & seg_boundary
rgb[:] = neutral
rgb[seg_only] = cyan
rgb[sp_only] = orange
rgb[both] = red
return rgb
if mode == 'focus_superpoint':
focus_mask = superpoint == int(focus_label)
same_seg_mask = np.zeros_like(focus_mask)
if np.any(focus_mask):
dom_seg = int(np.bincount(segment[focus_mask]).argmax())
same_seg_mask = segment == dom_seg
rgb[:] = focus_bg
rgb[same_seg_mask] = same_seg
rgb[focus_shell] = shell
rgb[focus_mask] = focus
return rgb
raise ValueError(mode)
def save_asset(path: Path, coord: np.ndarray, rgb: np.ndarray, meta: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
np.savez_compressed(path, coord=coord.astype(np.float32), rgb=rgb.astype(np.uint8))
path.with_suffix('.json').write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding='utf-8')
def build_story_grid(room_out: Path, room_tag: str, theme: str) -> Path | None:
try:
from PIL import Image, ImageDraw
except Exception:
return None
order = [
('semantic_family', 'Semantic-Family Superpoints'),
('semantic_family_cutaway', 'Semantic-Family Cutaway'),
('superpoint', 'Raw-ID Superpoints'),
('superpoint_cutaway', 'Raw-ID Cutaway'),
('sp_boundary', 'Superpoint Boundary'),
('segment_boundary', 'Semantic Boundary'),
('boundary_relation', 'Boundary Relation'),
('focus_superpoint', 'Focus Superpoint'),
]
imgs = []
for suffix, title in order:
p = room_out / f'{room_tag}_{suffix}.png'
if p.is_file():
imgs.append((title, Image.open(p).convert('RGB')))
if not imgs:
return None
card_w = 960
card_h = 600
pad = 24
cols = 2
rows = (len(imgs) + cols - 1) // cols
if theme == 'dark':
canvas_bg = (18, 18, 22)
panel_bg = (28, 28, 34)
title_fg = (235, 235, 240)
elif theme == 'light':
canvas_bg = (245, 245, 247)
panel_bg = (252, 252, 253)
title_fg = (38, 38, 45)
else:
canvas_bg = (255, 255, 255)
panel_bg = (250, 250, 250)
title_fg = (25, 25, 28)
canvas = Image.new('RGB', (cols * card_w + (cols + 1) * pad, rows * card_h + (rows + 1) * pad), canvas_bg)
for idx, (title, im) in enumerate(imgs):
r = idx // cols
c = idx % cols
x = pad + c * (card_w + pad)
y = pad + r * (card_h + pad)
panel = Image.new('RGB', (card_w, card_h), panel_bg)
thumb = im.copy()
thumb.thumbnail((card_w, card_h - 36))
ox = (card_w - thumb.width) // 2
oy = 36 + (card_h - 36 - thumb.height) // 2
panel.paste(thumb, (ox, oy))
d = ImageDraw.Draw(panel)
d.text((18, 10), title, fill=title_fg)
canvas.paste(panel, (x, y))
out = room_out / f'{room_tag}_story_grid.png'
canvas.save(out)
return out
def resolve_room_dir(data_root: Path, room: str) -> Path:
room_dir = data_root / room
if not room_dir.is_dir():
raise FileNotFoundError(f'Room not found: {room_dir}')
return room_dir
def load_superpoint_labels(
room_dir: Path,
source: str,
label_npy: Path | None,
voxel_size: float,
normal_bins: int,
knn_k: int,
k_thresh: float,
seg_min_verts: int,
segmentator_cpp: Path,
segmentator_build_dir: Path,
build_verbose: bool,
) -> tuple[np.ndarray, str]:
coord = np.load(room_dir / 'coord.npy').astype(np.float32)
normal_file = room_dir / 'normal.npy'
normals = np.load(normal_file).astype(np.float32) if normal_file.is_file() else None
if label_npy is not None:
labels = np.load(label_npy).reshape(-1).astype(np.int64)
return labels, str(label_npy)
if source == 'existing':
p = room_dir / 'superpoint.npy'
labels = np.load(p).reshape(-1).astype(np.int64)
return labels, str(p)
if source == 'canonical':
labels = canonical_generate_superpoints(coord, normals, voxel_size=voxel_size, normal_bins=normal_bins).astype(np.int64)
return labels, f'canonical(voxel={voxel_size}, normal_bins={normal_bins})'
if source == 'segmentator':
if normals is None:
raise FileNotFoundError(f'normal.npy is required for segmentator source: {room_dir}')
labels = segmentator_generate_superpoints(
coord,
normals,
knn_k=knn_k,
k_thresh=k_thresh,
seg_min_verts=seg_min_verts,
segmentator_cpp=segmentator_cpp,
build_dir=segmentator_build_dir,
build_verbose=build_verbose,
).astype(np.int64)
return labels, f'segmentator(knn_k={knn_k}, k_thresh={k_thresh}, seg_min_verts={seg_min_verts})'
raise ValueError(f'Unknown source: {source}')
def write_generated_labels(room_dir: Path, labels: np.ndarray, output_root: Path) -> Path:
out = output_root / room_dir.parent.name / room_dir.name / 'superpoint.npy'
out.parent.mkdir(parents=True, exist_ok=True)
np.save(str(out), labels.astype(np.int32))
return out
def prepare_story_assets(
coord: np.ndarray,
segment: np.ndarray,
superpoint: np.ndarray,
label_desc: str,
room: str,
out_dir: Path,
max_points: int,
boundary_knn: int,
sp_boundary_ratio: float,
seg_boundary_ratio: float,
focus_knn: int,
focus_label: int | None,
focus_view_points: int,
modes: list[str],
background_theme: str,
) -> dict:
rng = np.random.default_rng(0)
base_idx = sample_indices(len(coord), max_points, rng)
cut_idx = cutaway_indices(coord, max_points, rng)
coord_b = coord[base_idx]
segment_b = segment[base_idx]
superpoint_b = superpoint[base_idx]
coord_c = coord[cut_idx]
segment_c = segment[cut_idx]
superpoint_c = superpoint[cut_idx]
sp_boundary_b = knn_boundary_mask(coord_b, superpoint_b, k=boundary_knn, diff_ratio=sp_boundary_ratio)
seg_boundary_b = knn_boundary_mask(coord_b, segment_b, k=boundary_knn, diff_ratio=seg_boundary_ratio)
sp_boundary_c = knn_boundary_mask(coord_c, superpoint_c, k=boundary_knn, diff_ratio=sp_boundary_ratio)
seg_boundary_c = knn_boundary_mask(coord_c, segment_c, k=boundary_knn, diff_ratio=seg_boundary_ratio)
overlap_b = sp_boundary_b & seg_boundary_b
overlap_c = sp_boundary_c & seg_boundary_c
focus_label = int(focus_label) if focus_label is not None else auto_focus_label(coord, superpoint)
room_tag = room.replace('/', '_')
summary = {
'room': room,
'label_source': label_desc,
'background_theme': background_theme,
'points_full': int(len(coord)),
'points_sampled': int(len(coord_b)),
'points_cutaway_sampled': int(len(coord_c)),
'unique_superpoints_full': int(np.unique(superpoint).size),
'unique_superpoints_sampled': int(np.unique(superpoint_b).size),
'unique_superpoints_cutaway_sampled': int(np.unique(superpoint_c).size),
'sp_boundary_points_sampled': int(sp_boundary_b.sum()),
'segment_boundary_points_sampled': int(seg_boundary_b.sum()),
'boundary_overlap_points_sampled': int(overlap_b.sum()),
'sp_boundary_points_cutaway_sampled': int(sp_boundary_c.sum()),
'segment_boundary_points_cutaway_sampled': int(seg_boundary_c.sum()),
'boundary_overlap_points_cutaway_sampled': int(overlap_c.sum()),
'cutaway': {
'ceiling_ratio': 0.86,
'wall_depth': 0.12,
'walls_removed': ['xmin', 'ymin', 'xmax'],
},
'focus_label': int(focus_label),
'focus_label_points_full': int((superpoint == focus_label).sum()),
'boundary_knn': int(boundary_knn),
'sp_boundary_ratio': float(sp_boundary_ratio),
'seg_boundary_ratio': float(seg_boundary_ratio),
'boundary_definition': 'A point is marked as a boundary point when at least diff_ratio of its k nearest neighbors belong to a different label.',
'modes': list(modes),
}
out_dir.mkdir(parents=True, exist_ok=True)
(out_dir / f'{room_tag}_summary.json').write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding='utf-8')
assets_dir = out_dir / 'assets'
assets_dir.mkdir(parents=True, exist_ok=True)
for mode in modes:
base_mode = mode[:-8] if mode.endswith('_cutaway') else mode
use_cutaway = mode.endswith('_cutaway')
if mode == 'focus_superpoint':
focus_mask_full = superpoint == focus_label
focus_idx = local_focus_indices(coord, focus_mask_full, focus_view_points)
coord_m = coord[focus_idx]
segment_m = segment[focus_idx]
superpoint_m = superpoint[focus_idx]
focus_shell = knn_shell_mask(coord_m, superpoint_m, focus_label, k=focus_knn)
rgb = build_rgb(mode, superpoint_m, segment_m, np.zeros(len(coord_m), dtype=bool), np.zeros(len(coord_m), dtype=bool), focus_label, focus_shell, background_theme)
meta = dict(summary)
meta.update({'mode': mode, 'background': background_theme, 'sampled_points': int(len(coord_m))})
save_asset(assets_dir / f'{room_tag}_{mode}.npz', coord_m, rgb, meta)
continue
if use_cutaway:
coord_m = coord_c
segment_m = segment_c
superpoint_m = superpoint_c
sp_boundary_m = sp_boundary_c
seg_boundary_m = seg_boundary_c
else:
coord_m = coord_b
segment_m = segment_b
superpoint_m = superpoint_b
sp_boundary_m = sp_boundary_b
seg_boundary_m = seg_boundary_b
rgb = build_rgb(base_mode, superpoint_m, segment_m, sp_boundary_m, seg_boundary_m, focus_label, np.zeros(len(coord_m), dtype=bool), background_theme)
meta = dict(summary)
meta.update({'mode': mode, 'background': background_theme, 'sampled_points': int(len(coord_m))})
save_asset(assets_dir / f'{room_tag}_{mode}.npz', coord_m, rgb, meta)
return summary
def _composite_white_bg(png_path: Path, bg_color: tuple = (255, 255, 255)) -> None:
"""将 RGBA PNG 合成到纯色背景上,覆盖原文件。"""
try:
from PIL import Image
im = Image.open(png_path)
if im.mode != 'RGBA':
return
bg = Image.new('RGB', im.size, bg_color)
bg.paste(im, mask=im.split()[3])
bg.save(png_path)
except Exception as e:
print(f'composite failed for {png_path}: {e}')
def render_assets(room_out: Path, blender_bin: Path, background_theme: str = 'white') -> list[Path]:
assets = sorted((room_out / 'assets').glob('*.npz'))
if not assets:
return []
if not blender_bin.exists():
raise FileNotFoundError(f'Blender not found: {blender_bin}')
os.environ.setdefault('HOME', str(Path(room_out).parents[2] / '_tools' / 'blender_home'))
Path(os.environ['HOME']).mkdir(parents=True, exist_ok=True)
helper_file = Path(tempfile.mkdtemp(prefix='sp_bundle_')) / 'render_helper.py'
helper_file.write_text(_RENDER_HELPER, encoding='utf-8')
outputs = []
try:
for asset in assets:
name = asset.stem
out_png = room_out / f'{name}.png'
cmd = [str(blender_bin), '--background', '--python', str(helper_file), '--', '--asset_npz', str(asset), '--out_png', str(out_png)]
subprocess.run(cmd, check=True)
if background_theme != 'dark':
bg = (255, 255, 255) if background_theme == 'white' else (247, 247, 249)
_composite_white_bg(out_png, bg)
outputs.append(out_png)
finally:
try:
helper_file.unlink(missing_ok=True)
helper_file.parent.rmdir()
except Exception:
pass
return outputs
def cmd_generate(args: argparse.Namespace) -> None:
room_dir = resolve_room_dir(args.data_root, args.room)
coord = np.load(room_dir / 'coord.npy').astype(np.float32)
normals = None
normal_file = room_dir / 'normal.npy'
if normal_file.is_file():
normals = np.load(normal_file).astype(np.float32)
if args.method == 'canonical':
labels = canonical_generate_superpoints(coord, normals, voxel_size=args.voxel_size, normal_bins=args.normal_bins)
else:
if normals is None:
raise FileNotFoundError(f'normal.npy is required for segmentator method: {room_dir}')
labels = segmentator_generate_superpoints(
coord,
normals,
knn_k=args.knn_k,
k_thresh=args.k_thresh,
seg_min_verts=args.seg_min_verts,
segmentator_cpp=args.segmentator_cpp,
build_dir=args.segmentator_build_dir,
build_verbose=args.build_verbose,
)
out = write_generated_labels(room_dir, labels, args.output_root)
uniq, cnt = np.unique(labels, return_counts=True)
print(json.dumps({
'room': args.room,
'method': args.method,
'output': str(out),
'num_superpoints': int(uniq.size),
'mean_points_per_superpoint': float(cnt.mean()),
'max_points_per_superpoint': int(cnt.max()),
}, ensure_ascii=False))
def cmd_story(args: argparse.Namespace) -> None:
for room in args.rooms:
room_dir = resolve_room_dir(args.data_root, room)
coord = np.load(room_dir / 'coord.npy').astype(np.float32)
segment = np.load(room_dir / 'segment.npy').reshape(-1).astype(np.int64)
superpoint, label_desc = load_superpoint_labels(
room_dir,
args.source,
args.label_npy,
args.voxel_size,
args.normal_bins,
args.knn_k,
args.k_thresh,
args.seg_min_verts,
args.segmentator_cpp,
args.segmentator_build_dir,
args.build_verbose,
)
room_tag = room.replace('/', '_')
room_out = args.output_root / room_tag
summary = prepare_story_assets(
coord,
segment,
superpoint,
label_desc,
room,
room_out,
args.max_points,
args.boundary_knn,
args.sp_boundary_ratio,
args.seg_boundary_ratio,
args.focus_knn,
args.focus_label,
args.focus_view_points,
args.modes,
args.background_theme,
)
print(json.dumps({'room': room, **summary}, ensure_ascii=False))
if args.render:
render_assets(room_out, args.blender_bin, args.background_theme)
grid = build_story_grid(room_out, room_tag, args.background_theme)
if grid is not None:
print(grid)
def build_parser() -> argparse.ArgumentParser:
ap = argparse.ArgumentParser(description='One-file delivery bundle for superpoint generation and Blender analysis views')
sub = ap.add_subparsers(dest='cmd', required=True)
p_gen = sub.add_parser('generate', help='Generate superpoint labels')
p_gen.add_argument('--data_root', type=Path, default=DEFAULT_DATA_ROOT)
p_gen.add_argument('--room', type=str, required=True)
p_gen.add_argument('--output_root', type=Path, required=True)
p_gen.add_argument('--method', choices=['segmentator', 'canonical'], default='segmentator')
p_gen.add_argument('--voxel_size', type=float, default=0.12)
p_gen.add_argument('--normal_bins', type=int, default=8)
p_gen.add_argument('--knn_k', type=int, default=50)
p_gen.add_argument('--k_thresh', type=float, default=0.01)
p_gen.add_argument('--seg_min_verts', type=int, default=20)
p_gen.add_argument('--segmentator_cpp', type=Path, default=DEFAULT_SEGMENTATOR_CPP)
p_gen.add_argument('--segmentator_build_dir', type=Path, default=DEFAULT_SEGMENTATOR_BUILD_DIR)
p_gen.add_argument('--build_verbose', action='store_true')
p_gen.set_defaults(func=cmd_generate)
p_story = sub.add_parser('story', help='Build Blender-ready superpoint analysis views')
p_story.add_argument('--data_root', type=Path, default=DEFAULT_DATA_ROOT)
p_story.add_argument('--rooms', nargs='+', required=True)
p_story.add_argument('--output_root', type=Path, required=True)
p_story.add_argument('--source', choices=['existing', 'canonical', 'segmentator'], default='segmentator')
p_story.add_argument('--label_npy', type=Path, default=None)
p_story.add_argument('--voxel_size', type=float, default=0.12)
p_story.add_argument('--normal_bins', type=int, default=8)
p_story.add_argument('--knn_k', type=int, default=50)
p_story.add_argument('--k_thresh', type=float, default=0.01)
p_story.add_argument('--seg_min_verts', type=int, default=20)
p_story.add_argument('--segmentator_cpp', type=Path, default=DEFAULT_SEGMENTATOR_CPP)
p_story.add_argument('--segmentator_build_dir', type=Path, default=DEFAULT_SEGMENTATOR_BUILD_DIR)
p_story.add_argument('--build_verbose', action='store_true')
p_story.add_argument('--max_points', type=int, default=38000)
p_story.add_argument('--boundary_knn', type=int, default=20)
p_story.add_argument('--sp_boundary_ratio', type=float, default=0.90)
p_story.add_argument('--seg_boundary_ratio', type=float, default=0.30)
p_story.add_argument('--focus_knn', type=int, default=26)
p_story.add_argument('--focus_label', type=int, default=None)
p_story.add_argument('--focus_view_points', type=int, default=12000)
p_story.add_argument('--background_theme', choices=['white', 'light', 'dark'], default='white')
p_story.add_argument('--modes', nargs='+', default=['semantic_family', 'semantic_family_cutaway', 'superpoint', 'superpoint_cutaway', 'sp_boundary', 'segment_boundary', 'boundary_relation', 'focus_superpoint'])
p_story.add_argument('--render', action='store_true')
p_story.add_argument('--blender_bin', type=Path, default=DEFAULT_BLENDER_BIN)
p_story.set_defaults(func=cmd_story)
return ap
def main() -> None:
ap = build_parser()
args = ap.parse_args()
args.func(args)
if __name__ == '__main__':
main()