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,253 @@
1
+ import os
2
+ import sys
3
+ import argparse
4
+ import cv2
5
+ import numpy as np
6
+ from reconstruct3d.core import (
7
+ SfMDatabase, Features, create_frontend,
8
+ filter_triangulation,
9
+ DB_FILE, MAP_STATE_FILE, TRACKED_CLOUD_FILE,
10
+ )
11
+ from reconstruct3d.init_sfm import export_ply
12
+ from reconstruct3d.bundle_adjust import run_local_ba, fuse_map_points
13
+
14
+ sys.modules['__main__'].Features = Features
15
+
16
+ def triangulate_new_points(k, ref, map3d, db, cam, frontend, min_angle=1.5):
17
+ """
18
+ Triangula nuevas correspondencias entre el frame k y un frame de referencia.
19
+ Impone restricciones epipolares, finitud espacial y quiralidad (Z > 0).
20
+ """
21
+ fk, f_ref = db.features[k], db.features[ref]
22
+ matches = frontend.match(fk, f_ref)
23
+ if not matches: return 0
24
+
25
+ pts_k = fk.pts[[m[0] for m in matches]]
26
+ pts_ref = f_ref.pts[[m[1] for m in matches]]
27
+
28
+ E, mask = cv2.findEssentialMat(pts_k, pts_ref, cam.K, cv2.RANSAC, 0.999, 1.0)
29
+ if E is None: return 0
30
+
31
+ valid_m = np.where(mask.ravel() > 0)[0]
32
+ new_matches = []
33
+
34
+ # Filtrar: Solo triangular si NINGUNO de los keypoints existe ya en el mapa 3D
35
+ for idx in valid_m:
36
+ k_idx, ref_idx = matches[idx]
37
+ if k_idx not in map3d['obs'].get(k, {}) and ref_idx not in map3d['obs'].get(ref, {}):
38
+ new_matches.append((k_idx, ref_idx))
39
+
40
+ if not new_matches: return 0
41
+
42
+ pts_k_new = np.float32([fk.pts[m[0]] for m in new_matches])
43
+ pts_ref_new = np.float32([f_ref.pts[m[1]] for m in new_matches])
44
+ colors_new = np.array([fk.colors[m[0]] for m in new_matches])
45
+
46
+ # Matrices de proyección P = K[R|t]
47
+ Pk = cam.K @ map3d['poses'][k][:3, :4]
48
+ Pref = cam.K @ map3d['poses'][ref][:3, :4]
49
+
50
+ pts4d = cv2.triangulatePoints(Pk, Pref, pts_k_new.T, pts_ref_new.T)
51
+ pts3d = (pts4d[:3, :] / pts4d[3, :]).T
52
+
53
+ valid_mask = filter_triangulation(
54
+ pts3d, map3d['poses'][k], map3d['poses'][ref],
55
+ pts_k_new, pts_ref_new, cam.K,
56
+ max_reproj_err=6.0, min_angle_deg=min_angle,
57
+ )
58
+
59
+ valid_pts = pts3d[valid_mask]
60
+ valid_colors = colors_new[valid_mask]
61
+ valid_matches = [new_matches[i] for i, v in enumerate(valid_mask) if v]
62
+
63
+ # Inyectar al mapa global
64
+ if len(valid_pts) > 0:
65
+ start_idx = len(map3d['points'])
66
+ map3d['points'] = np.vstack((map3d['points'], valid_pts))
67
+ map3d['colors'] = np.vstack((map3d['colors'], valid_colors))
68
+
69
+ for i, (k_idx, ref_idx) in enumerate(valid_matches):
70
+ pt_idx = start_idx + i
71
+ map3d['obs'][k][k_idx] = pt_idx
72
+ map3d['obs'][ref][ref_idx] = pt_idx
73
+
74
+ return len(valid_pts)
75
+
76
+ def track_frames(out_dir, m_window=3, min_angle=1.5, local_ba=True, ba_every=1, fuse=True):
77
+ db = SfMDatabase()
78
+ db.load(os.path.join(out_dir, DB_FILE))
79
+ map_path = os.path.join(out_dir, MAP_STATE_FILE)
80
+ map3d = np.load(map_path, allow_pickle=True).item()
81
+ # Si el map_state es viejo y no trae 'frontend'/'camera', heredarlo de la DB
82
+ if 'frontend' not in map3d:
83
+ map3d['frontend'] = db.frontend_name
84
+
85
+ cam = db.get_camera() # intrínsecos persistidos (consistentes con extract/init)
86
+ if 'camera' not in map3d:
87
+ map3d['camera'] = cam.to_dict()
88
+ print(f"[*] Frontend activo: '{db.frontend_name}'")
89
+ frontend = create_frontend(db.frontend_name)
90
+
91
+ frames_all = sorted(db.features.keys())
92
+ registered = set(map3d['poses'].keys())
93
+
94
+ # Espaciado real entre frames extraídos (= k_skip usado en core.py). Se
95
+ # deriva de los datos para no tener que mantenerlo sincronizado a mano: la
96
+ # ventana de vecinos para PnP es ±m_window frames extraídos.
97
+ diffs = np.diff(frames_all)
98
+ step = int(np.median(diffs)) if len(diffs) else 1
99
+ window = step * m_window
100
+ print(f"[*] Espaciado detectado: {step} frames | ventana PnP: ±{window} frames ({m_window} vecinos)")
101
+
102
+ ba_msg = f"BA local cada {ba_every} reg." if local_ba else "OFF"
103
+ print(f"[*] Iniciando tracking incremental. Camaras base: {len(registered)} | Puntos: {len(map3d['points'])} | {ba_msg}")
104
+ pts_of = lambda f: db.features[f].pts
105
+ reg_count = 0
106
+
107
+ while True:
108
+ candidates = [f for f in frames_all if f not in registered and any(abs(f - r) <= window for r in registered)]
109
+ if not candidates:
110
+ print("[!] No hay más candidatos viables en la ventana. Fin del tracking.")
111
+ break
112
+
113
+ # Registrar primero el candidato MÁS CERCANO a una cámara ya registrada:
114
+ # baseline corto + mucho solape = PnP bien condicionado y mínimo arrastre
115
+ # de error. Procesarlos por índice de frame registraba saltos largos
116
+ # primero (geometría débil) y propagaba el error frame a frame.
117
+ candidates.sort(key=lambda f: min(abs(f - r) for r in registered))
118
+
119
+ progress_made = False
120
+
121
+ for k in candidates:
122
+ print(f" -> Evaluando frame {k:04d}...", end="\r")
123
+ fk = db.features[k]
124
+ obs_dict = {}
125
+
126
+ ref_frames = [r for r in registered if abs(k - r) <= window]
127
+ for ref in ref_frames:
128
+ f_ref = db.features[ref]
129
+ matches = frontend.match(fk, f_ref)
130
+ if not matches: continue
131
+
132
+ pts_k = fk.pts[[m[0] for m in matches]]
133
+ pts_ref = f_ref.pts[[m[1] for m in matches]]
134
+ E, mask = cv2.findEssentialMat(pts_k, pts_ref, cam.K, cv2.RANSAC, 0.999, 1.0)
135
+ if E is None: continue
136
+
137
+ valid_m = np.where(mask.ravel() > 0)[0]
138
+ for idx in valid_m:
139
+ k_idx, ref_idx = matches[idx]
140
+ if ref_idx in map3d['obs'].get(ref, {}):
141
+ obs_dict[k_idx] = map3d['obs'][ref][ref_idx]
142
+
143
+ if len(obs_dict) < 8:
144
+ continue
145
+
146
+ k_indices = list(obs_dict.keys())
147
+ p2d_candidate = np.array([fk.pts[idx] for idx in k_indices], dtype=np.float32)
148
+ p3d_candidate = np.array([map3d['points'][obs_dict[idx]] for idx in k_indices], dtype=np.float32)
149
+
150
+ # Semilla: pose de la cámara registrada más cercana. Como ahora
151
+ # registramos de cerca a lejos, es un guess muy bueno y estabiliza PnP.
152
+ r_near = min(registered, key=lambda r: abs(k - r))
153
+ rvec0, _ = cv2.Rodrigues(map3d['poses'][r_near][:3, :3])
154
+ tvec0 = map3d['poses'][r_near][:3, 3].reshape(3, 1).astype(np.float64)
155
+
156
+ succ, rvec, tvec, inliers = cv2.solvePnPRansac(
157
+ p3d_candidate, p2d_candidate, cam.K, None,
158
+ rvec=rvec0.copy(), tvec=tvec0.copy(), useExtrinsicGuess=True,
159
+ reprojectionError=3.0, iterationsCount=100, confidence=0.999,
160
+ )
161
+
162
+ if succ and inliers is not None and len(inliers) > 8:
163
+ # Refinar la pose con TODOS los inliers (LM): RANSAC solo ajusta
164
+ # sobre la muestra mínima, esto minimiza la reproyección global.
165
+ inl = inliers.ravel()
166
+ rvec, tvec = cv2.solvePnPRefineLM(
167
+ p3d_candidate[inl], p2d_candidate[inl], cam.K, None, rvec, tvec)
168
+ R, _ = cv2.Rodrigues(rvec)
169
+ map3d['poses'][k] = np.vstack((np.hstack((R, tvec)), [0,0,0,1]))
170
+ registered.add(k)
171
+
172
+ if k not in map3d['obs']: map3d['obs'][k] = {}
173
+ for i_inlier in inliers.ravel():
174
+ idx_2d = k_indices[i_inlier]
175
+ map3d['obs'][k][idx_2d] = obs_dict[idx_2d]
176
+
177
+ new_pts_total = 0
178
+ for ref in ref_frames:
179
+ new_pts_total += triangulate_new_points(k, ref, map3d, db, cam, frontend, min_angle=min_angle)
180
+
181
+ # BA LOCAL: re-optimiza ventana (k + vecinos) + sus puntos justo
182
+ # tras insertarlos, antes de que el error se propague al siguiente
183
+ # frame. Fija ancla + cámaras externas (ataca #2 arrastre y #4 lazo).
184
+ reg_count += 1
185
+ if local_ba and reg_count % ba_every == 0:
186
+ win = [r for r in registered if abs(k - r) <= window]
187
+ run_local_ba(map3d, pts_of, cam.K, win, max_nfev=20)
188
+
189
+ print(f"[+] Frame {k:04d} registrado | PnP Inliers: {len(inliers):03d} | Nuevos Puntos 3D: {new_pts_total}")
190
+
191
+ np.save(map_path, map3d)
192
+
193
+ # --- FASE DE EXPORTACIÓN CON CENTRADO ESPACIAL ---
194
+ # Calcular la mediana ignorando potenciales (aunque improbables) NaNs residuales
195
+ valid_global = np.isfinite(map3d['points']).all(axis=1)
196
+ if np.sum(valid_global) > 0:
197
+ median_pt = np.median(map3d['points'][valid_global], axis=0)
198
+ else:
199
+ median_pt = np.zeros(3)
200
+
201
+ centered_points = map3d['points'][valid_global] - median_pt
202
+ valid_colors = map3d['colors'][valid_global]
203
+
204
+ cam_centers = [-pose[:3, :3].T @ pose[:3, 3:] for pose in map3d['poses'].values()]
205
+ centered_cams = [c.ravel() - median_pt for c in cam_centers]
206
+
207
+ export_ply(os.path.join(out_dir, TRACKED_CLOUD_FILE), centered_points, valid_colors, centered_cams)
208
+
209
+ progress_made = True
210
+ break
211
+
212
+ if not progress_made:
213
+ print(f"\n[!] Bucle estancado. No se lograron registrar más cámaras de los {len(candidates)} candidatos.")
214
+ break
215
+
216
+ # FUSIÓN DE PUNTOS (#1 asociación de datos): une duplicados del mismo punto
217
+ # físico triangulado por separado en frames distintos (tracks disjuntos),
218
+ # acortando la nube y reforzando restricciones. Conservador (vóxel fino).
219
+ if fuse:
220
+ valid = np.isfinite(map3d['points']).all(axis=1)
221
+ if valid.sum() > 0:
222
+ scale = float(np.median(np.linalg.norm(
223
+ map3d['points'][valid] - np.median(map3d['points'][valid], axis=0), axis=1)))
224
+ voxel = scale / 800.0
225
+ n_fused = fuse_map_points(map3d, voxel)
226
+ print(f"[*] Fusión de puntos: {n_fused} duplicados unidos (vóxel={voxel:.4f}).")
227
+ np.save(map_path, map3d)
228
+ valid = np.isfinite(map3d['points']).all(axis=1)
229
+ median_pt = np.median(map3d['points'][valid], axis=0)
230
+ cam_centers = [-(p[:3, :3].T @ p[:3, 3:]).ravel() - median_pt for p in map3d['poses'].values()]
231
+ export_ply(os.path.join(out_dir, TRACKED_CLOUD_FILE),
232
+ map3d['points'][valid] - median_pt, map3d['colors'][valid], cam_centers)
233
+
234
+ print(f"\nTracking finalizado. Cámaras: {len(map3d['poses'])} | Puntos totales: {len(map3d['points'])}")
235
+
236
+ if __name__ == "__main__":
237
+ parser = argparse.ArgumentParser(description="SfM track: incremental camera registration")
238
+ parser.add_argument("--out", required=True, help="Output dir from core/init runs.")
239
+ parser.add_argument("--m-window", type=int, default=3,
240
+ help="Ventana PnP: registrar usando ±m frames extraídos como referencia "
241
+ "(default: 3). El espaciado real (k_skip) se autodetecta de los datos.")
242
+ parser.add_argument("--min-angle", type=float, default=1.5,
243
+ help="Ángulo mínimo de triangulación en grados (default: 1.5). Más alto "
244
+ "descarta puntos de baseline corta que generan ruido de escala.")
245
+ parser.add_argument("--no-local-ba", action="store_true",
246
+ help="Desactiva el Bundle Adjustment local por ventana deslizante.")
247
+ parser.add_argument("--ba-every", type=int, default=1,
248
+ help="Ejecutar BA local cada N cámaras registradas (default: 1 = siempre).")
249
+ parser.add_argument("--no-fuse", action="store_true",
250
+ help="Desactiva la fusión de puntos duplicados al final del tracking.")
251
+ args = parser.parse_args()
252
+ track_frames(args.out, m_window=args.m_window, min_angle=args.min_angle,
253
+ local_ba=not args.no_local_ba, ba_every=args.ba_every, fuse=not args.no_fuse)
@@ -0,0 +1,108 @@
1
+ import os
2
+ import sys
3
+ import argparse
4
+ import cv2
5
+ import numpy as np
6
+ from reconstruct3d.core import CameraConfig, MAP_STATE_FILE
7
+
8
+ def main(video_path, out_dir):
9
+ map3d = np.load(os.path.join(out_dir, MAP_STATE_FILE), allow_pickle=True).item()
10
+ cam = CameraConfig.from_dict(map3d.get('camera')) # intrínsecos del map_state
11
+
12
+ points3d = map3d['points']
13
+ colors3d = map3d['colors']
14
+
15
+ cap = cv2.VideoCapture(video_path)
16
+ registered_frames = sorted(map3d['poses'].keys())
17
+ num_frames = len(registered_frames)
18
+
19
+ window_name = "POV Proyeccion 3D"
20
+ cv2.namedWindow(window_name)
21
+
22
+ # Trackbars para Opacidad y Línea de tiempo
23
+ cv2.createTrackbar("Fondo %", window_name, 50, 100, lambda x: None)
24
+ cv2.createTrackbar("Frame", window_name, 0, max(0, num_frames - 1), lambda x: None)
25
+
26
+ # En Windows la ventana y sus trackbars no quedan registradas hasta que el
27
+ # event loop de HighGUI hace un ciclo; sin este "pump" inicial el primer
28
+ # getTrackbarPos lanza "NULL window".
29
+ cv2.imshow(window_name, np.zeros((10, 10, 3), dtype=np.uint8))
30
+ cv2.waitKey(1)
31
+
32
+ # Variables de estado para evitar re-renderizados innecesarios
33
+ last_idx = -1
34
+ last_alpha = -1
35
+
36
+ while True:
37
+ # 1. Leer estado actual de los sliders
38
+ idx = cv2.getTrackbarPos("Frame", window_name)
39
+ alpha_val = cv2.getTrackbarPos("Fondo %", window_name)
40
+
41
+ # 2. Solo procesar si el usuario movió algún control
42
+ if idx != last_idx or alpha_val != last_alpha:
43
+ f_idx = registered_frames[idx]
44
+ alpha = alpha_val / 100.0
45
+
46
+ cap.set(cv2.CAP_PROP_POS_FRAMES, f_idx)
47
+ ret, frame = cap.read()
48
+ if not ret:
49
+ break
50
+
51
+ frame = cam.preprocess(frame)
52
+ h, w = frame.shape[:2]
53
+
54
+ pose = map3d['poses'][f_idx]
55
+ R, t = pose[:3, :3], pose[:3, 3]
56
+
57
+ # Transformación isométrica al espacio de cámara local
58
+ pts_c = (R @ points3d.T).T + t
59
+
60
+ # Restricción de quiralidad (Z > 0)
61
+ mask_z = pts_c[:, 2] > 0
62
+ pts_c_valid = pts_c[mask_z]
63
+ colors_valid = colors3d[mask_z]
64
+
65
+ # Composición de fondo
66
+ black_bg = np.zeros_like(frame)
67
+ view = cv2.addWeighted(frame, alpha, black_bg, 1 - alpha, 0)
68
+
69
+ if len(pts_c_valid) > 0:
70
+ # Proyección en plano imagen
71
+ fx, fy = cam.K[0, 0], cam.K[1, 1]
72
+ cx, cy = cam.K[0, 2], cam.K[1, 2]
73
+
74
+ u = (fx * (pts_c_valid[:, 0] / pts_c_valid[:, 2]) + cx).astype(int)
75
+ v = (fy * (pts_c_valid[:, 1] / pts_c_valid[:, 2]) + cy).astype(int)
76
+
77
+ # Filtrado de límites del sensor
78
+ valid_img = (u >= 0) & (u < w) & (v >= 0) & (v < h)
79
+ u, v = u[valid_img], v[valid_img]
80
+ final_colors = colors_valid[valid_img]
81
+
82
+ # Renderizado de inliers con color original
83
+ for pt_u, pt_v, color in zip(u, v, final_colors):
84
+ c = (int(color[0]), int(color[1]), int(color[2]))
85
+ cv2.circle(view, (pt_u, pt_v), 2, c, -1)
86
+
87
+ # Actualizar título de la ventana con el frame real
88
+ cv2.setWindowTitle(window_name, f"POV Proyeccion 3D | Frame de Video: {f_idx}")
89
+ cv2.imshow(window_name, view)
90
+
91
+ # Guardar estado
92
+ last_idx = idx
93
+ last_alpha = alpha_val
94
+
95
+ # Mantener el bucle vivo y escuchar la tecla 'q' o 'ESC' para salir
96
+ key = cv2.waitKey(30) & 0xFF
97
+ if key == ord('q') or key == 27:
98
+ break
99
+
100
+ cap.release()
101
+ cv2.destroyAllWindows()
102
+
103
+ if __name__ == "__main__":
104
+ parser = argparse.ArgumentParser(description="POV viewer over reconstructed map")
105
+ parser.add_argument("video", help="Path to input video (mp4)")
106
+ parser.add_argument("--out", required=True, help="Output dir containing map_state.npy (from track_sfm.py).")
107
+ args = parser.parse_args()
108
+ main(args.video, args.out)