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.
- reconstruct3d/__init__.py +17 -0
- reconstruct3d/api.py +218 -0
- reconstruct3d/bundle_adjust.py +370 -0
- reconstruct3d/calibrate.py +199 -0
- reconstruct3d/cgal_mesh/CMakeLists.txt +22 -0
- reconstruct3d/cgal_mesh/mesh_reconstruct.cpp +312 -0
- reconstruct3d/chunked.py +335 -0
- reconstruct3d/cli.py +448 -0
- reconstruct3d/core.py +515 -0
- reconstruct3d/dense_mvs.py +256 -0
- reconstruct3d/init_sfm.py +151 -0
- reconstruct3d/mesh.py +99 -0
- reconstruct3d/track_sfm.py +253 -0
- reconstruct3d/viewer.py +108 -0
- reconstruct3d-0.1.0.dist-info/METADATA +416 -0
- reconstruct3d-0.1.0.dist-info/RECORD +19 -0
- reconstruct3d-0.1.0.dist-info/WHEEL +4 -0
- reconstruct3d-0.1.0.dist-info/entry_points.txt +2 -0
- reconstruct3d-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|