reconstruct3d 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,256 @@
1
+ """MVS-lite: densificación por stereo de dos vistas usando las poses ya estimadas.
2
+
3
+ Para cada par de frames vecinos registrados:
4
+ 1. Calcula la pose relativa entre las dos cámaras.
5
+ 2. Rectifica el par (stereoRectify + remap) -> filas epipolares alineadas.
6
+ 3. Disparidad densa con StereoSGBM.
7
+ 4. Retroproyecta a 3D (reprojectImageTo3D) en el frame rectificado.
8
+ 5. Transforma esos puntos al mundo con la pose conocida y acumula.
9
+
10
+ No es MVS "real" (sin fusión multi-vista ni refinamiento de profundidad), pero
11
+ convierte la nube rala (~30k pts) en una densa usando solo OpenCV en CPU. La
12
+ calidad depende del baseline: pares con poca traslación se descartan.
13
+
14
+ Entrada: map_state.npy (poses) + video. Salida: dense_cloud.ply.
15
+ """
16
+ import os
17
+ import argparse
18
+ import threading
19
+ from concurrent.futures import ThreadPoolExecutor
20
+ import numpy as np
21
+ import cv2
22
+ from tqdm import tqdm
23
+
24
+ from reconstruct3d.core import CameraConfig, MAP_STATE_FILE, resolve_workers
25
+ from reconstruct3d.init_sfm import export_ply
26
+
27
+ DENSE_CLOUD_FILE = "dense_cloud.ply"
28
+
29
+ # StereoSGBM tampoco es thread-safe si se comparte; un objeto por hilo.
30
+ _tls_dense = threading.local()
31
+
32
+
33
+ def _tls_sgbm(num_disp, block):
34
+ s = getattr(_tls_dense, "sgbm", None)
35
+ if s is None or getattr(_tls_dense, "cfg", None) != (num_disp, block):
36
+ s = _make_sgbm(num_disp, block)
37
+ _tls_dense.sgbm = s
38
+ _tls_dense.cfg = (num_disp, block)
39
+ return s
40
+
41
+
42
+ def _relative_pose(P_a, P_b):
43
+ """R,T que llevan un punto de la cámara A a la cámara B (X_b = R X_a + T).
44
+ Poses son world->cam (4x4)."""
45
+ R_a, t_a = P_a[:3, :3], P_a[:3, 3]
46
+ R_b, t_b = P_b[:3, :3], P_b[:3, 3]
47
+ R_rel = R_b @ R_a.T
48
+ t_rel = t_b - R_rel @ t_a
49
+ return R_rel, t_rel
50
+
51
+
52
+ def _make_sgbm(num_disp=128, block=5):
53
+ return cv2.StereoSGBM_create(
54
+ minDisparity=0,
55
+ numDisparities=num_disp, # múltiplo de 16
56
+ blockSize=block,
57
+ P1=8 * 3 * block ** 2,
58
+ P2=32 * 3 * block ** 2,
59
+ disp12MaxDiff=1,
60
+ uniquenessRatio=10,
61
+ speckleWindowSize=100,
62
+ speckleRange=2,
63
+ mode=cv2.STEREO_SGBM_MODE_SGBM_3WAY,
64
+ )
65
+
66
+
67
+ def _voxel_downsample(pts, colors, voxel):
68
+ if voxel <= 0 or len(pts) == 0:
69
+ return pts, colors
70
+ keys = np.floor(pts / voxel).astype(np.int64)
71
+ _, idx = np.unique(keys, axis=0, return_index=True)
72
+ return pts[idx], colors[idx]
73
+
74
+
75
+ def densify_pair(frame_a, frame_b, P_a, P_b, K, sgbm, max_depth):
76
+ """Devuelve (pts_world Nx3, colors_bgr Nx3) del par. Vacío si falla."""
77
+ h, w = frame_a.shape[:2]
78
+ dist = np.zeros(5)
79
+ R_rel, t_rel = _relative_pose(P_a, P_b)
80
+
81
+ R1, R2, P1, P2, Q, _, _ = cv2.stereoRectify(
82
+ K, dist, K, dist, (w, h), R_rel, t_rel.reshape(3, 1),
83
+ flags=cv2.CALIB_ZERO_DISPARITY, alpha=0,
84
+ )
85
+ # La profundidad reconstruida es Z ≈ f / (disp · Q[3,2]), así que para que
86
+ # las disparidades positivas de SGBM den Z>0 hace falta Q[3,2] > 0. Si sale
87
+ # negativo, TODO quedaría con Z<0 y se descartaría: intercambiamos A<->B
88
+ # (lo que invierte el signo de Q[3,2]) y re-rectificamos.
89
+ if Q[3, 2] < 0:
90
+ frame_a, frame_b = frame_b, frame_a
91
+ P_a, P_b = P_b, P_a
92
+ R_rel, t_rel = _relative_pose(P_a, P_b)
93
+ R1, R2, P1, P2, Q, _, _ = cv2.stereoRectify(
94
+ K, dist, K, dist, (w, h), R_rel, t_rel.reshape(3, 1),
95
+ flags=cv2.CALIB_ZERO_DISPARITY, alpha=0,
96
+ )
97
+
98
+ m1x, m1y = cv2.initUndistortRectifyMap(K, dist, R1, P1, (w, h), cv2.CV_32FC1)
99
+ m2x, m2y = cv2.initUndistortRectifyMap(K, dist, R2, P2, (w, h), cv2.CV_32FC1)
100
+ rect_a = cv2.remap(frame_a, m1x, m1y, cv2.INTER_LINEAR)
101
+ rect_b = cv2.remap(frame_b, m2x, m2y, cv2.INTER_LINEAR)
102
+
103
+ disp = sgbm.compute(cv2.cvtColor(rect_a, cv2.COLOR_BGR2GRAY),
104
+ cv2.cvtColor(rect_b, cv2.COLOR_BGR2GRAY)).astype(np.float32) / 16.0
105
+
106
+ pts_rect = cv2.reprojectImageTo3D(disp, Q)
107
+ Z = pts_rect[:, :, 2]
108
+ mask = (disp > sgbm.getMinDisparity()) & np.isfinite(pts_rect).all(axis=2) & (Z > 0)
109
+ if mask.sum() < 50:
110
+ return np.empty((0, 3)), np.empty((0, 3), np.uint8)
111
+
112
+ pts = pts_rect[mask]
113
+ colors = rect_a[mask] # BGR, alineado con la imagen rectificada izquierda
114
+
115
+ # Recorte de profundidad: percentiles del propio par + tope global
116
+ zr = pts[:, 2]
117
+ lo, hi = np.percentile(zr, 2), np.percentile(zr, 98)
118
+ hi = min(hi, max_depth)
119
+ keep = (zr >= lo) & (zr <= hi)
120
+ pts, colors = pts[keep], colors[keep]
121
+
122
+ # rectificado-izq -> cámara A original -> mundo
123
+ pts_cam = (R1.T @ pts.T).T
124
+ R_a, t_a = P_a[:3, :3], P_a[:3, 3]
125
+ pts_world = (R_a.T @ (pts_cam - t_a).T).T
126
+ return pts_world, colors
127
+
128
+
129
+ def _multiview_fuse(all_keys, all_pts, all_cols, min_views):
130
+ """Votación por vóxel: agrupa puntos por celda y conserva solo las celdas
131
+ confirmadas por >= min_views PARES distintos (cada par aporta <=1 punto por
132
+ celda). Devuelve posición y color promedio por celda. Esto elimina el ruido
133
+ SGBM de un solo par (un match espurio no lo confirma ningún otro par)."""
134
+ keys = np.concatenate(all_keys); pts = np.concatenate(all_pts)
135
+ cols = np.concatenate(all_cols).astype(np.float64)
136
+ uniq, inv, cnt = np.unique(keys, axis=0, return_inverse=True, return_counts=True)
137
+ sum_p = np.zeros((len(uniq), 3)); np.add.at(sum_p, inv, pts)
138
+ sum_c = np.zeros((len(uniq), 3)); np.add.at(sum_c, inv, cols)
139
+ keep = cnt >= min_views
140
+ mean_p = (sum_p[keep] / cnt[keep, None])
141
+ mean_c = (sum_c[keep] / cnt[keep, None]).astype(np.uint8)
142
+ return mean_p, mean_c, int(keep.sum()), len(uniq)
143
+
144
+
145
+ def run_dense(out_dir, video_path, neighbor_steps=(1, 2), min_views=2,
146
+ num_disp=128, block=5, voxel=0.0, jobs=0):
147
+ map3d = np.load(os.path.join(out_dir, MAP_STATE_FILE), allow_pickle=True).item()
148
+ cam = CameraConfig.from_dict(map3d.get('camera')) # intrínsecos del map_state
149
+ K = cam.K
150
+ frames = sorted(map3d['poses'].keys())
151
+ poses = map3d['poses']
152
+
153
+ centers = np.array([(-(poses[f][:3, :3].T @ poses[f][:3, 3:]).ravel()) for f in frames])
154
+ pts_sparse = np.asarray(map3d['points'])
155
+ pts_sparse = pts_sparse[np.isfinite(pts_sparse).all(axis=1)]
156
+ scale = float(np.median(np.linalg.norm(pts_sparse - centers.mean(axis=0), axis=1)))
157
+ seg = np.linalg.norm(np.diff(centers, axis=0), axis=1)
158
+ min_baseline = 0.3 * float(np.median(seg))
159
+ max_depth = 5.0 * scale
160
+ if voxel <= 0:
161
+ voxel = scale / 400.0
162
+ print(f"[MVS] escala~{scale:.2f} baseline_min={min_baseline:.3f} max_depth={max_depth:.1f} "
163
+ f"voxel={voxel:.4f} neighbor_steps={list(neighbor_steps)} min_views={min_views}")
164
+
165
+ # Caché de frames (cada uno se decodifica una sola vez; clave para que la
166
+ # fusión multi-vista con muchos pares no relea el video 4K repetidamente).
167
+ cap = cv2.VideoCapture(video_path)
168
+ cache = {}
169
+ for f in tqdm(frames, desc="Cargando frames"):
170
+ cap.set(cv2.CAP_PROP_POS_FRAMES, f)
171
+ ret, fr = cap.read()
172
+ if ret:
173
+ cache[f] = cam.preprocess(fr)
174
+ cap.release()
175
+
176
+ workers = resolve_workers(jobs)
177
+ all_keys, all_pts, all_cols = [], [], []
178
+ used = skipped = 0
179
+
180
+ pairs = [(i, i + s) for i in range(len(frames)) for s in neighbor_steps if i + s < len(frames)]
181
+
182
+ # Cada par es independiente -> se paraleliza. densify_pair es CPU-bound
183
+ # (StereoSGBM libera el GIL), así que los hilos dan speedup real.
184
+ def _task(pair):
185
+ i, j = pair
186
+ fa, fb = frames[i], frames[j]
187
+ if fa not in cache or fb not in cache:
188
+ return None
189
+ if np.linalg.norm(_relative_pose(poses[fa], poses[fb])[1]) < min_baseline:
190
+ return "skip"
191
+ pw, cl = densify_pair(cache[fa], cache[fb], poses[fa], poses[fb], K,
192
+ _tls_sgbm(num_disp, block), max_depth)
193
+ if len(pw) == 0:
194
+ return None
195
+ # 1 punto por vóxel por par -> el conteo por celda = nº de pares que la ven
196
+ pw, cl = _voxel_downsample(pw, cl, voxel)
197
+ return np.floor(pw / voxel).astype(np.int64), pw, cl
198
+
199
+ print(f"[MVS] densificando {len(pairs)} pares con {workers} hilos...")
200
+ if workers == 1:
201
+ results = (_task(p) for p in tqdm(pairs, desc="Densificando (multi-vista)"))
202
+ results = list(results)
203
+ else:
204
+ with ThreadPoolExecutor(max_workers=workers) as ex:
205
+ results = list(tqdm(ex.map(_task, pairs), total=len(pairs),
206
+ desc="Densificando (multi-vista)"))
207
+ for r in results:
208
+ if r is None:
209
+ continue
210
+ if r == "skip":
211
+ skipped += 1
212
+ continue
213
+ keys, pw, cl = r
214
+ all_keys.append(keys); all_pts.append(pw); all_cols.append(cl)
215
+ used += 1
216
+
217
+ if not all_pts:
218
+ print("[MVS] No se generaron puntos densos.")
219
+ return
220
+
221
+ if min_views <= 1:
222
+ pts = np.concatenate(all_pts); cols = np.concatenate(all_cols)
223
+ pts, cols = _voxel_downsample(pts, cols, voxel)
224
+ n_kept, n_total = len(pts), len(pts)
225
+ else:
226
+ pts, cols, n_kept, n_total = _multiview_fuse(all_keys, all_pts, all_cols, min_views)
227
+
228
+ print(f"[MVS] pares usados={used} saltados={skipped} celdas={n_total:,} "
229
+ f"conservadas(>={min_views} vistas)={n_kept:,}")
230
+
231
+ median_pt = np.median(pts, axis=0)
232
+ out_path = os.path.join(out_dir, DENSE_CLOUD_FILE)
233
+ export_ply(out_path, pts - median_pt, cols, [])
234
+ print(f"[MVS] nube densa: {len(pts):,} puntos -> {out_path}")
235
+
236
+
237
+ if __name__ == "__main__":
238
+ parser = argparse.ArgumentParser(description="MVS multi-vista: densificación stereo + fusión por consistencia")
239
+ parser.add_argument("video", help="Path al video original (mp4/MOV).")
240
+ parser.add_argument("--out", required=True, help="Output dir con map_state.npy.")
241
+ parser.add_argument("--neighbor-steps", type=str, default="1,2",
242
+ help="Offsets de vecinos para cada frame de referencia (default '1,2'). "
243
+ "Más vecinos = más cobertura y más votos de consistencia.")
244
+ parser.add_argument("--min-views", type=int, default=2,
245
+ help="Conservar solo puntos confirmados por >= N pares distintos (default 2; "
246
+ "1 = sin fusión). Más alto = más limpio pero menos denso.")
247
+ parser.add_argument("--num-disp", type=int, default=128, help="numDisparities SGBM (múltiplo de 16, default 128).")
248
+ parser.add_argument("--block", type=int, default=5, help="blockSize SGBM (impar, default 5).")
249
+ parser.add_argument("--voxel", type=float, default=0.0,
250
+ help="Tamaño de vóxel para fusión/downsample (default 0 = automático según escala).")
251
+ parser.add_argument("--jobs", type=int, default=0,
252
+ help="Hilos para densificar pares (0=auto=nº de CPUs; 1=secuencial).")
253
+ args = parser.parse_args()
254
+ steps = tuple(int(s) for s in args.neighbor_steps.split(",") if s.strip())
255
+ run_dense(args.out, args.video, neighbor_steps=steps, min_views=args.min_views,
256
+ num_disp=args.num_disp, block=args.block, voxel=args.voxel, jobs=args.jobs)
@@ -0,0 +1,151 @@
1
+ import os
2
+ import sys
3
+ import argparse
4
+ import cv2
5
+ import numpy as np
6
+ import pandas as pd
7
+ from reconstruct3d.core import (
8
+ SfMDatabase, Features, create_frontend,
9
+ filter_triangulation,
10
+ DB_FILE, MAP_STATE_FILE, INIT_CLOUD_FILE,
11
+ )
12
+
13
+ # Inyección en el namespace para permitir la deserialización de pickle
14
+ sys.modules['__main__'].Features = Features
15
+
16
+ def export_ply(filename, points3d, colors, cameras=[]):
17
+ with open(filename, 'w') as f:
18
+ f.write("ply\nformat ascii 1.0\n")
19
+ f.write(f"element vertex {len(points3d) + len(cameras)}\n")
20
+ f.write("property float x\nproperty float y\nproperty float z\n")
21
+ f.write("property uchar red\nproperty uchar green\nproperty uchar blue\n")
22
+ f.write("end_header\n")
23
+ for pt, c in zip(points3d, colors):
24
+ f.write(f"{pt[0]} {pt[1]} {pt[2]} {int(c[2])} {int(c[1])} {int(c[0])}\n") # BGR to RGB
25
+ for cam in cameras:
26
+ f.write(f"{cam[0]} {cam[1]} {cam[2]} 255 0 0\n") # Cámaras en rojo
27
+
28
+ def _try_seed_pair(frontend, fi, fj, cam):
29
+ """Triangula + filtra un par candidato. Siempre retorna dict con diagnósticos;
30
+ 'viable' indica si el seed es usable."""
31
+ res = {'viable': False, 'n_matches': 0, 'e_count': 0, 'chir_count': 0, 'kept_count': 0}
32
+ matches = frontend.match(fi, fj)
33
+ res['n_matches'] = len(matches)
34
+ if len(matches) < 20:
35
+ return res
36
+
37
+ pts1 = fi.pts[[m[0] for m in matches]]
38
+ pts2 = fj.pts[[m[1] for m in matches]]
39
+ colors = fi.colors[[m[0] for m in matches]]
40
+
41
+ E, mask_E = cv2.findEssentialMat(pts1, pts2, cam.K, cv2.RANSAC, 0.999, 1.0)
42
+ if E is None or E.shape != (3, 3):
43
+ return res
44
+ res['e_count'] = int(mask_E.sum())
45
+
46
+ # Pasar TODOS los matches a recoverPose (no solo E-inliers): los outliers
47
+ # actúan como tiebreakers para que la votación de chiralidad elija la
48
+ # descomposición correcta de E (las 4 son casi empatadas si solo das
49
+ # E-inliers en escenas con baseline corta).
50
+ _, R, t, mask_pose = cv2.recoverPose(E, pts1, pts2, cam.K)
51
+ chir_idx = np.where(mask_pose.ravel() > 0)[0]
52
+ res['chir_count'] = len(chir_idx)
53
+ if len(chir_idx) < 20:
54
+ return res
55
+
56
+ pts1_v, pts2_v = pts1[chir_idx], pts2[chir_idx]
57
+ colors_v = colors[chir_idx]
58
+
59
+ P1 = cam.K @ np.hstack((np.eye(3), np.zeros((3, 1))))
60
+ P2 = cam.K @ np.hstack((R, t))
61
+ pts4d = cv2.triangulatePoints(P1, P2, pts1_v.T, pts2_v.T)
62
+ pts3d = (pts4d[:3, :] / pts4d[3, :]).T
63
+
64
+ pose_i = np.eye(4)
65
+ pose_j = np.vstack((np.hstack((R, t)), [0, 0, 0, 1]))
66
+ keep = filter_triangulation(pts3d, pose_i, pose_j, pts1_v, pts2_v, cam.K,
67
+ max_reproj_err=10.0, min_angle_deg=1.0)
68
+ final_idx = chir_idx[keep]
69
+ res.update({
70
+ 'viable': int(keep.sum()) >= 20,
71
+ 'matches': matches,
72
+ 'pts3d': pts3d[keep], 'colors': colors_v[keep],
73
+ 'pose_i': pose_i, 'pose_j': pose_j, 'R': R, 't': t,
74
+ 'kept_count': int(keep.sum()),
75
+ 'valid_idx_kept': final_idx,
76
+ })
77
+ return res
78
+
79
+
80
+ def init_reconstruction(out_dir):
81
+ db = SfMDatabase()
82
+ db.load(os.path.join(out_dir, DB_FILE))
83
+ print(f"[*] Frontend leído de la DB: '{db.frontend_name}'")
84
+ frontend = create_frontend(db.frontend_name)
85
+
86
+ df = pd.DataFrame(db.pairwise)
87
+ df['gap'] = (df['frame_j'] - df['frame_i']).abs()
88
+ top = df.sort_values("inliers", ascending=False).head(20)
89
+ strong = top[top['inliers'] >= 0.6 * top['inliers'].iloc[0]]
90
+ candidates = strong.sort_values('gap', ascending=False)
91
+
92
+ cam = db.get_camera() # intrínsecos persistidos en la DB (o defaults)
93
+ MIN_SEED_POINTS = 25
94
+
95
+ best, tried = None, []
96
+ print("[*] Probando candidatos seed (orden: mayor baseline primero):")
97
+ for _, row in candidates.iterrows():
98
+ i, j = int(row['frame_i']), int(row['frame_j'])
99
+ res = _try_seed_pair(frontend, db.features[i], db.features[j], cam)
100
+ res.update({'i': i, 'j': j, 'inliers_db': int(row['inliers']), 'gap': int(row['gap'])})
101
+ flag = "OK" if res['viable'] else "skip"
102
+ print(f" ({i:4d},{j:4d}) gap={res['gap']:2d} db_inl={res['inliers_db']:4d} "
103
+ f"matches={res['n_matches']:4d} E={res['e_count']:4d} chir={res['chir_count']:4d} "
104
+ f"kept={res['kept_count']:4d} [{flag}]")
105
+ if res['viable']:
106
+ tried.append(res)
107
+ if res['kept_count'] >= MIN_SEED_POINTS:
108
+ best = res
109
+ break
110
+
111
+ if best is None:
112
+ if not tried:
113
+ raise RuntimeError("No se encontró ningún seed pair viable.")
114
+ best = max(tried, key=lambda r: r['kept_count'])
115
+ print(f"[!] Ningún seed alcanzó {MIN_SEED_POINTS} pts; tomando el mejor disponible "
116
+ f"({best['kept_count']} pts).")
117
+
118
+ i, j = best['i'], best['j']
119
+ matches = best['matches']
120
+ pts3d, colors_v = best['pts3d'], best['colors']
121
+ pose_i, pose_j = best['pose_i'], best['pose_j']
122
+ R, t = best['R'], best['t']
123
+ valid_idx_kept = best['valid_idx_kept']
124
+ print(f"[*] Seed elegido: ({i}, {j}) gap={best['gap']} puntos finales={best['kept_count']}")
125
+
126
+ # Map3D State
127
+ map3d = {
128
+ 'points': pts3d, 'colors': colors_v,
129
+ 'poses': {i: pose_i, j: pose_j},
130
+ # Registros: {frame_id: {kp_idx: pt3d_idx}}
131
+ 'obs': {
132
+ i: {matches[k][0]: idx for idx, k in enumerate(valid_idx_kept)},
133
+ j: {matches[k][1]: idx for idx, k in enumerate(valid_idx_kept)}
134
+ },
135
+ 'frontend': db.frontend_name,
136
+ 'camera': cam.to_dict(), # propaga los intrínsecos a las etapas que solo leen map_state
137
+ }
138
+
139
+ os.makedirs(out_dir, exist_ok=True)
140
+ np.save(os.path.join(out_dir, MAP_STATE_FILE), map3d)
141
+
142
+ cam_centers = [np.zeros(3), -R.T @ t.ravel()]
143
+ ply_path = os.path.join(out_dir, INIT_CLOUD_FILE)
144
+ export_ply(ply_path, pts3d, colors_v, cam_centers)
145
+ print(f"Triangulación inicial: {len(pts3d)} puntos. Exportado a {ply_path}")
146
+
147
+ if __name__ == "__main__":
148
+ parser = argparse.ArgumentParser(description="SfM init: triangulate seed pair from DB")
149
+ parser.add_argument("--out", required=True, help="Output dir containing sfm_data.pkl (from core.py).")
150
+ args = parser.parse_args()
151
+ init_reconstruction(args.out)
reconstruct3d/mesh.py ADDED
@@ -0,0 +1,99 @@
1
+ """Etapa de mallado: reconstruye una malla triangulada desde la nube densa usando
2
+ CGAL (Advancing Front o Poisson).
3
+
4
+ El cómputo lo hace un pequeño programa en C++ (`cgal_mesh/mesh_reconstruct.cpp`)
5
+ que enlaza CGAL. Este módulo se encarga de **compilarlo bajo demanda** (CMake) la
6
+ primera vez y luego invocarlo sobre `dense_cloud.ply` (o la nube que se indique)
7
+ para producir `mesh.ply`.
8
+
9
+ Requisitos del sistema (no se instalan con uv, son nativos): CGAL, boost, gmp,
10
+ mpfr, eigen, cmake y un compilador C++. En macOS: `brew install cgal cmake eigen`.
11
+
12
+ Uso:
13
+ uv run python mesh.py --out outputs/sift
14
+ uv run python mesh.py --out outputs/sift --method poisson --smooth 24
15
+ """
16
+ import argparse
17
+ import os
18
+ import shutil
19
+ import subprocess
20
+ import sys
21
+
22
+ DENSE_CLOUD_FILE = "dense_cloud.ply"
23
+ MESH_FILE = "mesh.ply"
24
+
25
+ _HERE = os.path.dirname(os.path.abspath(__file__))
26
+ CGAL_DIR = os.path.join(_HERE, "cgal_mesh")
27
+ BUILD_DIR = os.path.join(CGAL_DIR, "build")
28
+ BINARY = os.path.join(BUILD_DIR, "mesh_reconstruct")
29
+
30
+
31
+ def ensure_built(force=False):
32
+ """Compila el binario CGAL si falta (o si --force). Devuelve la ruta al binario."""
33
+ if os.path.exists(BINARY) and not force:
34
+ return BINARY
35
+ if shutil.which("cmake") is None:
36
+ raise RuntimeError(
37
+ "cmake no está instalado. Instala las dependencias nativas de CGAL:\n"
38
+ " macOS: brew install cgal cmake eigen boost gmp mpfr\n"
39
+ " Debian: sudo apt install libcgal-dev cmake libeigen3-dev")
40
+ print(f"[mesh] compilando el binario CGAL en {BUILD_DIR} ...")
41
+ subprocess.run(["cmake", "-S", CGAL_DIR, "-B", BUILD_DIR,
42
+ "-DCMAKE_BUILD_TYPE=Release"], check=True)
43
+ subprocess.run(["cmake", "--build", BUILD_DIR, "-j",
44
+ str(os.cpu_count() or 2)], check=True)
45
+ if not os.path.exists(BINARY):
46
+ raise RuntimeError(f"La compilación terminó pero no se encontró {BINARY}")
47
+ return BINARY
48
+
49
+
50
+ def run_mesh(out_dir, input_ply=None, method="afront", outlier_pct=2.0,
51
+ simplify=0.0, smooth=0, mesh_smooth=2, min_component=0.002,
52
+ afront_radius=5.0, poisson_spacing_mult=1.0, rebuild=False):
53
+ """Genera out_dir/mesh.ply desde la nube densa (o input_ply) con CGAL."""
54
+ binary = ensure_built(force=rebuild)
55
+ src = input_ply or os.path.join(out_dir, DENSE_CLOUD_FILE)
56
+ if not os.path.exists(src):
57
+ raise FileNotFoundError(
58
+ f"No existe la nube de entrada '{src}'. Corre primero `dense` "
59
+ "(o pasa la ruta del .ply).")
60
+ dst = os.path.join(out_dir, MESH_FILE)
61
+
62
+ cmd = [binary, src, dst,
63
+ "--method", method,
64
+ "--outlier-pct", str(outlier_pct),
65
+ "--simplify", str(simplify),
66
+ "--smooth", str(smooth),
67
+ "--mesh-smooth", str(mesh_smooth),
68
+ "--min-component", str(min_component),
69
+ "--afront-radius", str(afront_radius),
70
+ "--poisson-spacing-mult", str(poisson_spacing_mult)]
71
+ print(f"[mesh] {' '.join(cmd)}")
72
+ subprocess.run(cmd, check=True)
73
+ print(f"[mesh] malla escrita en {dst}")
74
+ return dst
75
+
76
+
77
+ if __name__ == "__main__":
78
+ p = argparse.ArgumentParser(description="Mallado de la nube densa con CGAL.")
79
+ p.add_argument("--out", required=True, help="Directorio con dense_cloud.ply (y donde se escribe mesh.ply).")
80
+ p.add_argument("--input", default=None, help="Nube .ply de entrada (default: <out>/dense_cloud.ply).")
81
+ p.add_argument("--method", choices=["afront", "poisson"], default="afront",
82
+ help="afront=interpola puntos, conserva color, bordes abiertos (default). "
83
+ "poisson=superficie suave/cerrada, tolera ruido.")
84
+ p.add_argument("--outlier-pct", type=float, default=2.0, help="%% de outliers a eliminar (0=off).")
85
+ p.add_argument("--simplify", type=float, default=0.0,
86
+ help="Celda de grid simplify (0=auto~spacing; <0=sin simplificar).")
87
+ p.add_argument("--smooth", type=int, default=0, help="Vecinos de jet smooth de PUNTOS (0=off).")
88
+ p.add_argument("--mesh-smooth", type=int, default=2,
89
+ help="Iteraciones de suavizado tangencial de la MALLA (default 2; 0=off).")
90
+ p.add_argument("--min-component", type=float, default=0.002,
91
+ help="Elimina componentes < FRAC*caras, p.ej. islas de ruido (default 0.002; 0=off).")
92
+ p.add_argument("--afront-radius", type=float, default=5.0, help="Longitud máx. de arista (x spacing).")
93
+ p.add_argument("--poisson-spacing-mult", type=float, default=1.0, help="Multiplicador de spacing para Poisson.")
94
+ p.add_argument("--rebuild", action="store_true", help="Recompilar el binario CGAL.")
95
+ args = p.parse_args()
96
+ run_mesh(args.out, input_ply=args.input, method=args.method, outlier_pct=args.outlier_pct,
97
+ simplify=args.simplify, smooth=args.smooth, mesh_smooth=args.mesh_smooth,
98
+ min_component=args.min_component, afront_radius=args.afront_radius,
99
+ poisson_spacing_mult=args.poisson_spacing_mult, rebuild=args.rebuild)