diff --git a/src/caliscope/cameras/camera_array.py b/src/caliscope/cameras/camera_array.py index 0a82e87aa..4b6e425eb 100644 --- a/src/caliscope/cameras/camera_array.py +++ b/src/caliscope/cameras/camera_array.py @@ -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 diff --git a/src/caliscope/gui/main_widget.py b/src/caliscope/gui/main_widget.py index cffc59c8f..b5a528b10 100644 --- a/src/caliscope/gui/main_widget.py +++ b/src/caliscope/gui/main_widget.py @@ -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 @@ -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() @@ -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"): @@ -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): @@ -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() @@ -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() @@ -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) diff --git a/src/caliscope/gui/presenters/extrinsic_calibration_presenter.py b/src/caliscope/gui/presenters/extrinsic_calibration_presenter.py index 845043915..36a2a88dc 100644 --- a/src/caliscope/gui/presenters/extrinsic_calibration_presenter.py +++ b/src/caliscope/gui/presenters/extrinsic_calibration_presenter.py @@ -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 @@ -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() @@ -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, @@ -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: diff --git a/src/caliscope/gui/presenters/intrinsic_calibration_presenter.py b/src/caliscope/gui/presenters/intrinsic_calibration_presenter.py index 5f252b5f3..a31e7cb62 100644 --- a/src/caliscope/gui/presenters/intrinsic_calibration_presenter.py +++ b/src/caliscope/gui/presenters/intrinsic_calibration_presenter.py @@ -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) @@ -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( @@ -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() diff --git a/src/caliscope/gui/presenters/multi_camera_processing_presenter.py b/src/caliscope/gui/presenters/multi_camera_processing_presenter.py index 743669369..904c2239c 100644 --- a/src/caliscope/gui/presenters/multi_camera_processing_presenter.py +++ b/src/caliscope/gui/presenters/multi_camera_processing_presenter.py @@ -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( @@ -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() diff --git a/src/caliscope/gui/presenters/reconstruction_presenter.py b/src/caliscope/gui/presenters/reconstruction_presenter.py index 5034dcdf4..79f04619c 100644 --- a/src/caliscope/gui/presenters/reconstruction_presenter.py +++ b/src/caliscope/gui/presenters/reconstruction_presenter.py @@ -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 @@ -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() diff --git a/src/caliscope/gui/widgets/model_download_dialog.py b/src/caliscope/gui/widgets/model_download_dialog.py index 261d1ce17..7d04c0f91 100644 --- a/src/caliscope/gui/widgets/model_download_dialog.py +++ b/src/caliscope/gui/widgets/model_download_dialog.py @@ -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( @@ -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.""" diff --git a/src/caliscope/synthetic/explorer/presenter.py b/src/caliscope/synthetic/explorer/presenter.py index ceb0c603b..159b7ccca 100644 --- a/src/caliscope/synthetic/explorer/presenter.py +++ b/src/caliscope/synthetic/explorer/presenter.py @@ -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() diff --git a/src/caliscope/task_manager/task_manager.py b/src/caliscope/task_manager/task_manager.py index 175f950d5..94faea4c2 100644 --- a/src/caliscope/task_manager/task_manager.py +++ b/src/caliscope/task_manager/task_manager.py @@ -64,6 +64,7 @@ def submit( worker: WorkerFn, name: str, task_id: str | None = None, + auto_start: bool = False, ) -> TaskHandle: """Submit a task for background execution. @@ -71,6 +72,7 @@ def submit( 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 @@ -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: diff --git a/src/caliscope/workspace_coordinator.py b/src/caliscope/workspace_coordinator.py index b2fcf21e6..2dc0be693 100644 --- a/src/caliscope/workspace_coordinator.py +++ b/src/caliscope/workspace_coordinator.py @@ -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: @@ -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. @@ -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: diff --git a/tests/test_aniposelib_export.py b/tests/test_aniposelib_export.py index 890a8d8fc..f65ebe256 100644 --- a/tests/test_aniposelib_export.py +++ b/tests/test_aniposelib_export.py @@ -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 diff --git a/tests/test_task_manager.py b/tests/test_task_manager.py index 73b90ed56..14346972a 100644 --- a/tests/test_task_manager.py +++ b/tests/test_task_manager.py @@ -4,7 +4,6 @@ Requires Qt (PySide6) but no xvfb - uses QCoreApplication only. """ -import threading import time import pytest @@ -49,22 +48,20 @@ def worker(token, handle): assert handle.name == "test_task" assert handle.task_id is not None + manager.start_task(handle.task_id) manager.shutdown(timeout_ms=1000) def test_worker_result_emitted_via_completed_signal(qapp): manager = TaskManager() received = {} - start_event = threading.Event() def worker(token, handle): - start_event.wait() # Wait until signal is connected return "result_value" handle = manager.submit(worker, "test_task") handle.completed.connect(lambda r: received.update({"result": r})) - qapp.processEvents() # Ensure connection is processed - start_event.set() # Now let worker proceed + manager.start_task(handle.task_id) _wait_for_condition(lambda: "result" in received, timeout=2.0, qapp=qapp) @@ -77,16 +74,13 @@ def worker(token, handle): def test_worker_exception_emitted_via_failed_signal(qapp): manager = TaskManager() received = {} - start_event = threading.Event() def worker(token, handle): - start_event.wait() # Wait until signal is connected raise ValueError("test error") handle = manager.submit(worker, "failing_task") handle.failed.connect(lambda t, m: received.update({"type": t, "msg": m})) - qapp.processEvents() # Ensure connection is processed - start_event.set() # Now let worker proceed + manager.start_task(handle.task_id) _wait_for_condition(lambda: "type" in received, timeout=2.0, qapp=qapp) @@ -100,17 +94,14 @@ def worker(token, handle): def test_cancel_emits_cancelled_signal(qapp): manager = TaskManager() received = {"cancelled": False} - start_event = threading.Event() def worker(token, handle): - start_event.wait() # Wait until signal is connected while not token.is_cancelled: token.sleep_unless_cancelled(0.1) handle = manager.submit(worker, "cancellable_task") handle.cancelled.connect(lambda: received.update({"cancelled": True})) - qapp.processEvents() # Ensure connection is processed - start_event.set() # Now let worker proceed + manager.start_task(handle.task_id) time.sleep(0.05) # Let worker start its loop manager.cancel(handle.task_id) @@ -125,18 +116,15 @@ def worker(token, handle): def test_progress_updates_emitted(qapp): manager = TaskManager() progress_reports = [] - start_event = threading.Event() def worker(token, handle): - start_event.wait() # Wait until signal is connected for i in range(3): handle.report_progress(i * 33, f"Step {i}") time.sleep(0.01) handle = manager.submit(worker, "progress_task") handle.progress_updated.connect(lambda p, m: progress_reports.append((p, m))) - qapp.processEvents() # Ensure connection is processed - start_event.set() # Now let worker proceed + manager.start_task(handle.task_id) _wait_for_condition(lambda: len(progress_reports) >= 3, timeout=2.0, qapp=qapp) @@ -151,7 +139,6 @@ def test_shutdown_cancels_and_waits(qapp): completed = {"done": False} def worker(token, handle): - # Simulates a task that respects cancellation for _ in range(10): if token.is_cancelled: return "cancelled" @@ -159,8 +146,12 @@ def worker(token, handle): completed["done"] = True return "finished" - # Submit multiple tasks - handles = [manager.submit(worker, f"task_{i}") for i in range(3)] + # Submit and start multiple tasks + handles = [] + for i in range(3): + handle = manager.submit(worker, f"task_{i}") + manager.start_task(handle.task_id) + handles.append(handle) time.sleep(0.05) # Let workers start manager.shutdown(timeout_ms=5000)