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,335 @@
1
+ """Reconstrucción por fragmentos (chunks) con solape + fusión de nubes.
2
+
3
+ Para videos largos, reconstruir todo de una vez es lento y acumula deriva. Esta
4
+ estrategia parte el video en fragmentos que se SOLAPAN, reconstruye cada uno de
5
+ forma INDEPENDIENTE (y en PARALELO, ya que no comparten estado), y luego alinea
6
+ las sub-nubes usando los frames compartidos en el solape.
7
+
8
+ Idea de la alineación (Sim(3) por Umeyama):
9
+ Dos chunks contiguos extraen exactamente los mismos frames absolutos en su zona
10
+ de solape (todos muestrean la MISMA rejilla global de k_skip). Cada chunk estima
11
+ la pose de esos frames en su propio sistema de coordenadas y a su propia escala
12
+ arbitraria. Los CENTROS DE CÁMARA de los frames compartidos son entonces
13
+ correspondencias 3D-3D directas entre el sistema del chunk y el sistema global
14
+ -> resolvemos una similaridad (escala s, rotación R, traslación t) con el método
15
+ de Umeyama y la aplicamos a los puntos y cámaras del chunk para llevarlos al
16
+ marco global. Encadenando chunk a chunk, todo queda en un único marco.
17
+
18
+ Salida: `merged_cloud.ply` (nube global) y `merged_state.npy` (poses globales +
19
+ puntos + cámara), compatibles con `dense` y `view`.
20
+
21
+ Uso:
22
+ uv run python chunked.py data/video.mp4 --chunk 80 --overlap 20 --chunk-jobs 4
23
+ uv run python pipeline.py chunked data/video.mp4 --chunk 80 --overlap 20
24
+ """
25
+ import os
26
+ import argparse
27
+ from concurrent.futures import ProcessPoolExecutor
28
+
29
+ import cv2
30
+ import numpy as np
31
+
32
+ from reconstruct3d.core import MAP_STATE_FILE, CameraConfig, DEFAULT_FRONTEND, resolve_workers
33
+ from reconstruct3d.init_sfm import export_ply
34
+
35
+ MERGED_CLOUD_FILE = "merged_cloud.ply"
36
+ MERGED_STATE_FILE = "merged_state.npy"
37
+
38
+
39
+ # --------------------------------------------------------------------------- #
40
+ # Alineación de similaridad (Umeyama) #
41
+ # --------------------------------------------------------------------------- #
42
+ def umeyama(src, dst, with_scale=True):
43
+ """Similaridad que mapea `src` -> `dst`: dst ≈ s·R·src + t.
44
+
45
+ src, dst: (N,3) correspondencias. Devuelve (s, R(3x3), t(3,)).
46
+ Método de Umeyama (1991), robusto a reflexiones vía corrección del
47
+ determinante.
48
+ """
49
+ src = np.asarray(src, dtype=np.float64)
50
+ dst = np.asarray(dst, dtype=np.float64)
51
+ n = src.shape[0]
52
+ mu_s, mu_d = src.mean(0), dst.mean(0)
53
+ sc, dc = src - mu_s, dst - mu_d
54
+ cov = (dc.T @ sc) / n
55
+ U, D, Vt = np.linalg.svd(cov)
56
+ S = np.eye(3)
57
+ if np.linalg.det(U) * np.linalg.det(Vt) < 0:
58
+ S[2, 2] = -1.0
59
+ R = U @ S @ Vt
60
+ if with_scale:
61
+ var_s = (sc ** 2).sum() / n
62
+ s = float((D * np.diag(S)).sum() / var_s) if var_s > 1e-12 else 1.0
63
+ else:
64
+ s = 1.0
65
+ t = mu_d - s * R @ mu_s
66
+ return s, R, t
67
+
68
+
69
+ def apply_sim3(pts, s, R, t):
70
+ """Aplica dst = s·R·pts + t a (N,3)."""
71
+ return (s * (R @ np.asarray(pts, dtype=np.float64).T).T) + t
72
+
73
+
74
+ def _camera_centers(poses):
75
+ """{frame: pose 4x4 world->cam} -> {frame: centro de cámara (3,) en mundo}."""
76
+ return {f: (-(P[:3, :3].T @ P[:3, 3])).ravel() for f, P in poses.items()}
77
+
78
+
79
+ # --------------------------------------------------------------------------- #
80
+ # Particionado en chunks #
81
+ # --------------------------------------------------------------------------- #
82
+ def plan_chunks(n_frames, k_skip, chunk, overlap):
83
+ """Parte la rejilla global de frames muestreados en chunks solapados.
84
+
85
+ Todos los chunks muestrean la MISMA rejilla (múltiplos de k_skip desde 0),
86
+ así que los frames del solape tienen índices absolutos idénticos entre chunks
87
+ contiguos (requisito para alinear por centros de cámara compartidos).
88
+
89
+ Devuelve lista de (start_frame, end_frame) en frames absolutos.
90
+ """
91
+ if overlap >= chunk:
92
+ raise ValueError(f"overlap ({overlap}) debe ser menor que chunk ({chunk}).")
93
+ grid = list(range(0, n_frames, k_skip))
94
+ step = chunk - overlap
95
+ spans = []
96
+ a = 0
97
+ while a < len(grid):
98
+ b = min(a + chunk, len(grid))
99
+ start_frame = grid[a]
100
+ end_frame = grid[b - 1] + 1 # exclusivo; +1 para incluir el último
101
+ spans.append((start_frame, end_frame))
102
+ if b >= len(grid):
103
+ break
104
+ a += step
105
+ return spans
106
+
107
+
108
+ # --------------------------------------------------------------------------- #
109
+ # Worker de chunk (corre en su propio proceso) #
110
+ # --------------------------------------------------------------------------- #
111
+ def _run_chunk_worker(cfg):
112
+ """Reconstruye un chunk completo (extract->init->track->[ba]) en su out_dir.
113
+ Debe ser top-level y con `cfg` picklable para ProcessPoolExecutor (spawn)."""
114
+ from reconstruct3d.core import process_core, CameraConfig
115
+ from reconstruct3d.init_sfm import init_reconstruction
116
+ from reconstruct3d.track_sfm import track_frames
117
+
118
+ out = cfg["out"]
119
+ os.makedirs(out, exist_ok=True)
120
+ camera = CameraConfig.from_dict(cfg["camera"]) if cfg["camera"] else None
121
+ try:
122
+ process_core(
123
+ cfg["video"], out, frontend_name=cfg["frontend"], k_skip=cfg["k_skip"],
124
+ m_window=cfg["m_window"], start_frame=cfg["start_frame"], end_frame=cfg["end_frame"],
125
+ camera=camera, reuse_db=not cfg["force"], jobs=cfg["inner_jobs"],
126
+ )
127
+ init_reconstruction(out)
128
+ track_frames(out, m_window=cfg["m_window"], min_angle=cfg["min_angle"],
129
+ local_ba=cfg["local_ba"], ba_every=cfg["ba_every"], fuse=cfg["fuse"])
130
+ if cfg["ba"]:
131
+ from reconstruct3d.bundle_adjust import run_bundle_adjustment
132
+ run_bundle_adjustment(out, max_nfev=cfg["ba_max_nfev"])
133
+ return {"chunk": cfg["chunk"], "out": out, "ok": True}
134
+ except Exception as e: # noqa: BLE001 - reportamos y seguimos con otros chunks
135
+ return {"chunk": cfg["chunk"], "out": out, "ok": False, "error": repr(e)}
136
+
137
+
138
+ # --------------------------------------------------------------------------- #
139
+ # Orquestación #
140
+ # --------------------------------------------------------------------------- #
141
+ def run_chunked(video_path, out_dir, frontend_name=DEFAULT_FRONTEND, k_skip=5, m_window=3,
142
+ chunk=80, overlap=20, min_angle=1.5, local_ba=True, ba_every=1, fuse=True,
143
+ ba=False, ba_max_nfev=200, chunk_jobs=0, inner_jobs=1, camera=None,
144
+ min_overlap=4, voxel=0.0):
145
+ os.makedirs(out_dir, exist_ok=True)
146
+ cam = camera or CameraConfig()
147
+
148
+ cap = cv2.VideoCapture(video_path)
149
+ n_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
150
+ cap.release()
151
+ if n_frames <= 0:
152
+ raise RuntimeError(f"No se pudo leer el conteo de frames de {video_path}")
153
+
154
+ spans = plan_chunks(n_frames, k_skip, chunk, overlap)
155
+ chunk_jobs = resolve_workers(chunk_jobs, frontend_name)
156
+ chunk_jobs = min(chunk_jobs, len(spans))
157
+ print(f"[chunked] {len(spans)} chunks (chunk={chunk}, overlap={overlap} frames muestreados) "
158
+ f"| procesos paralelos={chunk_jobs} | hilos internos/chunk={inner_jobs}")
159
+ for c, (s, e) in enumerate(spans):
160
+ print(f" chunk {c:02d}: frames absolutos [{s}, {e})")
161
+
162
+ cfgs = []
163
+ for c, (s, e) in enumerate(spans):
164
+ cfgs.append(dict(
165
+ chunk=c, out=os.path.join(out_dir, f"chunk_{c:02d}"), video=video_path,
166
+ frontend=frontend_name, k_skip=k_skip, m_window=m_window,
167
+ start_frame=s, end_frame=e, camera=cam.to_dict(), force=True,
168
+ inner_jobs=inner_jobs, min_angle=min_angle, local_ba=local_ba,
169
+ ba_every=ba_every, fuse=fuse, ba=ba, ba_max_nfev=ba_max_nfev,
170
+ ))
171
+
172
+ # --- Fase 1: reconstruir cada chunk (en paralelo) ---
173
+ if chunk_jobs == 1:
174
+ results = [_run_chunk_worker(cfg) for cfg in cfgs]
175
+ else:
176
+ with ProcessPoolExecutor(max_workers=chunk_jobs) as ex:
177
+ results = list(ex.map(_run_chunk_worker, cfgs))
178
+
179
+ for r in sorted(results, key=lambda r: r["chunk"]):
180
+ status = "OK" if r["ok"] else f"FALLÓ ({r.get('error', '')})"
181
+ print(f"[chunked] chunk {r['chunk']:02d}: {status}")
182
+
183
+ # --- Fase 2: alinear y fusionar ---
184
+ return merge_chunks(out_dir, [r["out"] for r in sorted(results, key=lambda r: r["chunk"]) if r["ok"]],
185
+ cam, min_overlap=min_overlap, voxel=voxel)
186
+
187
+
188
+ def merge_chunks(out_dir, chunk_dirs, cam, min_overlap=4, voxel=0.0):
189
+ """Alinea las sub-nubes por solape (Umeyama) y escribe la nube fusionada."""
190
+ chunks = []
191
+ for d in chunk_dirs:
192
+ p = os.path.join(d, MAP_STATE_FILE)
193
+ if not os.path.exists(p):
194
+ print(f"[merge] {d}: sin {MAP_STATE_FILE}, se omite.")
195
+ continue
196
+ m = np.load(p, allow_pickle=True).item()
197
+ if len(m.get("poses", {})) < 2:
198
+ print(f"[merge] {d}: <2 poses, se omite.")
199
+ continue
200
+ chunks.append((d, m))
201
+
202
+ if not chunks:
203
+ print("[merge] No hay chunks reconstruidos para fusionar.")
204
+ return None
205
+
206
+ # El primer chunk define el marco global.
207
+ global_centers = {} # frame_abs -> centro en marco global
208
+ merged_pts, merged_cols = [], []
209
+ merged_poses = {} # frame_abs -> pose global 4x4 world->cam
210
+ n_used = 0
211
+
212
+ for i, (d, m) in enumerate(chunks):
213
+ poses = m["poses"]
214
+ centers = _camera_centers(poses)
215
+ pts = np.asarray(m["points"], dtype=np.float64)
216
+ cols = np.asarray(m["colors"])
217
+ finite = np.isfinite(pts).all(axis=1)
218
+ pts, cols = pts[finite], cols[finite]
219
+
220
+ if i == 0:
221
+ s, R, t = 1.0, np.eye(3), np.zeros(3)
222
+ else:
223
+ shared = [f for f in centers if f in global_centers]
224
+ if len(shared) < min_overlap:
225
+ print(f"[merge] chunk dir '{os.path.basename(d)}': solape insuficiente "
226
+ f"({len(shared)} < {min_overlap} frames), se omite del merge.")
227
+ continue
228
+ src = np.array([centers[f] for f in shared]) # marco local del chunk
229
+ dst = np.array([global_centers[f] for f in shared]) # marco global
230
+ s, R, t = umeyama(src, dst, with_scale=True)
231
+ # Diagnóstico: residual RMS de la alineación
232
+ res = np.linalg.norm(apply_sim3(src, s, R, t) - dst, axis=1)
233
+ print(f"[merge] chunk dir '{os.path.basename(d)}': {len(shared)} frames de solape, "
234
+ f"escala={s:.3f}, RMS alineación={res.mean():.4f}")
235
+
236
+ merged_pts.append(apply_sim3(pts, s, R, t))
237
+ merged_cols.append(cols)
238
+ # Registrar/actualizar centros y poses globales de este chunk
239
+ for f, c in centers.items():
240
+ gc = apply_sim3(c[None], s, R, t)[0]
241
+ global_centers.setdefault(f, gc)
242
+ if f not in merged_poses:
243
+ merged_poses[f] = _transform_pose(poses[f], s, R, t)
244
+ n_used += 1
245
+
246
+ if not merged_pts:
247
+ print("[merge] Ningún chunk pudo alinearse.")
248
+ return None
249
+
250
+ pts = np.concatenate(merged_pts)
251
+ cols = np.concatenate(merged_cols)
252
+
253
+ # Dedup del solape por vóxel (opcional). Escala automática si voxel<=0.
254
+ if voxel <= 0:
255
+ center = pts.mean(0)
256
+ scale = float(np.median(np.linalg.norm(pts - center, axis=1)))
257
+ voxel = scale / 600.0 if scale > 0 else 0.0
258
+ if voxel > 0:
259
+ keys = np.floor(pts / voxel).astype(np.int64)
260
+ _, idx = np.unique(keys, axis=0, return_index=True)
261
+ pts, cols = pts[idx], cols[idx]
262
+
263
+ median_pt = np.median(pts, axis=0)
264
+ cam_centers = [c - median_pt for c in global_centers.values()]
265
+ out_ply = os.path.join(out_dir, MERGED_CLOUD_FILE)
266
+ export_ply(out_ply, pts - median_pt, cols, cam_centers)
267
+
268
+ merged_state = {
269
+ "points": pts, "colors": cols, "poses": merged_poses,
270
+ "camera": cam.to_dict(), "frontend": "", "obs": {},
271
+ }
272
+ np.save(os.path.join(out_dir, MERGED_STATE_FILE), merged_state)
273
+ # También como map_state.npy para que `dense`/`view --out <out_dir>` funcionen
274
+ # directo sobre la nube fusionada (ambos leen map_state.npy).
275
+ np.save(os.path.join(out_dir, MAP_STATE_FILE), merged_state)
276
+ print(f"[merge] {n_used} chunks fusionados | {len(pts):,} puntos | "
277
+ f"{len(merged_poses)} cámaras -> {out_ply}")
278
+ print(f"[merge] estado global -> {MERGED_STATE_FILE} (+ copia como {MAP_STATE_FILE} "
279
+ f"para dense/view con --out {out_dir})")
280
+ return out_ply
281
+
282
+
283
+ def _transform_pose(P, s, R, t):
284
+ """Lleva una pose world->cam del marco local al global bajo la similaridad
285
+ (s,R,t) que mapea puntos locales a globales (X_g = s R X_l + t).
286
+
287
+ Para puntos: X_g = sR X_l + t. La cámara cumple x_cam = R_wc X_l + t_wc.
288
+ En el marco global: x_cam = R_wc' X_g + t_wc' con
289
+ R_wc' = R_wc R^T, t_wc' = t_wc - R_wc' t, y la traslación de cámara
290
+ se reescala por s (la escala del mundo). Se mantiene la orientación de la
291
+ cámara; solo cambia su posición/escala.
292
+ """
293
+ R_wc, t_wc = P[:3, :3], P[:3, 3]
294
+ R_new = R_wc @ R.T
295
+ t_new = s * t_wc - R_new @ t
296
+ out = np.eye(4)
297
+ out[:3, :3] = R_new
298
+ out[:3, 3] = t_new
299
+ return out
300
+
301
+
302
+ if __name__ == "__main__":
303
+ p = argparse.ArgumentParser(description="Reconstrucción por chunks con solape + fusión de nubes.")
304
+ p.add_argument("video", help="Video de entrada (mp4/MOV).")
305
+ p.add_argument("--out", default=None, help="Directorio de salida (default outputs/<frontend>/).")
306
+ p.add_argument("--frontend", default=DEFAULT_FRONTEND, help="sift|orb|spglue (default sift).")
307
+ p.add_argument("--camera", default=None, help="JSON de intrínsecos (ver camera.example.json).")
308
+ p.add_argument("--k-skip", type=int, default=5, help="1 de cada k frames (default 5).")
309
+ p.add_argument("--m-window", type=int, default=3, help="Ventana de pares/PnP (default 3).")
310
+ p.add_argument("--chunk", type=int, default=80, help="Frames muestreados por chunk (default 80).")
311
+ p.add_argument("--overlap", type=int, default=20, help="Frames muestreados de solape (default 20).")
312
+ p.add_argument("--min-angle", type=float, default=1.5, help="Ángulo mín. de triangulación.")
313
+ p.add_argument("--no-local-ba", action="store_true", help="Desactiva BA local en cada chunk.")
314
+ p.add_argument("--ba-every", type=int, default=1)
315
+ p.add_argument("--no-fuse", action="store_true")
316
+ p.add_argument("--ba", action="store_true", help="BA global por chunk antes de fusionar.")
317
+ p.add_argument("--ba-max-nfev", type=int, default=200)
318
+ p.add_argument("--chunk-jobs", type=int, default=0,
319
+ help="Chunks reconstruidos en paralelo (procesos; 0=auto, 1=secuencial).")
320
+ p.add_argument("--inner-jobs", type=int, default=1,
321
+ help="Hilos de extracción/matching dentro de cada chunk (default 1).")
322
+ p.add_argument("--min-overlap", type=int, default=4,
323
+ help="Frames de solape mínimos para alinear un chunk (default 4).")
324
+ p.add_argument("--voxel", type=float, default=0.0, help="Vóxel de dedup del merge (0=auto).")
325
+ args = p.parse_args()
326
+
327
+ out = args.out or os.path.join("outputs", args.frontend)
328
+ camera = CameraConfig.from_json(args.camera) if args.camera else None
329
+ run_chunked(
330
+ args.video, out, frontend_name=args.frontend, k_skip=args.k_skip, m_window=args.m_window,
331
+ chunk=args.chunk, overlap=args.overlap, min_angle=args.min_angle,
332
+ local_ba=not args.no_local_ba, ba_every=args.ba_every, fuse=not args.no_fuse,
333
+ ba=args.ba, ba_max_nfev=args.ba_max_nfev, chunk_jobs=args.chunk_jobs,
334
+ inner_jobs=args.inner_jobs, camera=camera, min_overlap=args.min_overlap, voxel=args.voxel,
335
+ )