Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/caliscope/cameras/camera_array.py
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,7 @@ def to_aniposelib_toml(self, path: Path) -> None:
"distortions": distortions_flat,
"rotation": rotation_rodrigues,
"translation": translation_flat,
"fisheye": camera.fisheye,
}
data[f"cam_{cam_id}"] = camera_dict

Expand Down
36 changes: 23 additions & 13 deletions src/caliscope/gui/main_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
QWidget,
)

from caliscope import APP_SETTINGS_PATH, LOG_DIR, __root__
from caliscope import APP_SETTINGS_PATH, LOG_DIR
from caliscope.gui import ICONS_DIR
from caliscope.workspace_coordinator import WorkspaceCoordinator
from caliscope.task_manager import TaskHandle
from caliscope.gui.cameras_tab_widget import CamerasTabWidget
Expand All @@ -37,9 +38,9 @@ def __init__(self):
self.app_settings = rtoml.load(APP_SETTINGS_PATH)

self.setWindowTitle("Caliscope")
self.setWindowIcon(QIcon(str(Path(__root__, "caliscope/gui/icons/box3d-center.svg"))))
self.setWindowIcon(QIcon(str(ICONS_DIR / "box3d-center.svg")))
self.setMinimumSize(500, 500)
self.central_tab = QWidget(self)
self.central_tab = QTabWidget(self)
self.setCentralWidget(self.central_tab)

self.build_menus()
Expand All @@ -57,12 +58,15 @@ def closeEvent(self, event: QCloseEvent) -> None:
logger.info("Application exit initiated")

# Clean up tabs that have presenter resources
if hasattr(self, "cameras_tab_widget") and hasattr(self.cameras_tab_widget, "cleanup"):
self.cameras_tab_widget.cleanup()
if hasattr(self, "multi_camera_tab") and hasattr(self.multi_camera_tab, "cleanup"):
self.multi_camera_tab.cleanup()
if hasattr(self, "reconstruction_tab") and hasattr(self.reconstruction_tab, "cleanup"):
self.reconstruction_tab.cleanup()
cameras = getattr(self, "cameras_tab_widget", None)
if isinstance(cameras, CamerasTabWidget):
cameras.cleanup()
multi = getattr(self, "multi_camera_tab", None)
if isinstance(multi, MultiCameraProcessingTab):
multi.cleanup()
recon = getattr(self, "reconstruction_tab", None)
if isinstance(recon, ReconstructionTab):
recon.cleanup()

# Coordinator cleanup (TaskManager shutdown)
if hasattr(self, "coordinator"):
Expand All @@ -73,7 +77,9 @@ def closeEvent(self, event: QCloseEvent) -> None:

def connect_menu_actions(self):
self.open_project_action.triggered.connect(self.create_new_project_folder)
self.exit_pyxy3d_action.triggered.connect(QApplication.instance().quit)
app = QApplication.instance()
assert app is not None
self.exit_pyxy3d_action.triggered.connect(app.quit)
self.open_log_directory_action.triggered.connect(self.open_log_dir)

