stereo-charuco-pipeline 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,329 @@
1
+ """
2
+ Project Manager UI: Select or create a project before launching the pipeline.
3
+
4
+ Shows existing projects under the projects base directory and allows
5
+ creating new timestamped project folders with the correct scaffold.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from dataclasses import dataclass
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ import tkinter as tk
16
+ from tkinter import ttk, messagebox
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # Theme constants (match calibration_ui / pipeline_ui)
21
+ BG = "#2b2b2b"
22
+ BG_PANEL = "#333333"
23
+ BG_LIST = "#1a1a1a"
24
+ FG = "#e0e0e0"
25
+ FG_DIM = "#888888"
26
+ ACCENT = "#4CAF50"
27
+ ACCENT_BLUE = "#2196F3"
28
+ ACCENT_ORANGE = "#FF9800"
29
+
30
+
31
+ @dataclass
32
+ class ProjectContext:
33
+ """Carries dynamic project paths through the unified workflow."""
34
+ project_dir: Path
35
+ project_name: str
36
+ is_new: bool
37
+ needs_calibration: bool # True if calibration/intrinsic/port_1.mp4 missing
38
+
39
+
40
+ @dataclass
41
+ class _ProjectInfo:
42
+ """Internal: scanned project metadata for display."""
43
+ name: str
44
+ path: Path
45
+ has_calib_videos: bool
46
+ calibrated: bool
47
+ num_recordings: int
48
+
49
+
50
+ class ProjectManagerUI(tk.Tk):
51
+ """Project selection/creation dialog."""
52
+
53
+ def __init__(self, projects_base: Path):
54
+ super().__init__()
55
+
56
+ self.projects_base = projects_base
57
+ self.projects_base.mkdir(parents=True, exist_ok=True)
58
+
59
+ self.result: Optional[ProjectContext] = None
60
+ self._projects: list[_ProjectInfo] = []
61
+
62
+ self.title("Stereo Pipeline - Project Manager")
63
+ self.geometry("700x450")
64
+ self.configure(bg=BG)
65
+ self.resizable(False, False)
66
+
67
+ self._build_ui()
68
+ self._scan_and_display()
69
+
70
+ # Center window on screen
71
+ self.update_idletasks()
72
+ x = (self.winfo_screenwidth() - 700) // 2
73
+ y = (self.winfo_screenheight() - 450) // 2
74
+ self.geometry(f"700x450+{x}+{y}")
75
+
76
+ # ======================================================================
77
+ # UI
78
+ # ======================================================================
79
+
80
+ def _build_ui(self):
81
+ # Title
82
+ tk.Label(
83
+ self, text="Project Manager",
84
+ font=("Segoe UI", 16, "bold"), fg=FG, bg=BG,
85
+ ).pack(pady=(15, 5))
86
+
87
+ tk.Label(
88
+ self, text=f"Base: {self.projects_base}",
89
+ font=("Segoe UI", 9), fg=FG_DIM, bg=BG,
90
+ ).pack(pady=(0, 10))
91
+
92
+ # Main content: list + details side by side
93
+ content = tk.Frame(self, bg=BG)
94
+ content.pack(fill=tk.BOTH, expand=True, padx=15)
95
+
96
+ # ── Left: project list ──
97
+ left = tk.Frame(content, bg=BG)
98
+ left.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 8))
99
+
100
+ tk.Label(
101
+ left, text="Projects", font=("Segoe UI", 10, "bold"),
102
+ fg=FG, bg=BG, anchor="w",
103
+ ).pack(fill=tk.X)
104
+
105
+ list_frame = tk.Frame(left, bg=BG_LIST)
106
+ list_frame.pack(fill=tk.BOTH, expand=True, pady=(4, 0))
107
+
108
+ self._listbox = tk.Listbox(
109
+ list_frame, bg=BG_LIST, fg=FG,
110
+ font=("Consolas", 10), selectmode=tk.SINGLE,
111
+ selectbackground=ACCENT_BLUE, selectforeground="white",
112
+ highlightthickness=0, bd=0,
113
+ )
114
+ scrollbar = ttk.Scrollbar(list_frame, orient="vertical",
115
+ command=self._listbox.yview)
116
+ self._listbox.configure(yscrollcommand=scrollbar.set)
117
+
118
+ scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
119
+ self._listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
120
+ self._listbox.bind("<<ListboxSelect>>", self._on_select)
121
+ self._listbox.bind("<Double-Button-1>", lambda e: self._on_open())
122
+
123
+ # ── Right: details panel ──
124
+ right = tk.Frame(content, bg=BG_PANEL, width=250)
125
+ right.pack(side=tk.RIGHT, fill=tk.Y)
126
+ right.pack_propagate(False)
127
+
128
+ tk.Label(
129
+ right, text="Details", font=("Segoe UI", 10, "bold"),
130
+ fg=FG, bg=BG_PANEL, anchor="w",
131
+ ).pack(fill=tk.X, padx=10, pady=(10, 5))
132
+
133
+ self._detail_name = tk.Label(
134
+ right, text="(select a project)", font=("Segoe UI", 10),
135
+ fg=FG_DIM, bg=BG_PANEL, anchor="w", wraplength=220,
136
+ )
137
+ self._detail_name.pack(fill=tk.X, padx=10, pady=2)
138
+
139
+ self._detail_calib = tk.Label(
140
+ right, text="", font=("Segoe UI", 9),
141
+ fg=FG_DIM, bg=BG_PANEL, anchor="w",
142
+ )
143
+ self._detail_calib.pack(fill=tk.X, padx=10, pady=2)
144
+
145
+ self._detail_status = tk.Label(
146
+ right, text="", font=("Segoe UI", 9),
147
+ fg=FG_DIM, bg=BG_PANEL, anchor="w",
148
+ )
149
+ self._detail_status.pack(fill=tk.X, padx=10, pady=2)
150
+
151
+ self._detail_recordings = tk.Label(
152
+ right, text="", font=("Segoe UI", 9),
153
+ fg=FG_DIM, bg=BG_PANEL, anchor="w",
154
+ )
155
+ self._detail_recordings.pack(fill=tk.X, padx=10, pady=2)
156
+
157
+ self._detail_path = tk.Label(
158
+ right, text="", font=("Segoe UI", 8),
159
+ fg=FG_DIM, bg=BG_PANEL, anchor="w", wraplength=220,
160
+ )
161
+ self._detail_path.pack(fill=tk.X, padx=10, pady=(10, 2))
162
+
163
+ # ── Bottom: buttons ──
164
+ btn_frame = tk.Frame(self, bg=BG)
165
+ btn_frame.pack(fill=tk.X, padx=15, pady=15)
166
+
167
+ self.btn_new = tk.Button(
168
+ btn_frame, text="New Project",
169
+ font=("Segoe UI", 10, "bold"),
170
+ bg=ACCENT, fg="white", activebackground="#388E3C",
171
+ relief="flat", padx=20, pady=6,
172
+ command=self._on_new,
173
+ )
174
+ self.btn_new.pack(side=tk.LEFT)
175
+
176
+ self.btn_open = tk.Button(
177
+ btn_frame, text="Open Project",
178
+ font=("Segoe UI", 10, "bold"),
179
+ bg=ACCENT_BLUE, fg="white", activebackground="#1565C0",
180
+ relief="flat", padx=20, pady=6,
181
+ state=tk.DISABLED,
182
+ command=self._on_open,
183
+ )
184
+ self.btn_open.pack(side=tk.LEFT, padx=10)
185
+
186
+ self.btn_cancel = tk.Button(
187
+ btn_frame, text="Cancel",
188
+ font=("Segoe UI", 10),
189
+ bg="#555", fg=FG,
190
+ relief="flat", padx=15, pady=6,
191
+ command=self.destroy,
192
+ )
193
+ self.btn_cancel.pack(side=tk.RIGHT)
194
+
195
+ # ======================================================================
196
+ # Scanning
197
+ # ======================================================================
198
+
199
+ def _scan_projects(self) -> list[_ProjectInfo]:
200
+ """Scan for valid project directories."""
201
+ found: list[_ProjectInfo] = []
202
+
203
+ def _check_dir(d: Path):
204
+ if not d.is_dir() or d.name.startswith((".", "__")):
205
+ return
206
+ has_calib = (d / "calibration").exists()
207
+ has_rec = (d / "recordings").exists()
208
+ if not has_calib and not has_rec and not d.name.startswith("project_"):
209
+ return
210
+ has_calib_videos = (
211
+ (d / "calibration" / "intrinsic" / "port_1.mp4").exists()
212
+ and (d / "calibration" / "intrinsic" / "port_2.mp4").exists()
213
+ )
214
+ calibrated = (d / "camera_array.toml").exists()
215
+ num_rec = 0
216
+ rec_dir = d / "recordings"
217
+ if rec_dir.exists():
218
+ num_rec = sum(1 for rd in rec_dir.iterdir()
219
+ if rd.is_dir() and rd.name.startswith("session_"))
220
+ found.append(_ProjectInfo(
221
+ name=d.name,
222
+ path=d,
223
+ has_calib_videos=has_calib_videos,
224
+ calibrated=calibrated,
225
+ num_recordings=num_rec,
226
+ ))
227
+
228
+ # Scan 1 level deep
229
+ for item in sorted(self.projects_base.iterdir()):
230
+ _check_dir(item)
231
+ # Scan 2 levels deep (e.g. caliscope/caliscope_project)
232
+ if item.is_dir() and not item.name.startswith((".", "__")):
233
+ for sub in sorted(item.iterdir()):
234
+ _check_dir(sub)
235
+
236
+ return found
237
+
238
+ def _scan_and_display(self):
239
+ """Scan projects and update the listbox."""
240
+ self._projects = self._scan_projects()
241
+ self._listbox.delete(0, tk.END)
242
+
243
+ for p in self._projects:
244
+ status = ""
245
+ if p.calibrated:
246
+ status = " [calibrated]"
247
+ elif p.has_calib_videos:
248
+ status = " [has videos]"
249
+ else:
250
+ status = " [new]"
251
+ self._listbox.insert(tk.END, f" {p.name}{status}")
252
+
253
+ if not self._projects:
254
+ self._detail_name.config(text="No projects found.\nClick 'New Project' to create one.")
255
+
256
+ # ======================================================================
257
+ # Event handlers
258
+ # ======================================================================
259
+
260
+ def _on_select(self, event=None):
261
+ """Handle project list selection."""
262
+ sel = self._listbox.curselection()
263
+ if not sel:
264
+ return
265
+
266
+ idx = sel[0]
267
+ p = self._projects[idx]
268
+
269
+ self.btn_open.config(state=tk.NORMAL)
270
+
271
+ self._detail_name.config(text=p.name, fg=FG)
272
+
273
+ if p.calibrated:
274
+ self._detail_calib.config(text="Calibration: Complete", fg=ACCENT)
275
+ elif p.has_calib_videos:
276
+ self._detail_calib.config(text="Calibration: Videos ready", fg=ACCENT_ORANGE)
277
+ else:
278
+ self._detail_calib.config(text="Calibration: Not started", fg=FG_DIM)
279
+
280
+ if p.calibrated:
281
+ self._detail_status.config(text="Status: Ready to record & reconstruct", fg=ACCENT)
282
+ elif p.has_calib_videos:
283
+ self._detail_status.config(text="Status: Needs auto-calibration", fg=ACCENT_ORANGE)
284
+ else:
285
+ self._detail_status.config(text="Status: Needs calibration videos", fg=FG_DIM)
286
+
287
+ self._detail_recordings.config(
288
+ text=f"Recordings: {p.num_recordings}",
289
+ fg=FG if p.num_recordings > 0 else FG_DIM,
290
+ )
291
+ self._detail_path.config(text=f"Path: {p.path}")
292
+
293
+ def _on_new(self):
294
+ """Create a new project."""
295
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
296
+ project_name = f"project_{ts}"
297
+ project_dir = self.projects_base / project_name
298
+
299
+ # Create scaffold
300
+ (project_dir / "calibration" / "intrinsic").mkdir(parents=True, exist_ok=True)
301
+ (project_dir / "calibration" / "extrinsic").mkdir(parents=True, exist_ok=True)
302
+ (project_dir / "recordings").mkdir(parents=True, exist_ok=True)
303
+ (project_dir / "raw_output").mkdir(parents=True, exist_ok=True)
304
+
305
+ logger.info(f"Created new project: {project_dir}")
306
+
307
+ self.result = ProjectContext(
308
+ project_dir=project_dir,
309
+ project_name=project_name,
310
+ is_new=True,
311
+ needs_calibration=True,
312
+ )
313
+ self.destroy()
314
+
315
+ def _on_open(self):
316
+ """Open the selected project."""
317
+ sel = self._listbox.curselection()
318
+ if not sel:
319
+ return
320
+
321
+ p = self._projects[sel[0]]
322
+
323
+ self.result = ProjectContext(
324
+ project_dir=p.path,
325
+ project_name=p.name,
326
+ is_new=False,
327
+ needs_calibration=not p.has_calib_videos,
328
+ )
329
+ self.destroy()