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
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())
|