def build_menus(self):
Expand Down Expand Up @@ -313,15 +319,17 @@ def _on_tab_changed(self, new_index: int) -> None:
tabs - it only stops painting them. We manually notify 3D rendering widgets
when their tab becomes inactive to reduce CPU usage.
"""
_3d_tab_types = (ExtrinsicCalibrationTab, ReconstructionTab)

# Suspend rendering on previous tab if it supports it
prev_widget = self.central_tab.widget(self._previous_tab_index)
if hasattr(prev_widget, "suspend_rendering"):
if isinstance(prev_widget, _3d_tab_types):
logger.debug(f"Suspending rendering on tab {self._previous_tab_index}")
prev_widget.suspend_rendering()

# Resume rendering on new tab if it supports it
new_widget = self.central_tab.widget(new_index)
if hasattr(new_widget, "resume_rendering"):
if isinstance(new_widget, _3d_tab_types):
logger.debug(f"Resuming rendering on tab {new_index}")
new_widget.resume_rendering()

Expand All @@ -338,7 +346,8 @@ def reload_workspace(self):
if widget_to_remove is not None:
# Explicit cleanup for widgets that need it
# (closeEvent not triggered by removeTab + deleteLater)
if hasattr(widget_to_remove, "cleanup"):
_cleanable = (CamerasTabWidget, MultiCameraProcessingTab, ExtrinsicCalibrationTab, ReconstructionTab)
if isinstance(widget_to_remove, _cleanable):
widget_to_remove.cleanup()
widget_to_remove.deleteLater()

Expand All @@ -357,6 +366,7 @@ def add_to_recent_project(self, project_path: str):

def open_recent_project(self):
action = self.sender()
assert isinstance(action, QAction)
project_path = action.text()
logger.info(f"Opening recent session stored at {project_path}")
self.launch_workspace(project_path)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ def worker(token: CancellationToken, handle: TaskHandle) -> CaptureVolume:
self._task_handle = self._task_manager.submit(
worker,
name="Extrinsic calibration",
auto_start=False,
)
# Use QueuedConnection because TaskHandle signals are emitted from worker threads.
# Without explicit QueuedConnection, Qt uses DirectConnection (sender/receiver both
Expand All @@ -277,6 +278,7 @@ def worker(token: CancellationToken, handle: TaskHandle) -> CaptureVolume:
self.progress_updated,
Qt.ConnectionType.QueuedConnection,
)
self._task_manager.start_task(self._task_handle.task_id)

self._emit_state_changed()

Expand Down Expand Up @@ -734,7 +736,7 @@ def worker(token: CancellationToken, handle: TaskHandle) -> CaptureVolume:
logger.info(f"Post-filter optimization RMSE: {optimized.reprojection_report.overall_rmse:.3f}px")
return optimized

self._task_handle = self._task_manager.submit(worker, name="Optimize capture volume")
self._task_handle = self._task_manager.submit(worker, name="Optimize capture volume", auto_start=False)
# Use QueuedConnection - TaskHandle signals emitted from worker threads
self._task_handle.completed.connect(
self._on_capture_volume_optimized,
Expand All @@ -752,6 +754,7 @@ def worker(token: CancellationToken, handle: TaskHandle) -> CaptureVolume:
self.progress_updated,
Qt.ConnectionType.QueuedConnection,
)
self._task_manager.start_task(self._task_handle.task_id)
self._emit_state_changed()

def _update_capture_volume(self, capture_volume: CaptureVolume) -> None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,9 @@ def __init__(
self._stream_handle = self._task_manager.submit(
self._streamer.play_worker,
name=f"Streamer cam_id {self._cam_id}",
auto_start=False,
)
self._task_manager.start_task(self._stream_handle.task_id)
self._streamer.pause() # Immediately pause for scrubbing mode

# Position tracking (must be set before _load_initial_frame)
Expand Down Expand Up @@ -462,6 +464,7 @@ def calibration_worker(token: CancellationToken, handle: TaskHandle) -> Intrinsi
self._calibration_task = self._task_manager.submit(
calibration_worker,
name=f"Intrinsic calibration cam_id {self._cam_id}",
auto_start=False,
)
# Use QueuedConnection - TaskHandle signals emitted from worker threads
self._calibration_task.completed.connect(
Expand All @@ -472,6 +475,7 @@ def calibration_worker(token: CancellationToken, handle: TaskHandle) -> Intrinsi
self._on_calibration_failed,
Qt.ConnectionType.QueuedConnection,
)
self._task_manager.start_task(self._calibration_task.task_id)

self._emit_state_changed()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@ def worker(token: CancellationToken, handle: TaskHandle) -> ImagePoints:
self._task_handle = self._task_manager.submit(
worker,
name="Multi-camera processing",
auto_start=False,
)
# Use QueuedConnection - TaskHandle signals emitted from worker threads
self._task_handle.completed.connect(
Expand All @@ -326,6 +327,7 @@ def worker(token: CancellationToken, handle: TaskHandle) -> ImagePoints:
self._on_processing_cancelled,
Qt.ConnectionType.QueuedConnection,
)
self._task_manager.start_task(self._task_handle.task_id)

self._emit_state_changed()

Expand Down
3 changes: 2 additions & 1 deletion src/caliscope/gui/presenters/reconstruction_presenter.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ def worker(token, handle):

return reconstructor

self._processing_task = self._task_manager.submit(worker, name="reconstruction")
self._processing_task = self._task_manager.submit(worker, name="reconstruction", auto_start=False)

# Connect signals - use QueuedConnection since TaskHandle signals
# are emitted from worker threads
Expand All @@ -359,6 +359,7 @@ def worker(token, handle):
self._on_progress,
Qt.ConnectionType.QueuedConnection,
)
self._task_manager.start_task(self._processing_task.task_id)

self._emit_state_changed()

Expand Down
3 changes: 2 additions & 1 deletion src/caliscope/gui/widgets/model_download_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ def worker(token, handle):
cancellation_check=lambda: token.is_cancelled,
)

self._task_handle = self._task_manager.submit(worker, name=f"download_{card.name}")
self._task_handle = self._task_manager.submit(worker, name=f"download_{card.name}", auto_start=False)

# Connect signals with QueuedConnection (signals come from worker thread)
self._task_handle.completed.connect(
Expand All @@ -253,6 +253,7 @@ def worker(token, handle):
self._on_progress,
Qt.ConnectionType.QueuedConnection,
)
self._task_manager.start_task(self._task_handle.task_id)

def _cancel_download(self) -> None:
"""Cancel the running download task."""
Expand Down
2 changes: 2 additions & 0 deletions src/caliscope/synthetic/explorer/presenter.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,9 +240,11 @@ def pipeline_worker(token: CancellationToken, handle: TaskHandle) -> PipelineRes
self._pipeline_task = self._task_manager.submit(
pipeline_worker,
name="Synthetic Pipeline",
auto_start=False,
)
self._pipeline_task.completed.connect(self._on_pipeline_complete)
self._pipeline_task.failed.connect(self._on_pipeline_failed)
self._task_manager.start_task(self._pipeline_task.task_id)

self.pipeline_started.emit()

Expand Down
17 changes: 15 additions & 2 deletions src/caliscope/task_manager/task_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,15 @@ def submit(
worker: WorkerFn,
name: str,
task_id: str | None = None,
auto_start: bool = False,
) -> TaskHandle:
"""Submit a task for background execution.

