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/core.py ADDED
@@ -0,0 +1,515 @@
1
+ import cv2
2
+ import os
3
+ import sys
4
+ import argparse
5
+ import pickle
6
+ import threading
7
+ from abc import ABC, abstractmethod
8
+ from concurrent.futures import ThreadPoolExecutor
9
+ import numpy as np
10
+ import pandas as pd
11
+ from tqdm import tqdm
12
+
13
+ DB_FILE = "sfm_data.pkl"
14
+ MAP_STATE_FILE = "map_state.npy"
15
+ INIT_CLOUD_FILE = "init_cloud.ply"
16
+ TRACKED_CLOUD_FILE = "tracked_cloud.ply"
17
+ DEFAULT_FRONTEND = "sift"
18
+
19
+ # Intrínsecos por defecto: iPhone calibrado a 540x960 (yaml ORB-SLAM3) con
20
+ # checkerboard (error reproyección 0.40 px). El video crudo viene a 2160x3840
21
+ # (exactamente 4x), se redimensiona a PROC_SIZE antes de extraer y K se usa tal
22
+ # cual. Distorsión ~0 -> no se rectifica.
23
+ DEFAULT_K = np.array([[801.25, 0, 188.67], [0, 801.25, 390.22], [0, 0, 1]], dtype=np.float64)
24
+ DEFAULT_PROC_SIZE = (540, 960) # (W, H) — debe coincidir con la resolución de calibración
25
+
26
+
27
+ class CameraConfig:
28
+ """Intrínsecos de cámara. **Dependen del dispositivo de grabación**, por eso
29
+ se pueden cambiar pasando un JSON con `--camera` (ver `camera.example.json`).
30
+ Si no se especifica, se usan los valores por defecto (iPhone @ 540x960).
31
+
32
+ K corresponde a la resolución PROC_SIZE: el frame se redimensiona a PROC_SIZE
33
+ antes de extraer features, así que K debe estar calibrada para ESA resolución.
34
+
35
+ La config elegida en `extract` se persiste en `sfm_data.pkl` y `map_state.npy`,
36
+ de modo que todas las etapas posteriores usan la misma sin re-especificarla.
37
+ """
38
+ PROC_SIZE = DEFAULT_PROC_SIZE # default de clase (compat. retro)
39
+
40
+ def __init__(self, K=None, dist_coeffs=None, proc_size=None):
41
+ self.K = np.array(DEFAULT_K if K is None else K, dtype=np.float64)
42
+ self.dist_coeffs = (np.zeros(5, dtype=np.float64) if dist_coeffs is None
43
+ else np.asarray(dist_coeffs, dtype=np.float64).ravel())
44
+ self.PROC_SIZE = tuple(DEFAULT_PROC_SIZE if proc_size is None else proc_size)
45
+
46
+ def preprocess(self, frame):
47
+ """Lleva el frame a la resolución de calibración (y rectifica si hay
48
+ distorsión). Todos los keypoints viven en este espacio PROC_SIZE."""
49
+ w, h = self.PROC_SIZE
50
+ if (frame.shape[1], frame.shape[0]) != (w, h):
51
+ frame = cv2.resize(frame, (w, h), interpolation=cv2.INTER_AREA)
52
+ if np.any(self.dist_coeffs):
53
+ frame = cv2.undistort(frame, self.K, self.dist_coeffs)
54
+ return frame
55
+
56
+ def to_dict(self):
57
+ return {"K": self.K.tolist(), "dist_coeffs": self.dist_coeffs.tolist(),
58
+ "proc_size": list(self.PROC_SIZE)}
59
+
60
+ @classmethod
61
+ def from_dict(cls, d):
62
+ """Construye desde un dict. Acepta K explícita 3x3 o el atajo
63
+ fx/fy/cx/cy, y proc_size o width/height. Dict vacío/None -> defaults."""
64
+ if not d:
65
+ return cls()
66
+ K = d.get("K")
67
+ if K is None and all(k in d for k in ("fx", "fy", "cx", "cy")):
68
+ K = [[d["fx"], 0, d["cx"]], [0, d["fy"], d["cy"]], [0, 0, 1]]
69
+ proc = d.get("proc_size")
70
+ if proc is None and "width" in d and "height" in d:
71
+ proc = (d["width"], d["height"])
72
+ return cls(K=K, dist_coeffs=d.get("dist_coeffs"), proc_size=proc)
73
+
74
+ @classmethod
75
+ def from_json(cls, path):
76
+ """Carga intrínsecos desde un archivo JSON (ver camera.example.json)."""
77
+ import json
78
+ with open(path) as f:
79
+ return cls.from_dict(json.load(f))
80
+
81
+ class Features:
82
+ def __init__(self, pts, descriptors, colors, scores=None, image_size=None):
83
+ self.pts = pts # (N, 2) float32 — pixel coords in processed (resized) frame
84
+ self.descriptors = descriptors # frontend-specific: SIFT (N,128) f32, ORB (N,32) u8, SP (N,256) f32
85
+ self.colors = colors # (N, 3) BGR uint8
86
+ self.scores = scores # (N,) float32 or None
87
+ self.image_size = image_size # (W, H) float32 or None — needed by LightGlue
88
+
89
+ def __setstate__(self, state):
90
+ # Backward-compat: old pickles predate scores/image_size
91
+ state.setdefault('scores', None)
92
+ state.setdefault('image_size', None)
93
+ self.__dict__.update(state)
94
+
95
+
96
+ class FrontEnd(ABC):
97
+ """Pluggable feature extractor + matcher. Each preset is atomic:
98
+ extractor and matcher are bound together because they must agree on
99
+ descriptor type (binary vs float) and matching strategy (BF vs GNN)."""
100
+ name: str = ""
101
+
102
+ @abstractmethod
103
+ def extract(self, frame: np.ndarray) -> Features:
104
+ ...
105
+
106
+ @abstractmethod
107
+ def match(self, feat_i: Features, feat_j: Features) -> list:
108
+ """Return list of (idx_i, idx_j) tuples."""
109
+ ...
110
+
111
+
112
+ def filter_triangulation(pts3d, pose_a, pose_b, pts2d_a, pts2d_b, K,
113
+ max_reproj_err=4.0, min_angle_deg=1.0):
114
+ """Boolean mask of points that pass: finite, chirality in both cameras,
115
+ reprojection error ≤ max_reproj_err in both views, and triangulation
116
+ angle ≥ min_angle_deg. Poses are world→cam (4x4)."""
117
+ n = len(pts3d)
118
+ if n == 0:
119
+ return np.zeros(0, dtype=bool)
120
+
121
+ Ra, ta = pose_a[:3, :3], pose_a[:3, 3:]
122
+ Rb, tb = pose_b[:3, :3], pose_b[:3, 3:]
123
+ pts_a = (Ra @ pts3d.T + ta).T
124
+ pts_b = (Rb @ pts3d.T + tb).T
125
+
126
+ valid = np.isfinite(pts3d).all(axis=1)
127
+ valid &= pts_a[:, 2] > 0
128
+ valid &= pts_b[:, 2] > 0
129
+
130
+ with np.errstate(invalid='ignore', divide='ignore'):
131
+ proj_a = (K @ pts_a.T).T
132
+ proj_a = proj_a[:, :2] / proj_a[:, 2:3]
133
+ proj_b = (K @ pts_b.T).T
134
+ proj_b = proj_b[:, :2] / proj_b[:, 2:3]
135
+ err_a = np.linalg.norm(proj_a - pts2d_a, axis=1)
136
+ err_b = np.linalg.norm(proj_b - pts2d_b, axis=1)
137
+ valid &= np.nan_to_num(err_a, nan=np.inf) <= max_reproj_err
138
+ valid &= np.nan_to_num(err_b, nan=np.inf) <= max_reproj_err
139
+
140
+ ca = (-Ra.T @ ta).ravel()
141
+ cb = (-Rb.T @ tb).ravel()
142
+ ray_a = pts3d - ca
143
+ ray_b = pts3d - cb
144
+ na = np.linalg.norm(ray_a, axis=1, keepdims=True)
145
+ nb = np.linalg.norm(ray_b, axis=1, keepdims=True)
146
+ with np.errstate(invalid='ignore', divide='ignore'):
147
+ ray_a = ray_a / na
148
+ ray_b = ray_b / nb
149
+ cos_ang = np.clip(np.einsum('ij,ij->i', ray_a, ray_b), -1, 1)
150
+ angles_deg = np.degrees(np.arccos(cos_ang))
151
+ valid &= np.nan_to_num(angles_deg, nan=0.0) >= min_angle_deg
152
+
153
+ return valid
154
+
155
+
156
+ def _sample_colors(frame, pts):
157
+ if len(pts) == 0:
158
+ return np.empty((0, 3), dtype=np.uint8)
159
+ h, w = frame.shape[:2]
160
+ ix = np.clip(pts[:, 0].astype(int), 0, w - 1)
161
+ iy = np.clip(pts[:, 1].astype(int), 0, h - 1)
162
+ return frame[iy, ix]
163
+
164
+
165
+ class SiftFrontEnd(FrontEnd):
166
+ name = "sift"
167
+
168
+ def __init__(self, ratio=0.75):
169
+ self.sift = cv2.SIFT_create()
170
+ self.bf = cv2.BFMatcher(cv2.NORM_L2)
171
+ self.ratio = ratio
172
+
173
+ def extract(self, frame):
174
+ gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
175
+ kp, des = self.sift.detectAndCompute(gray, None)
176
+ if kp is None or len(kp) == 0:
177
+ return Features(np.empty((0, 2), dtype=np.float32), None, np.empty((0, 3), dtype=np.uint8))
178
+ pts = np.float32([k.pt for k in kp])
179
+ return Features(pts, des, _sample_colors(frame, pts))
180
+
181
+ def match(self, feat_i, feat_j):
182
+ d1, d2 = feat_i.descriptors, feat_j.descriptors
183
+ if d1 is None or d2 is None or len(d1) < 2 or len(d2) < 2:
184
+ return []
185
+ knn = self.bf.knnMatch(d1, d2, k=2)
186
+ out = []
187
+ for pair in knn:
188
+ if len(pair) < 2:
189
+ continue
190
+ m, n = pair
191
+ if m.distance < self.ratio * n.distance:
192
+ out.append((m.queryIdx, m.trainIdx))
193
+ return out
194
+
195
+
196
+ class OrbFrontEnd(FrontEnd):
197
+ name = "orb"
198
+
199
+ def __init__(self, nfeatures=5000, ratio=0.8):
200
+ self.orb = cv2.ORB_create(
201
+ nfeatures=nfeatures, scaleFactor=1.2, nlevels=8,
202
+ edgeThreshold=15, fastThreshold=10,
203
+ )
204
+ self.bf = cv2.BFMatcher(cv2.NORM_HAMMING)
205
+ self.ratio = ratio
206
+
207
+ def extract(self, frame):
208
+ gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
209
+ kp, des = self.orb.detectAndCompute(gray, None)
210
+ if kp is None or len(kp) == 0:
211
+ return Features(np.empty((0, 2), dtype=np.float32), None, np.empty((0, 3), dtype=np.uint8))
212
+ pts = np.float32([k.pt for k in kp])
213
+ return Features(pts, des, _sample_colors(frame, pts))
214
+
215
+ def match(self, feat_i, feat_j):
216
+ d1, d2 = feat_i.descriptors, feat_j.descriptors
217
+ if d1 is None or d2 is None or len(d1) < 2 or len(d2) < 2:
218
+ return []
219
+ knn = self.bf.knnMatch(d1, d2, k=2)
220
+ out = []
221
+ for pair in knn:
222
+ if len(pair) < 2:
223
+ continue
224
+ m, n = pair
225
+ if m.distance < self.ratio * n.distance:
226
+ out.append((m.queryIdx, m.trainIdx))
227
+ return out
228
+
229
+
230
+ class SuperPointLightGlueFrontEnd(FrontEnd):
231
+ name = "spglue"
232
+
233
+ def __init__(self, max_keypoints=2048, device="cpu"):
234
+ # Lazy import — only spglue users need torch installed
235
+ import torch
236
+ from lightglue import SuperPoint, LightGlue
237
+ self._torch = torch
238
+ self.device = torch.device(device)
239
+ self.extractor = SuperPoint(max_num_keypoints=max_keypoints).eval().to(self.device)
240
+ self.matcher = LightGlue(features="superpoint").eval().to(self.device)
241
+
242
+ def _to_tensor(self, frame):
243
+ rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
244
+ t = self._torch.from_numpy(rgb).permute(2, 0, 1).float() / 255.0
245
+ return t.to(self.device)
246
+
247
+ def extract(self, frame):
248
+ h, w = frame.shape[:2]
249
+ img_size = np.array([w, h], dtype=np.float32)
250
+
251
+ with self._torch.inference_mode():
252
+ feats = self.extractor.extract(self._to_tensor(frame))
253
+
254
+ pts = feats["keypoints"][0].cpu().numpy().astype(np.float32)
255
+ if len(pts) == 0:
256
+ return Features(
257
+ np.empty((0, 2), dtype=np.float32), None, np.empty((0, 3), dtype=np.uint8),
258
+ scores=None, image_size=img_size,
259
+ )
260
+ scores = feats["keypoint_scores"][0].cpu().numpy().astype(np.float32)
261
+ desc = feats["descriptors"][0].cpu().numpy().astype(np.float32)
262
+ return Features(pts, desc, _sample_colors(frame, pts), scores=scores, image_size=img_size)
263
+
264
+ def match(self, feat_i, feat_j):
265
+ if feat_i.descriptors is None or feat_j.descriptors is None:
266
+ return []
267
+ if len(feat_i.pts) < 2 or len(feat_j.pts) < 2:
268
+ return []
269
+
270
+ torch = self._torch
271
+
272
+ def as_input(f):
273
+ return {
274
+ "keypoints": torch.from_numpy(f.pts).float().unsqueeze(0).to(self.device),
275
+ "keypoint_scores": torch.from_numpy(f.scores).float().unsqueeze(0).to(self.device),
276
+ "descriptors": torch.from_numpy(f.descriptors).float().unsqueeze(0).to(self.device),
277
+ "image_size": torch.from_numpy(f.image_size).float().unsqueeze(0).to(self.device),
278
+ }
279
+
280
+ with torch.inference_mode():
281
+ out = self.matcher({"image0": as_input(feat_i), "image1": as_input(feat_j)})
282
+
283
+ m = out["matches"][0].cpu().numpy() # (K, 2)
284
+ return [(int(a), int(b)) for a, b in m]
285
+
286
+
287
+ _FRONTEND_REGISTRY = {
288
+ SiftFrontEnd.name: SiftFrontEnd,
289
+ OrbFrontEnd.name: OrbFrontEnd,
290
+ SuperPointLightGlueFrontEnd.name: SuperPointLightGlueFrontEnd,
291
+ }
292
+
293
+
294
+ def create_frontend(name: str) -> FrontEnd:
295
+ if name not in _FRONTEND_REGISTRY:
296
+ raise ValueError(f"Frontend '{name}' desconocido. Opciones: {sorted(_FRONTEND_REGISTRY)}")
297
+ return _FRONTEND_REGISTRY[name]()
298
+
299
+
300
+ # Los detectores/matchers de OpenCV (SIFT, ORB, BFMatcher) NO son thread-safe si
301
+ # se comparte la misma instancia entre hilos. Para paralelizar damos a cada hilo
302
+ # su propio frontend vía almacenamiento thread-local (creado perezosamente).
303
+ _thread_frontends = threading.local()
304
+
305
+
306
+ def tls_frontend(frontend_name: str) -> FrontEnd:
307
+ fe = getattr(_thread_frontends, "fe", None)
308
+ if fe is None or getattr(_thread_frontends, "name", None) != frontend_name:
309
+ fe = create_frontend(frontend_name)
310
+ _thread_frontends.fe = fe
311
+ _thread_frontends.name = frontend_name
312
+ return fe
313
+
314
+
315
+ def resolve_workers(jobs: int, frontend_name: str = "") -> int:
316
+ """Número de hilos a usar. jobs<=0 -> nº de CPUs; jobs==1 -> secuencial.
317
+ 'spglue' (torch) se fuerza a 1: el modelo no es thread-safe y torch ya
318
+ paraleliza internamente sobre los núcleos."""
319
+ n = jobs if jobs and jobs > 0 else (os.cpu_count() or 1)
320
+ if frontend_name == "spglue":
321
+ return 1
322
+ return max(1, n)
323
+
324
+
325
+ class SfMDatabase:
326
+ def __init__(self, frontend_name=DEFAULT_FRONTEND, camera=None):
327
+ self.features = {} # idx -> Features
328
+ self.pairwise = [] # list of dicts
329
+ self.frontend_name = frontend_name
330
+ self.camera = camera # dict de CameraConfig.to_dict() o None (defaults)
331
+
332
+ def get_camera(self):
333
+ """CameraConfig persistida en la DB (o la default si no hay)."""
334
+ return CameraConfig.from_dict(self.camera)
335
+
336
+ def save(self, filepath):
337
+ with open(filepath, 'wb') as f:
338
+ pickle.dump({
339
+ 'features': self.features,
340
+ 'pairwise': self.pairwise,
341
+ 'frontend_name': self.frontend_name,
342
+ 'camera': self.camera,
343
+ }, f)
344
+
345
+ def load(self, filepath):
346
+ with open(filepath, 'rb') as f:
347
+ data = pickle.load(f)
348
+ self.features = data['features']
349
+ self.pairwise = data['pairwise']
350
+ self.frontend_name = data.get('frontend_name', DEFAULT_FRONTEND)
351
+ self.camera = data.get('camera') # backward-compat: pickles viejos
352
+
353
+
354
+ def process_core(video_path, out_dir, frontend_name=DEFAULT_FRONTEND, k_skip=5, m_window=3,
355
+ max_seconds=None, start_seconds=0.0, start_frame=None, end_frame=None,
356
+ camera=None, reuse_db=True, jobs=0):
357
+ print(f"[*] k_skip={k_skip} m_window={m_window}")
358
+ cam = camera or CameraConfig()
359
+ db = SfMDatabase(frontend_name=frontend_name, camera=cam.to_dict())
360
+ db_path = os.path.join(out_dir, DB_FILE)
361
+ if os.path.exists(db_path) and reuse_db:
362
+ # Reutilizar la DB existente (no recalcular). Pasa reuse_db=False para
363
+ # forzar la re-extracción. Antes esto era un input() interactivo, lo que
364
+ # impedía automatizar el pipeline.
365
+ db.load(db_path)
366
+ print(f" [DB cargada] {db_path} frontend='{db.frontend_name}' frames={len(db.features)}")
367
+ print(" (usa --force para re-extraer desde cero)")
368
+ return db
369
+
370
+ workers = resolve_workers(jobs, frontend_name)
371
+ print(f"[*] Cámara: K=[fx={cam.K[0,0]:.2f} fy={cam.K[1,1]:.2f} "
372
+ f"cx={cam.K[0,2]:.2f} cy={cam.K[1,2]:.2f}] proc_size={cam.PROC_SIZE}")
373
+ print(f"Inicializando frontend '{frontend_name}'...")
374
+ frontend = create_frontend(frontend_name)
375
+
376
+ cap = cv2.VideoCapture(video_path)
377
+ fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
378
+ frames_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
379
+ if max_seconds is not None:
380
+ limit = int(fps * max_seconds)
381
+ if limit < frames_count:
382
+ print(f"[*] Recortando a {max_seconds}s ({limit}/{frames_count} frames @ {fps:.2f} fps)")
383
+ frames_count = limit
384
+ # Rango explícito de frames (usado por el procesamiento por chunks). Tiene
385
+ # prioridad sobre los segundos.
386
+ if end_frame is not None:
387
+ frames_count = min(frames_count, int(end_frame))
388
+ sf = int(start_frame) if start_frame is not None else int(fps * start_seconds)
389
+ if sf > 0:
390
+ print(f"[*] Inicio en frame {sf}")
391
+
392
+ frame_idxs = list(range(sf, frames_count, k_skip))
393
+ print(f"Extrayendo features [{frontend_name}] (Salto: {k_skip}, hilos: {workers})...")
394
+ if workers == 1:
395
+ for i in tqdm(frame_idxs):
396
+ cap.set(cv2.CAP_PROP_POS_FRAMES, i)
397
+ ret, frame = cap.read()
398
+ if not ret: break
399
+ db.features[i] = frontend.extract(cam.preprocess(frame))
400
+ else:
401
+ # El video se decodifica en serie (VideoCapture no es thread-safe), pero
402
+ # la extracción de features (lo costoso) se reparte en hilos. Buffer
403
+ # acotado para no cargar todo el video a memoria; los frames ya van
404
+ # reducidos a PROC_SIZE.
405
+ batch = max(8, workers * 4)
406
+ with ThreadPoolExecutor(max_workers=workers) as ex:
407
+ pbar = tqdm(total=len(frame_idxs))
408
+ buf_idx, buf_fr = [], []
409
+
410
+ def _extract(frame):
411
+ return tls_frontend(frontend_name).extract(frame)
412
+
413
+ def flush():
414
+ for idx, feat in zip(buf_idx, ex.map(_extract, buf_fr)):
415
+ db.features[idx] = feat
416
+ pbar.update(len(buf_idx))
417
+ buf_idx.clear(); buf_fr.clear()
418
+
419
+ for i in frame_idxs:
420
+ cap.set(cv2.CAP_PROP_POS_FRAMES, i)
421
+ ret, frame = cap.read()
422
+ if not ret: break
423
+ buf_idx.append(i); buf_fr.append(cam.preprocess(frame))
424
+ if len(buf_idx) >= batch:
425
+ flush()
426
+ if buf_idx:
427
+ flush()
428
+ pbar.close()
429
+ cap.release()
430
+
431
+ indices = sorted(db.features.keys())
432
+ # Tareas de pares (ambas direcciones, como antes): cada frame contra sus
433
+ # ±m_window vecinos extraídos. Son independientes -> se paralelizan.
434
+ tasks = []
435
+ for idx_pos in range(len(indices)):
436
+ start_pos, end_pos = max(0, idx_pos - m_window), min(len(indices), idx_pos + m_window + 1)
437
+ for j_pos in range(start_pos, end_pos):
438
+ if j_pos == idx_pos:
439
+ continue
440
+ tasks.append((indices[idx_pos], indices[j_pos]))
441
+
442
+ def _match_pair(pair):
443
+ i_frame, j_frame = pair
444
+ fe = frontend if workers == 1 else tls_frontend(frontend_name)
445
+ fi, fj = db.features[i_frame], db.features[j_frame]
446
+ matches = fe.match(fi, fj)
447
+ if len(matches) < 5:
448
+ return None
449
+ pts1 = fi.pts[[m[0] for m in matches]]
450
+ pts2 = fj.pts[[m[1] for m in matches]]
451
+ E, mask = cv2.findEssentialMat(pts1, pts2, cam.K, cv2.RANSAC, 0.999, 1.0)
452
+ if E is None:
453
+ return None
454
+ return {"frame_i": i_frame, "frame_j": j_frame,
455
+ "inliers": int(mask.sum()), "matches": len(matches),
456
+ "E": E, "mask": mask.ravel()}
457
+
458
+ print(f"Calculando M. Esencial (Ventana: +/-{m_window}, hilos: {workers})...")
459
+ if workers == 1:
460
+ results = (_match_pair(t) for t in tqdm(tasks))
461
+ db.pairwise = [r for r in results if r is not None]
462
+ else:
463
+ with ThreadPoolExecutor(max_workers=workers) as ex:
464
+ results = list(tqdm(ex.map(_match_pair, tasks), total=len(tasks)))
465
+ db.pairwise = [r for r in results if r is not None]
466
+
467
+ db.save(db_path)
468
+ return db
469
+
470
+ if __name__ == "__main__":
471
+ parser = argparse.ArgumentParser(description="SfM core: feature extraction + pairwise essential matrices")
472
+ parser.add_argument("video", help="Path to input video (mp4)")
473
+ parser.add_argument(
474
+ "--frontend", choices=sorted(_FRONTEND_REGISTRY), default=DEFAULT_FRONTEND,
475
+ help=f"Detector + matcher preset (default: {DEFAULT_FRONTEND}). "
476
+ "'sift'=SIFT+BF/L2, 'orb'=ORB+BF/Hamming, 'spglue'=SuperPoint+LightGlue (CPU/GPU)."
477
+ )
478
+ parser.add_argument(
479
+ "--out", default=None,
480
+ help="Output directory for all artifacts (default: outputs/{frontend}/)."
481
+ )
482
+ parser.add_argument(
483
+ "--max-seconds", type=float, default=None,
484
+ help="Procesar hasta el segundo N del video (límite final, default: video completo)."
485
+ )
486
+ parser.add_argument(
487
+ "--start-seconds", type=float, default=0.0,
488
+ help="Empezar a procesar desde el segundo N (default: 0). Útil para saltar "
489
+ "un arranque degenerado de casi-rotación que envenena la escala."
490
+ )
491
+ parser.add_argument("--k-skip", type=int, default=5, help="Process 1 out of every k frames (default: 5).")
492
+ parser.add_argument("--m-window", type=int, default=3,
493
+ help="Pairwise window: compare each frame with ±m extracted neighbors "
494
+ "(default: 3). Wider = mejor baseline para escenas lentas, pero más costoso.")
495
+ parser.add_argument("--force", action="store_true",
496
+ help="Re-extraer aunque ya exista sfm_data.pkl (por defecto se reutiliza).")
497
+ parser.add_argument("--camera", default=None,
498
+ help="JSON con intrínsecos de la cámara del dispositivo "
499
+ "(ver camera.example.json). Default: iPhone @ 540x960.")
500
+ parser.add_argument("--jobs", type=int, default=0,
501
+ help="Hilos para extracción/matching (0=auto=nº de CPUs; 1=secuencial).")
502
+ args = parser.parse_args()
503
+
504
+ out_dir = args.out or os.path.join("outputs", args.frontend)
505
+ os.makedirs(out_dir, exist_ok=True)
506
+ print(f"[*] Output dir: {out_dir}")
507
+
508
+ camera = CameraConfig.from_json(args.camera) if args.camera else None
509
+ db = process_core(args.video, out_dir, frontend_name=args.frontend,
510
+ k_skip=args.k_skip, m_window=args.m_window, max_seconds=args.max_seconds,
511
+ start_seconds=args.start_seconds, camera=camera, reuse_db=not args.force,
512
+ jobs=args.jobs)
513
+ df = pd.DataFrame(db.pairwise).sort_values("inliers", ascending=False)
514
+ print(f"\nFrontend usado: {db.frontend_name}")
515
+ print("Top candidatos:\n", df[['frame_i', 'frame_j', 'inliers', 'matches']].head())