-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathReMap-GUI.py
More file actions
3181 lines (2791 loc) · 153 KB
/
Copy pathReMap-GUI.py
File metadata and controls
3181 lines (2791 loc) · 153 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import customtkinter as ctk
import tkinter as tk
from tkinter import filedialog as tk_filedialog
import shutil
import threading
import sys
import multiprocessing
import subprocess
import secrets
import tempfile
from pathlib import Path
import os
import logging
import math
import time
from tqdm import tqdm as _original_tqdm
import cv2
import numpy as np
from PIL import Image
from backend.color_conversion_worker import process_image_color_worker, convert_frame_to_srgb_proxy_worker, convert_frame_to_output_exr_worker
from backend.bundle_postprocess import normalize_final_bundle
from backend.frame_filter import reject_low_quality_frames
try:
import OpenImageIO as oiio
HAS_OCIO = True
except ImportError:
oiio = None
HAS_OCIO = False
# --- Color Space Conversion System ---
# Source profiles: describes what the input data looks like
COLOR_SOURCES = [
"Auto-detect",
"Linear BT.2020",
"Linear ACEScg",
"Apple Log (BT.2020)",
"Linear sRGB",
"sRGB (Rec.709)",
"HLG (BT.2020)",
"S-Log 3 (S-Gamut 3)",
]
# Destination profiles: where we want to go
COLOR_DESTINATIONS = [
"ACEScg (EXR + sRGB PNG)",
"Linear sRGB",
"sRGB (Tone Mapped)",
"Custom OCIO...",
]
ACESCG_OCIO_SPACE = "ACES - ACEScg"
# ffprobe metadata → source profile mapping
_FFPROBE_TO_SOURCE = {
("bt2020", "linear"): "Linear BT.2020",
("bt2020", "alog"): "Apple Log (BT.2020)",
("bt2020", "arib-std-b67"): "HLG (BT.2020)",
("bt709", "bt709"): "sRGB (Rec.709)",
("bt709", "linear"): "Linear sRGB",
("bt709", "iec61966-2-1"): "sRGB (Rec.709)",
("bt709", "srgb"): "sRGB (Rec.709)",
("bt2020", "unknown"): "S-Log 3 (S-Gamut 3)",
("bt2020", "slog3"): "S-Log 3 (S-Gamut 3)",
}
# --- Gamut Matrices (computed from CIE XYZ with Bradford D65↔D60 adaptation) ---
# BT.2020 → ACEScg (AP1)
_MAT_BT2020_TO_ACESCG = np.array([
[ 0.97990525, 0.02225227, -0.03192382],
[-0.00058388, 0.99476128, 0.01081350],
[ 0.00046861, 0.01941638, 1.06066918]
], dtype=np.float32)
# ACEScg (AP1) → sRGB (Rec.709) — for dual-output PNG generation
_MAT_ACESCG_TO_SRGB = np.array([
[ 1.70298067, -0.62451279, -0.03670953],
[-0.12985749, 1.14073295, -0.01436027],
[-0.02069324, -0.12236011, 1.05442752]
], dtype=np.float32)
# BT.2020 → sRGB (Rec.709)
_MAT_BT2020_TO_SRGB = np.array([
[ 1.66022663, -0.58754766, -0.07283817],
[-0.12455332, 1.13292610, -0.00834968],
[-0.01815514, -0.10060303, 1.11899821]
], dtype=np.float32)
# sRGB (Rec.709) → ACEScg (AP1)
_MAT_SRGB_TO_ACESCG = np.array([
[0.61590865, 0.34031053, 0.01410133],
[0.06855723, 0.91546150, 0.02095702],
[0.01902455, 0.11135884, 0.94994319]
], dtype=np.float32)
def _apply_matrix(rgb, matrix):
"""Apply a 3×3 color matrix to an (H, W, 3) or (N, 3) array."""
orig_shape = rgb.shape
flat = rgb.reshape(-1, 3)
result = np.dot(flat, matrix.T)
return result.reshape(orig_shape)
def _apple_log_to_linear(P):
"""
Decode Apple Log encoded values to scene-linear.
Based on Apple Log Profile White Paper.
"""
R_cut = 0.00104
a = 5.555556
b = 0.047996
c = 0.529136
d = 0.089004
e_lin = 10.444689
f = 0.180395
E_cut = e_lin * R_cut + f # ~0.1913
E = P.astype(np.float32)
R = np.where(
E >= E_cut,
(np.power(2.0, (E - d) / c) - b) / a,
(E - f) / e_lin
)
return np.maximum(R, 0.0)
def _hlg_eotf(E):
"""
Hybrid Log-Gamma (HLG) OETF inverse — decode HLG signal to scene-linear.
ITU-R BT.2100.
"""
a = 0.17883277
b = 1.0 - 4.0 * a
c = 0.5 - a * np.log(4.0 * a)
E = np.asarray(E, dtype=np.float32)
return np.where(
E <= 0.5,
(E ** 2) / 3.0,
(np.exp((E - c) / a) + b) / 12.0
)
def _srgb_eotf(E):
"""sRGB gamma decode (sRGB display → linear)."""
E = np.asarray(E, dtype=np.float32)
return np.where(
E <= 0.04045,
E / 12.92,
np.power(np.maximum((E + 0.055) / 1.055, 0.0), 2.4)
)
def _srgb_oetf(x):
"""sRGB gamma encoding (linear → sRGB display)."""
return np.where(x <= 0.0031308, 12.92 * x,
1.055 * np.power(np.maximum(x, 1e-7), 1/2.4) - 0.055)
def _aces_tonemap(x):
"""Narkowicz ACES filmic tone mapping curve."""
a = 2.51; b = 0.03; c = 2.43; d = 0.59; e = 0.14
return np.clip((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0)
def _linearize(rgb_norm, source):
"""Apply the correct EOTF based on source profile → scene-linear RGB."""
if source in ("Linear BT.2020", "Linear ACEScg", "Linear sRGB"):
return rgb_norm # already linear
elif source == "Apple Log (BT.2020)":
return _apple_log_to_linear(rgb_norm)
elif source == "HLG (BT.2020)":
return _hlg_eotf(rgb_norm)
elif source == "sRGB (Rec.709)":
return _srgb_eotf(rgb_norm)
return rgb_norm
def _source_primaries(source):
"""Return 'bt2020' or 'srgb' for a given source profile."""
if source in ("Linear BT.2020", "Apple Log (BT.2020)", "HLG (BT.2020)"):
return "bt2020"
if source == "Linear ACEScg":
return "acescg"
return "srgb"
def _gamut_convert(rgb_linear, src_primaries, dst_primaries):
"""Convert between gamuts using pre-computed 3×3 matrices."""
key = (src_primaries, dst_primaries)
matrices = {
("bt2020", "acescg"): _MAT_BT2020_TO_ACESCG,
("bt2020", "srgb"): _MAT_BT2020_TO_SRGB,
("srgb", "acescg"): _MAT_SRGB_TO_ACESCG,
("acescg", "srgb"): _MAT_ACESCG_TO_SRGB,
}
if key in matrices:
return _apply_matrix(rgb_linear, matrices[key])
if src_primaries == dst_primaries:
return rgb_linear
return rgb_linear # fallback: no conversion
# --- Theme & Colors ---
try:
import torch
from hloc import extract_features, match_features, reconstruction, pairs_from_exhaustive
from backend.loma_matcher import LoMaMatcher, is_loma_matcher, loma_feature_path, loma_matches_path
import pycolmap
from sfm_runner import run_sfm_with_live_export
from stray_to_colmap import convert_stray_to_colmap
except ImportError as e:
print(f"CRITICAL: HLoc, PyCOLMAP or Torch not installed. {e}")
sys.exit(1)
# --- Theme & Colors ---
ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("blue")
COLORS = {
"bg_dark": "#0f0f1a",
"bg_card": "#1a1a2e",
"bg_card_hover": "#22223a",
"accent_blue": "#4f6df5",
"accent_purple": "#7c3aed",
"accent_gradient_start": "#4f6df5",
"accent_gradient_end": "#9333ea",
"text_primary": "#e2e8f0",
"text_secondary": "#94a3b8",
"text_muted": "#64748b",
"success": "#10b981",
"error": "#ef4444",
"warning": "#f59e0b",
"console_bg": "#0a0a14",
"console_fg": "#4ade80",
"border": "#2a2a4a",
}
def generate_sequential_pairs(image_dir, pairs_path, overlap=10):
"""Generate sequential pairs: each image is paired with its `overlap` nearest neighbors."""
images = sorted([f.name for f in Path(image_dir).iterdir() if f.suffix.lower() in ('.jpg', '.jpeg', '.png', '.tif', '.tiff')])
pairs = []
for i in range(len(images)):
for j in range(i + 1, min(i + 1 + overlap, len(images))):
pairs.append((images[i], images[j]))
with open(pairs_path, 'w') as f:
f.writelines(' '.join(p) + '\n' for p in pairs)
return len(pairs)
def count_pairs_file(pairs_path):
try:
with open(pairs_path, "r", encoding="utf-8") as f:
return sum(1 for line in f if line.strip())
except Exception:
return 0
def _process_image_color_worker(img_path_str, source_space, dest_space, cs_in, cs_out, colorconfig_path, exr_out_dir_str):
"""
Modular color conversion worker for parallel executors.
Pipeline: Read → Normalize → Linearize (EOTF) → Gamut Matrix → Encode → Write
Args:
source_space: Source profile (e.g. "Linear BT.2020", "Apple Log (BT.2020)")
dest_space: Destination profile (e.g. "ACEScg (EXR + sRGB PNG)", "sRGB (Tone Mapped)")
cs_in/cs_out: OCIO colorspace names (only used when dest_space == "Custom OCIO...")
colorconfig_path: Path to .ocio config (only for Custom OCIO)
exr_out_dir_str: Directory for EXR output (for dual-output modes)
Returns: (success_bool, error_msg_or_none)
"""
import cv2, numpy as np
img_path = Path(img_path_str)
# --- Custom OCIO passthrough ---
if dest_space == "Custom OCIO..." and HAS_OCIO:
try:
buf = oiio.ImageBuf(str(img_path))
if not buf.has_error:
res = oiio.ImageBufAlgo.colorconvert(buf, buf, cs_in, cs_out, colorconfig=colorconfig_path or "")
if res:
buf.write(str(img_path))
return True, None
except Exception as e:
return False, str(e)
return False, "OCIO Error"
# --- Native math pipeline ---
try:
img = cv2.imread(str(img_path), cv2.IMREAD_UNCHANGED)
if img is None:
return False, "Failed to read image"
max_val = 65535.0 if img.dtype == np.uint16 else 255.0
if len(img.shape) < 3 or img.shape[2] < 3:
return False, "Unsupported channels"
# BGR(A) → RGB, normalize to [0,1] float32
try:
gpu_img = cv2.cuda_GpuMat()
gpu_img.upload(img)
gpu_rgb = cv2.cuda.cvtColor(gpu_img, cv2.COLOR_BGR2RGB if img.shape[2] == 3 else cv2.COLOR_BGRA2RGB)
img_rgb = gpu_rgb.download().astype(np.float32) / max_val
except Exception:
img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB if img.shape[2] == 3 else cv2.COLOR_BGRA2RGB).astype(np.float32) / max_val
h, w, ch = img_rgb.shape
# Step 1: Linearize (apply EOTF based on source)
linear_rgb = _linearize(img_rgb, source_space)
# Step 2: Determine source and destination gamut primaries
src_prim = _source_primaries(source_space)
if dest_space == "ACEScg (EXR + sRGB PNG)":
# --- ACEScg dual output: EXR (linear ACEScg) + sRGB PNG ---
acescg_rgb = _gamut_convert(linear_rgb, src_prim, "acescg")
# Write EXR (ACEScg linear, 32-bit float)
if exr_out_dir_str:
os.makedirs(exr_out_dir_str, exist_ok=True)
out_exr = str(Path(exr_out_dir_str) / f"{img_path.stem}.exr")
else:
out_exr = str(img_path).rsplit('.', 1)[0] + '.exr'
if HAS_OCIO:
spec_exr = oiio.ImageSpec(w, h, ch, oiio.FLOAT)
spec_exr.attribute("oiio:ColorSpace", ACESCG_OCIO_SPACE)
buf_exr = oiio.ImageBuf(spec_exr)
buf_exr.set_pixels(oiio.ROI(), acescg_rgb)
buf_exr.write(out_exr)
else:
# Fallback: write via cv2 (limited EXR support)
exr_bgr = cv2.cvtColor(acescg_rgb, cv2.COLOR_RGB2BGR)
cv2.imwrite(out_exr, exr_bgr)
# Write sRGB PNG (tone mapped, 16-bit) — for SfM
srgb_linear = _gamut_convert(linear_rgb, src_prim, "srgb")
srgb_tonemapped = _aces_tonemap(np.maximum(srgb_linear, 0.0))
srgb_display = _srgb_oetf(srgb_tonemapped)
srgb_16 = np.clip(srgb_display * 65535, 0, 65535).astype(np.uint16)
srgb_bgr = cv2.cvtColor(srgb_16, cv2.COLOR_RGB2BGR)
cv2.imwrite(str(img_path), srgb_bgr)
return True, None
elif dest_space == "sRGB (Tone Mapped)":
# --- sRGB tone-mapped output (16-bit PNG) ---
srgb_linear = _gamut_convert(linear_rgb, src_prim, "srgb")
srgb_tonemapped = _aces_tonemap(np.maximum(srgb_linear, 0.0))
srgb_display = _srgb_oetf(srgb_tonemapped)
srgb_16 = np.clip(srgb_display * 65535, 0, 65535).astype(np.uint16)
srgb_bgr = cv2.cvtColor(srgb_16, cv2.COLOR_RGB2BGR)
cv2.imwrite(str(img_path), srgb_bgr)
return True, None
elif dest_space == "Linear sRGB":
# --- Linear sRGB (16-bit PNG, no gamma) ---
srgb_linear = _gamut_convert(linear_rgb, src_prim, "srgb")
srgb_16 = np.clip(srgb_linear * 65535, 0, 65535).astype(np.uint16)
srgb_bgr = cv2.cvtColor(srgb_16, cv2.COLOR_RGB2BGR)
cv2.imwrite(str(img_path), srgb_bgr)
return True, None
return False, f"Unknown destination: {dest_space}"
except Exception as e:
return False, str(e)
def _detect_16bit_from_images(image_dir):
"""Check if existing images in a directory are 16-bit."""
for ext in ('*.png', '*.tif', '*.tiff'):
for img_path in Path(image_dir).glob(ext):
try:
with Image.open(img_path) as img:
if img.mode.startswith("I;16"):
return True
if img.format == 'PNG':
with open(img_path, 'rb') as f:
if f.read(8) == b'\x89PNG\r\n\x1a\n':
f.seek(24)
b = f.read(1)
if b and b[0] == 16:
return True
if img.format == 'TIFF' and img.mode in ('RGB', 'RGBA'):
img_cv = cv2.imread(str(img_path), cv2.IMREAD_UNCHANGED)
if img_cv is not None and img_cv.dtype == np.uint16:
return True
return False
except Exception:
pass
break
return False
def _ocio_needs_16bit(colorspace_name):
"""Return True if the OCIO output colorspace is linear and needs 16-bit precision."""
if not colorspace_name:
return False
lower = colorspace_name.lower()
return any(kw in lower for kw in ('linear', 'acescg', 'scene-linear', 'scene_linear', 'aces - acescg'))
class CancelledError(Exception):
"""Raised when the user cancels processing."""
pass
class GUIProgressTqdm(_original_tqdm):
"""Tqdm replacement that reports progress to the GUI step label, throttled."""
_gui_app = None
_step_index = 0
_step_name = ""
def __init__(self, *args, **kwargs):
kwargs['file'] = open(os.devnull, 'w')
kwargs['disable'] = False
super().__init__(*args, **kwargs)
self._last_gui_update = 0
self._devnull_fp = kwargs.get('file')
def update(self, n=1):
super().update(n)
# Check for cancellation
app = GUIProgressTqdm._gui_app
if app and app._cancelled:
raise CancelledError("Processing cancelled by user")
now = time.monotonic()
if now - self._last_gui_update < 0.25: # max 4 updates/sec
return
self._last_gui_update = now
if app and self.total:
pct = self.n / self.total
step_i = GUIProgressTqdm._step_index
total_steps = len(app.STEPS)
overall = (step_i + pct) / total_steps
text = (
f"Step {step_i + 1}/{total_steps} — {GUIProgressTqdm._step_name} "
f"({self.n}/{self.total}, {pct * 100:.0f}%)"
)
app.after(0, lambda: app.step_label.configure(text=text, text_color=COLORS["accent_blue"]))
app.after(0, lambda: app.progress_bar.set(overall))
def close(self):
try:
if hasattr(self, '_devnull_fp') and self._devnull_fp and not self._devnull_fp.closed:
self._devnull_fp.close()
except Exception:
pass
super().close()
class GuiLogHandler(logging.Handler):
"""Custom logging handler to redirect logs to the GUI console."""
def __init__(self, callback):
super().__init__()
self.callback = callback
self.tag = ""
def set_tag(self, tag):
self.tag = tag
def emit(self, record):
try:
msg = self.format(record)
if self.tag:
msg = f"{self.tag} {msg}"
self.callback(msg)
except Exception:
self.handleError(record)
class GuiStream:
"""Redirect sys.stdout/sys.stderr to the GUI console during processing."""
def __init__(self, callback):
self.callback = callback
self._buf = ""
def write(self, msg):
if not msg:
return
self._buf += msg
while '\n' in self._buf:
line, self._buf = self._buf.split('\n', 1)
if line.strip():
self.callback(line)
def flush(self):
if self._buf.strip():
self.callback(self._buf)
self._buf = ""
def isatty(self):
return False
class SectionCard(ctk.CTkFrame):
"""A card-style section with a title, styled border, and inner padding."""
def __init__(self, master, title, icon="", **kwargs):
super().__init__(master, fg_color=COLORS["bg_card"], corner_radius=12, border_width=1,
border_color=COLORS["border"], **kwargs)
header = ctk.CTkFrame(self, fg_color="transparent")
header.pack(fill="x", padx=16, pady=(14, 4))
ctk.CTkLabel(header, text=f"{icon} {title}", font=ctk.CTkFont(size=15, weight="bold"),
text_color=COLORS["text_primary"]).pack(side="left")
self.content = ctk.CTkFrame(self, fg_color="transparent")
self.content.pack(fill="x", padx=16, pady=(4, 14))
class InfoTooltip(ctk.CTkFrame):
"""A small info button that toggles a collapsible explanation panel.
The info panel is created as a child of the SectionCard (grandparent)
and packed between the header and content areas to avoid grid/pack conflicts.
"""
def __init__(self, master, text, **kwargs):
super().__init__(master, fg_color="transparent", **kwargs)
self._expanded = False
self._text = text
self.btn = ctk.CTkButton(self, text="ⓘ", width=28, height=28, corner_radius=14,
fg_color=COLORS["bg_card_hover"], hover_color=COLORS["accent_blue"],
text_color=COLORS["text_secondary"], font=ctk.CTkFont(size=14),
command=self._toggle)
self.btn.pack(side="left")
# Create info_frame as child of the SectionCard (grandparent of tooltip)
# SectionCard uses pack for header + content, so we can safely pack between them
self._card = master.master # SectionCard
self._card_content = master # card.content (pack before this)
self.info_frame = ctk.CTkFrame(self._card, fg_color=COLORS["bg_dark"], corner_radius=8,
border_width=1, border_color=COLORS["border"])
self.info_label = ctk.CTkLabel(self.info_frame, text=self._text,
text_color=COLORS["text_secondary"],
font=ctk.CTkFont(size=12), wraplength=600, justify="left")
self.info_label.pack(padx=12, pady=10, anchor="w")
def _toggle(self):
if self._expanded:
self.info_frame.pack_forget()
self.btn.configure(fg_color=COLORS["bg_card_hover"])
else:
# Pack in the SectionCard, right before card.content
self.info_frame.pack(fill="x", padx=16, pady=(0, 8), before=self._card_content)
self.btn.configure(fg_color=COLORS["accent_blue"])
self._expanded = not self._expanded
def pack_info_after(self, widget):
"""No-op, kept for backwards compatibility."""
pass
class SearchableSelectionWindow(ctk.CTkToplevel):
def __init__(self, master, title, options, current_selection, callback):
super().__init__(master)
self.title(title)
self.geometry("400x500")
self.minsize(300, 400)
self.configure(fg_color=COLORS["bg_dark"])
# Make it modal
self.transient(master)
# Defer grab_set to avoid "window not viewable" TclError
self.after(100, self.grab_set)
self.options = options
self.callback = callback
# Search Entry
self.search_var = ctk.StringVar()
self.search_var.trace_add("write", self._filter_options)
self.search_entry = ctk.CTkEntry(self, textvariable=self.search_var, placeholder_text="Search...",
fg_color=COLORS["bg_card"], border_color=COLORS["border"],
text_color=COLORS["text_primary"], height=36)
self.search_entry.pack(fill="x", padx=16, pady=(16, 8))
self.search_entry.focus_set()
# Scrollable List
self.scroll_frame = ctk.CTkScrollableFrame(self, fg_color="transparent")
self.scroll_frame.pack(fill="both", expand=True, padx=16, pady=(0, 16))
self.buttons = []
self._populate_list(self.options, current_selection)
def _filter_options(self, *args):
query = self.search_var.get().lower()
if not query:
filtered = self.options
else:
filtered = [opt for opt in self.options if query in opt.lower()]
self._populate_list(filtered)
def _populate_list(self, items, current_selection=None):
for btn in self.buttons:
btn.destroy()
self.buttons.clear()
for item in items:
is_selected = item == current_selection
fg_color = COLORS["accent_blue"] if is_selected else COLORS["bg_card"]
hover_color = COLORS["accent_purple"] if is_selected else COLORS["bg_card_hover"]
btn = ctk.CTkButton(self.scroll_frame, text=item, anchor="w",
fg_color=fg_color, hover_color=hover_color,
text_color=COLORS["text_primary"], corner_radius=6,
command=lambda val=item: self._select_item(val))
btn.pack(fill="x", pady=2)
self.buttons.append(btn)
def _select_item(self, value):
self.callback(value)
self.destroy()
class SfMApp(ctk.CTk):
STEPS = ["FFmpeg/Prep", "OCIO", "Features", "Pairs", "Matching", "SfM"]
STEPS_STRAY_A = ["Rescan→COLMAP", "OCIO", "Features", "Pairs", "Matching", "Triangulation"]
STEPS_STRAY_B = ["Rescan→COLMAP", "OCIO", "Features", "Pairs", "Matching", "SfM"]
def __init__(self):
super().__init__()
self.title("ReMap — Gaussian Splatting Preparation Pipeline")
self.geometry("1000x860")
self.minsize(640, 600)
self.configure(fg_color=COLORS["bg_dark"])
self._cancelled = False
self._processing = False
self._server_process = None # subprocess.Popen handle for the API server
# --- Variables ---
self.video_paths = [] # List of Path objects for multi-video
self.stray_paths = [] # List of Path objects for multi-Rescan datasets
self.video_path = ctk.StringVar() # Display variable for the entry field
self.output_path = ctk.StringVar()
self.fps_extract = ctk.DoubleVar(value=4.0)
self.force_16bit = ctk.BooleanVar(value=False)
self.camera_model = ctk.StringVar(value="OPENCV")
self.feature_type = ctk.StringVar(value="superpoint_aachen")
self.matcher_type = ctk.StringVar(value="superpoint+lightglue")
self.max_keypoints = ctk.StringVar(value="4096")
self.pairing_mode = ctk.StringVar(value="Sequential (Video)")
self.fps_label_var = ctk.StringVar(value="4.0 FPS")
self.mapper_type = ctk.StringVar(value="COLMAP")
# Check for GLOMAP
self.has_glomap = shutil.which("glomap") is not None
if self.has_glomap:
self.mapper_type.set("GLOMAP")
self.input_mode = ctk.StringVar(value="Video (.mp4, .mov)")
# Rescan specific variables
self.stray_approach = ctk.StringVar(value="full_sfm")
self.stray_confidence = ctk.IntVar(value=2)
self.stray_depth_subsample = ctk.IntVar(value=2)
self.stray_gen_pointcloud = ctk.BooleanVar(value=True)
# Color conversion variables (Source/Destination system)
self.color_enabled = ctk.BooleanVar(value=False)
self.color_source = ctk.StringVar(value="Auto-detect")
self.color_dest = ctk.StringVar(value="ACEScg (EXR + sRGB PNG)")
self.detected_color_profile = ctk.StringVar(value="") # Filled by ffprobe
# OCIO (kept for "Custom OCIO..." destination)
self.ocio_path = ctk.StringVar(value=os.environ.get("OCIO", ""))
self.ocio_in_cs = ctk.StringVar(value="")
self.ocio_out_cs = ctk.StringVar(value="")
self.use_acescg_exr = ctk.BooleanVar(value=True)
self.ocio_spaces = []
self.has_ocio_lib = HAS_OCIO
# Input probing data (for frame count estimates)
self._video_infos = [] # [{path, duration, native_fps, total_frames}]
self._rescan_infos = [] # [{path, total_frames}]
self.frame_estimate_var = ctk.StringVar(value="")
# Dashboard stats (updated during processing)
self._dash_stats = {"images": 0, "features": 0, "matches": 0, "points3d": 0}
# Worker configuration
default_workers = multiprocessing.cpu_count()
self.num_workers = ctk.IntVar(value=default_workers)
self.workers_label_var = ctk.StringVar(value=f"{default_workers} Threads")
self._build_ui()
self.protocol("WM_DELETE_WINDOW", self._on_closing)
def _on_closing(self):
"""Terminate the server subprocess (if running) before closing the window."""
if self._server_process is not None:
self._server_process.terminate()
try:
self._server_process.wait(timeout=5)
except subprocess.TimeoutExpired:
self._server_process.kill()
if hasattr(self, "_server_log_fp") and self._server_log_fp:
try:
self._server_log_fp.close()
except Exception:
pass
self.destroy()
# -------------------------------------------------------------------------
# UI BUILDING
# -------------------------------------------------------------------------
def _build_ui(self):
# --- Title Bar ---
title_frame = ctk.CTkFrame(self, fg_color="transparent")
title_frame.pack(fill="x", padx=24, pady=(18, 6))
ctk.CTkLabel(title_frame, text="◆", font=ctk.CTkFont(size=28),
text_color=COLORS["accent_blue"]).pack(side="left", padx=(0, 8))
ctk.CTkLabel(title_frame, text="Re", font=ctk.CTkFont(size=24, weight="bold"),
text_color=COLORS["text_primary"]).pack(side="left")
ctk.CTkLabel(title_frame, text="Map", font=ctk.CTkFont(size=24),
text_color=COLORS["accent_purple"]).pack(side="left", padx=(4, 0))
ctk.CTkLabel(title_frame, text="Gaussian Splatting Preparation Pipeline",
font=ctk.CTkFont(size=12), text_color=COLORS["text_muted"]).pack(side="left", padx=(16, 0))
# --- Scrollable main area ---
main_scroll = ctk.CTkScrollableFrame(self, fg_color="transparent", corner_radius=0)
main_scroll.pack(fill="both", expand=True, padx=20, pady=(6, 0))
# Fix mouse wheel scrolling on Linux (X11 uses Button-4 / Button-5)
def _on_mousewheel(event):
canvas = main_scroll._parent_canvas
if event.num == 4:
canvas.yview_scroll(-3, "units")
elif event.num == 5:
canvas.yview_scroll(3, "units")
main_scroll.bind_all("<Button-4>", _on_mousewheel)
main_scroll.bind_all("<Button-5>", _on_mousewheel)
# --- 1. Input / Output ---
card_io = SectionCard(main_scroll, "Input / Output", icon="📁")
card_io.pack(fill="x", pady=(0, 10))
self._card_io_ref = card_io # Reference for dynamic card positioning
# Info tooltip for I/O
io_tip = InfoTooltip(card_io.content,
"Select your input source and output directory.\n"
"The output will contain images/, sparse/0/ and hloc_outputs/ folders\n"
"compatible with 3DGS training tools (e.g. gsplat, nerfstudio).")
io_tip.grid(row=0, column=2, sticky="e", pady=(0, 6))
io_tip.pack_info_after(card_io.content)
# Input Mode Selection
mode_row = ctk.CTkFrame(card_io.content, fg_color="transparent")
mode_row.grid(row=0, column=0, columnspan=2, sticky="ew", pady=(0, 6))
ctk.CTkLabel(mode_row, text="Input Mode", text_color=COLORS["text_secondary"],
font=ctk.CTkFont(size=13)).pack(side="left", padx=(0, 10))
self.seg_input_mode = ctk.CTkSegmentedButton(
mode_row, values=["Video (.mp4, .mov)", "Image Folder", "Rescan (LiDAR)"],
variable=self.input_mode, command=self._on_input_mode_change,
selected_color=COLORS["accent_blue"],
selected_hover_color=COLORS["accent_blue"],
unselected_color=COLORS["bg_dark"],
text_color=COLORS["text_primary"]
)
self.seg_input_mode.pack(side="left", fill="x", expand=True)
self.seg_input_mode.set("Video (.mp4, .mov)")
self.input_label_var = ctk.StringVar(value="Video File(s)")
self._file_row(card_io.content, self.input_label_var, self.video_path, self._browse_input, row=1)
self._file_row(card_io.content, "Output Folder", self.output_path, self._browse_output, row=2)
# --- FPS Global Row ---
self.fps_frame = ctk.CTkFrame(card_io.content, fg_color="transparent")
self.fps_frame.grid(row=3, column=0, columnspan=3, sticky="ew", pady=(10, 0))
lbl_row = ctk.CTkFrame(self.fps_frame, fg_color="transparent")
lbl_row.pack(fill="x")
ctk.CTkLabel(lbl_row, text="Extraction FPS", text_color=COLORS["text_secondary"], font=ctk.CTkFont(size=13)).pack(side="left")
self.fps_label = ctk.CTkLabel(lbl_row, textvariable=self.fps_label_var, text_color=COLORS["accent_blue"], font=ctk.CTkFont(size=13, weight="bold"))
self.fps_label.pack(side="right", padx=(0, 4))
self.slider_fps = ctk.CTkSlider(self.fps_frame, from_=0.5, to=30, number_of_steps=59,
variable=self.fps_extract, command=self._on_fps_change,
progress_color=COLORS["accent_blue"],
button_color=COLORS["accent_purple"],
fg_color=COLORS["border"])
self.slider_fps.pack(fill="x", pady=(4, 2))
# --- 2. Video Extraction ---
self.card_vid = SectionCard(main_scroll, "Video Extraction (FFmpeg)", icon="🎬")
self.card_vid.pack(fill="x", pady=(0, 10))
# Info tooltip for Video Extraction
vid_tip = InfoTooltip(self.card_vid.content,
"Controls how frames are extracted from video files.\n\n"
"FPS: Higher FPS = more images = better coverage but slower processing.\n"
"• Walkthrough / indoor: 2–4 FPS\n"
"• Drone / FPV: 4–8 FPS\n"
"• Small object / turntable: 8–15 FPS\n\n"
"16-bit output is auto-detected from OCIO settings, or use Force 16-bit.\n"
"Extraction is automatically skipped if images already exist in the output.")
vid_tip.pack(anchor="e", pady=(0, 4))
vid_tip.pack_info_after(self.card_vid.content)
# Force 16-bit Row
bit_row = ctk.CTkFrame(self.card_vid.content, fg_color="transparent")
bit_row.pack(fill="x", pady=(0, 8))
ctk.CTkLabel(bit_row, text="Force 16-bit PNG output",
text_color=COLORS["text_secondary"], font=ctk.CTkFont(size=13)).pack(side="left")
ctk.CTkSwitch(bit_row, variable=self.force_16bit, text="", width=46,
progress_color=COLORS["accent_purple"],
button_color=COLORS["text_primary"]).pack(side="right")
# Frame estimate label (video)
self.vid_frame_estimate_label = ctk.CTkLabel(
self.card_vid.content, textvariable=self.frame_estimate_var,
text_color=COLORS["accent_purple"], font=ctk.CTkFont(size=12, weight="bold"))
self.vid_frame_estimate_label.pack(anchor="w", pady=(4, 0))
# --- 2b. Rescan Settings (hidden by default) ---
self.card_stray = SectionCard(main_scroll, "Rescan (LiDAR)", icon="📱")
# Hidden by default — shown when Rescan mode is selected
# Info tooltip for Rescan
stray_tip = InfoTooltip(self.card_stray.content,
"Settings for LiDAR scan datasets (Stray Scanner / Rescan app).\n\n"
"Approach B (Full SfM): Recalculates camera poses from scratch.\n"
" → Best quality, recommended for most cases.\n"
"Approach A (ARKit Poses): Uses device odometry directly.\n"
" → Faster but potentially less accurate.\n\n"
"Subsampling: Use every Nth frame. Higher = fewer frames = faster.\n"
"LiDAR Confidence: Filter out low-confidence depth measurements.")
stray_tip.pack(anchor="e", pady=(0, 4))
stray_tip.pack_info_after(self.card_stray.content)
# Approach selection
approach_row = ctk.CTkFrame(self.card_stray.content, fg_color="transparent")
approach_row.pack(fill="x", pady=(0, 8))
ctk.CTkLabel(approach_row, text="Approach", text_color=COLORS["text_secondary"],
font=ctk.CTkFont(size=13)).pack(side="left", padx=(0, 10))
self.seg_stray_approach = ctk.CTkSegmentedButton(
approach_row, values=["B — Full SfM (default)", "A — ARKit Poses"],
variable=self.stray_approach,
selected_color=COLORS["accent_purple"],
selected_hover_color=COLORS["accent_purple"],
unselected_color=COLORS["bg_dark"],
text_color=COLORS["text_primary"],
command=self._on_stray_approach_change
)
self.seg_stray_approach.pack(side="left", fill="x", expand=True)
self.seg_stray_approach.set("B — Full SfM (default)")
# Frame estimate label (Rescan)
self.rescan_frame_estimate_label = ctk.CTkLabel(
self.card_stray.content, text="",
text_color=COLORS["accent_purple"], font=ctk.CTkFont(size=12, weight="bold"))
self.rescan_frame_estimate_label.pack(anchor="w", pady=(0, 6))
# Confidence threshold
conf_row = ctk.CTkFrame(self.card_stray.content, fg_color="transparent")
conf_row.pack(fill="x", pady=(0, 4))
ctk.CTkLabel(conf_row, text="Min. LiDAR Confidence", text_color=COLORS["text_secondary"],
font=ctk.CTkFont(size=13)).pack(side="left")
ctk.CTkComboBox(conf_row, variable=self.stray_confidence,
values=["0 (all)", "1 (medium)", "2 (high)"], state="readonly",
width=140, dropdown_fg_color=COLORS["bg_card"],
button_color=COLORS["accent_blue"],
border_color=COLORS["border"],
fg_color=COLORS["bg_dark"],
command=self._on_stray_confidence_change).pack(side="right")
# Set default display
self.stray_confidence.set(2)
# Point cloud toggle
pc_row = ctk.CTkFrame(self.card_stray.content, fg_color="transparent")
pc_row.pack(fill="x", pady=(4, 0))
ctk.CTkLabel(pc_row, text="Generate LiDAR point cloud",
text_color=COLORS["text_secondary"], font=ctk.CTkFont(size=13)).pack(side="left")
ctk.CTkSwitch(pc_row, variable=self.stray_gen_pointcloud, text="", width=46,
progress_color=COLORS["accent_blue"],
button_color=COLORS["text_primary"]).pack(side="right")
exr_row = ctk.CTkFrame(self.card_stray.content, fg_color="transparent")
exr_row.pack(fill="x", pady=(4, 0))
ctk.CTkLabel(exr_row, text="Generate / patch ACEScg EXR output",
text_color=COLORS["text_secondary"], font=ctk.CTkFont(size=13)).pack(side="left")
ctk.CTkSwitch(exr_row, variable=self.use_acescg_exr, text="", width=46,
progress_color=COLORS["accent_purple"],
button_color=COLORS["text_primary"]).pack(side="right")
ctk.CTkLabel(self.card_stray.content, text="Full SfM: COLMAP recalculates poses. ARKit Poses: uses device odometry directly.",
text_color=COLORS["text_muted"], font=ctk.CTkFont(size=11), wraplength=600).pack(anchor="w", pady=(4, 0))
# --- 3. SfM Pipeline ---
card_sfm = SectionCard(main_scroll, "SfM Pipeline", icon="🔬")
card_sfm.pack(fill="x", pady=(0, 10))
# Info tooltip for SfM Pipeline
sfm_tip = InfoTooltip(card_sfm.content,
"Feature detection and matching settings.\n\n"
"Features: SuperPoint is recommended for most cases.\n"
" DISK or ALIKED for challenging lighting/textures.\n"
"Matcher: LightGlue is fastest. SuperGlue more robust for difficult scenes.\n"
"Max Keypoints: 4096 is a good default. Increase to 8192 for complex scenes.\n"
"Pairing: Sequential for video/scan. Exhaustive for small unordered sets (<200).\n"
" Exhaustive is more precise for spatialization but much slower.\n\n"
"SfM Engine: GLOMAP is faster (GPU). COLMAP is the robust default.")
sfm_tip.grid(row=0, column=3, sticky="e", pady=(0, 4))
sfm_tip.pack_info_after(card_sfm.content)
grid = card_sfm.content
grid.columnconfigure(1, weight=1)
grid.columnconfigure(3, weight=1)
grid.columnconfigure(0, minsize=120)
grid.columnconfigure(2, minsize=120)
features_list = ["superpoint_aachen", "superpoint_max", "disk", "aliked-n16", "sift"]
matchers_list = ["superpoint+lightglue", "superglue", "disk+lightglue", "adalam", "loma_b", "loma_g"]
pairing_list = ["Sequential (Video)", "Exhaustive (Small dataset < 200)"]
self._combo_row(grid, "Features", self.feature_type, features_list, row=1, col=0)
self._entry_row(grid, "Max Keypoints", self.max_keypoints, row=1, col=2, width=100)
self._combo_row(grid, "Matcher", self.matcher_type, matchers_list, row=2, col=0)
self._combo_row(grid, "Pair Strategy", self.pairing_mode, pairing_list, row=2, col=2)
# --- Mapper Select (Row 3) ---
ctk.CTkLabel(grid, text="SfM Engine", text_color=COLORS["text_secondary"],
font=ctk.CTkFont(size=12, weight="bold")).grid(row=3, column=0, sticky="w", pady=(10, 0))
map_values = ["GLOMAP", "COLMAP"]
if not self.has_glomap:
map_values = ["GLOMAP (Not installed)", "COLMAP"]
self.seg_map = ctk.CTkSegmentedButton(grid, values=map_values, variable=self.mapper_type,
selected_color=COLORS["accent_purple"],
selected_hover_color=COLORS["accent_purple"],
unselected_color=COLORS["bg_card"],
text_color=COLORS["text_primary"],
command=self._on_mapper_change)
self.seg_map.grid(row=3, column=1, columnspan=3, sticky="ew", pady=(10, 0), padx=(5, 0))
if not self.has_glomap:
self.seg_map.set("COLMAP")
self.seg_map.configure(state="disabled")
# --- Color Management (Source / Destination) ---
card_color = SectionCard(main_scroll, "Color Management", icon="🎨")
card_color.pack(fill="x", pady=(0, 10))
# Info tooltip
color_tip = InfoTooltip(card_color.content,
"Convert extracted frames between color spaces.\n\n"
"Source: The color profile of the input video.\n"
" • Auto-detect reads ffprobe metadata (recommended)\n"
" • Manual override for known sources\n\n"
"Destination: Target color space for processing.\n"
" • ACEScg: Industry-standard scene-linear (EXR + sRGB PNG)\n"
" • Linear sRGB: Scene-linear Rec.709\n"
" • sRGB (Tone Mapped): Display-ready with ACES filmic curve\n"
" • Custom OCIO: Use your own .ocio config\n\n"
"16-bit output is automatic for linear/log sources.")
color_tip.pack(anchor="e", pady=(0, 4))
color_tip.pack_info_after(card_color.content)
# Enable toggle row
color_toggle_row = ctk.CTkFrame(card_color.content, fg_color="transparent")
color_toggle_row.pack(fill="x", pady=(0, 8))
ctk.CTkLabel(color_toggle_row, text="Color Conversion",
text_color=COLORS["text_secondary"], font=ctk.CTkFont(size=13)).pack(side="left")
ctk.CTkSwitch(color_toggle_row, variable=self.color_enabled, text="", width=46,
progress_color=COLORS["accent_purple"],
button_color=COLORS["text_primary"],
command=self._on_color_enabled_change).pack(side="right")
# Source / Destination frame (shown when enabled)
self.color_sd_frame = ctk.CTkFrame(card_color.content, fg_color="transparent")
sd_row = ctk.CTkFrame(self.color_sd_frame, fg_color="transparent")
sd_row.pack(fill="x", pady=(0, 4))
sd_row.columnconfigure(1, weight=1)
sd_row.columnconfigure(3, weight=1)
ctk.CTkLabel(sd_row, text="Source", text_color=COLORS["text_secondary"],
font=ctk.CTkFont(size=13)).grid(row=0, column=0, padx=(0, 8), sticky="w")
self.combo_color_source = ctk.CTkComboBox(
sd_row, variable=self.color_source, values=COLOR_SOURCES, state="readonly",
dropdown_fg_color=COLORS["bg_card"], button_color=COLORS["accent_blue"],
border_color=COLORS["border"], fg_color=COLORS["bg_dark"], width=200)
self.combo_color_source.grid(row=0, column=1, sticky="w", padx=(0, 20))
ctk.CTkLabel(sd_row, text="Destination", text_color=COLORS["text_secondary"],
font=ctk.CTkFont(size=13)).grid(row=0, column=2, padx=(0, 8), sticky="w")
self.combo_color_dest = ctk.CTkComboBox(
sd_row, variable=self.color_dest, values=COLOR_DESTINATIONS, state="readonly",
dropdown_fg_color=COLORS["bg_card"], button_color=COLORS["accent_blue"],
border_color=COLORS["border"], fg_color=COLORS["bg_dark"], width=220,
command=self._on_color_dest_change)
self.combo_color_dest.grid(row=0, column=3, sticky="w")
# Detected profile label
self.detected_profile_label = ctk.CTkLabel(
self.color_sd_frame, text="", text_color=COLORS["accent_purple"],
font=ctk.CTkFont(size=12, weight="bold"))