Args:
worker: Callable with signature (token, handle) -> Any
name: Human-readable name for logging
task_id: Optional identifier; auto-generated if not provided
auto_start: If False, defer thread.start() until start_task() is called

Returns:
TaskHandle for monitoring/controlling the task
Expand All @@ -86,11 +88,22 @@ def submit(
thread.finished.connect(lambda: self._cleanup_task(task_id))

self._tasks[task_id] = (handle, thread)
logger.info(f"Starting task '{name}' (id={task_id})")
thread.start()

if auto_start:
logger.info(f"Starting task '{name}' (id={task_id})")
thread.start()

return handle

def start_task(self, task_id: str) -> bool:
"""Start a previously submitted task. Returns True if found and started."""
if task_id in self._tasks:
_, thread = self._tasks[task_id]
if not thread.isRunning():
thread.start()
return True
return False

def cancel(self, task_id: str) -> bool:
"""Cancel a specific task. Returns True if found."""
if task_id in self._tasks:
Expand Down
9 changes: 6 additions & 3 deletions src/caliscope/workspace_coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,8 +240,9 @@ def worker(_token, _handle):
else:
logger.info("Skipping capture volume load (not calibrated)")

handle = self.task_manager.submit(worker, name="load_workspace")
handle = self.task_manager.submit(worker, name="load_workspace", auto_start=False)
handle.completed.connect(lambda _: self.status_changed.emit())
self.task_manager.start_task(handle.task_id)
return handle

def all_instrinsic_mp4s_available(self) -> bool:
Expand Down Expand Up @@ -722,8 +723,9 @@ def worker(_token, _handle):
# Log prominently - user's changes may be lost on restart
logger.error(f"Failed to persist CaptureVolume: {e}")

handle = self.task_manager.submit(worker, name="save_capture_volume")
handle = self.task_manager.submit(worker, name="save_capture_volume", auto_start=False)
handle.completed.connect(lambda _: self.status_changed.emit()) # Post-save
self.task_manager.start_task(handle.task_id)

def rotate_capture_volume(self, axis: Literal["x", "y", "z"], angle_degrees: float) -> None:
"""Rotate the capture volume and persist.
Expand Down Expand Up @@ -783,7 +785,8 @@ def worker(token, handle):
self.reconstructor.create_xyz()
handle.report_progress(100, "Complete")

handle = self.task_manager.submit(worker, name="process_recordings")
handle = self.task_manager.submit(worker, name="process_recordings", auto_start=False)
self.task_manager.start_task(handle.task_id)
return handle

def cleanup(self) -> None:
Expand Down
4 changes: 3 additions & 1 deletion tests/test_aniposelib_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ def test_aniposelib_export_format(tmp_path: Path) -> None:
assert "grid_count" not in section
assert "ignore" not in section
assert "rotation_count" not in section
assert "fisheye" not in section

# fisheye flag is included (expected by Pose2Sim)
assert section["fisheye"] is False

# Verify metadata
assert data["metadata"]["adjusted"] is False
Expand Down
Loading
Loading