Skip to content
Draft
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
3 changes: 2 additions & 1 deletion dlclivegui/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@
SINGLE_CAMERA_WORKER_DO_LOG_TIMING: bool = False
MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = False
REC_DO_LOG_TIMING: bool = False
DLC_DO_LOG_TIMING: bool = True
DLC_DO_LOG_TIMING: bool = False
DLC_LIFECYCLE_EXTRA_LOGS: bool = True
# MAIN_WINDOW_DO_LOG_TIMING: bool = False
#### Backends
BASLER_DO_LOG_TIMING: bool = False
Expand Down
175 changes: 167 additions & 8 deletions dlclivegui/gui/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,11 @@
)

from ..processors.processor_utils import (
create_spec_from_scan,
default_processors_dir,
instantiate_from_scan,
log_processor_context,
processor_builds_in_worker,
scan_processor_folder,
scan_processor_package,
)
Expand Down Expand Up @@ -1786,6 +1789,7 @@ def _start_multi_camera_recording(self) -> None:
if run_dir is None:
self._show_error("Failed to start recording.")
return
self._notify_processor_recording_started(run_dir)
self.multi_camera_controller.set_recording_sink(self._rec_manager.write_frame)
self.multi_camera_controller.set_recording_frame_do_emit(True)

Expand Down Expand Up @@ -1827,7 +1831,125 @@ def worker():
daemon=True,
).start()

def _get_dlc_processor_instance(self):
"""Return the active custom DLC processor instance, if available."""
processor = getattr(self._dlc, "_processor", None)

if processor is not None:
return processor

# Fallback: if DLCLive owns it internally.
dlc_obj = getattr(self._dlc, "_dlc", None)
if dlc_obj is not None:
return getattr(dlc_obj, "processor", None)

return None

def _save_processor_data_if_available(self) -> None:
"""Best-effort generic processor save.

The GUI intentionally does not pass a path here. This lets custom processors
use their own save_path / filename / internal policy.

Expected processor contract:
processor.save() -> int | bool | None

Return values are only logged; failure should not crash the GUI.
"""
processor = self._get_dlc_processor_instance()

if processor is None:
logger.debug("Processor save skipped: no processor instance available.")
return

save = getattr(processor, "save", None)
if not callable(save):
logger.debug("Processor save skipped: processor has no callable save().")
return

try:
result = save()
logger.info("Processor save() completed with result: %r", result)
except Exception:
logger.exception("Processor save() failed.")

def _notify_processor_recording_started(self, run_dir) -> None:
processor = self._get_dlc_processor_instance()
if processor is None:
return

hook = getattr(processor, "on_recording_started", None)
if not callable(hook):
return

try:
context = self._build_processor_recording_context(run_dir)
hook(context)
logger.info("Notified processor recording started: %s", context)
except Exception:
logger.exception("Processor on_recording_started hook failed")

from pathlib import Path

def _build_processor_recording_context(self, run_dir) -> dict:
run_dir = Path(run_dir) if run_dir is not None else None

file_context = {}
try:
file_context = self._rec_manager.get_recording_file_context()
except Exception:
logger.exception("Failed to get recording file context from RecordingManager")
file_context = {}

if run_dir is None:
run_dir = file_context.get("run_dir", None)
if run_dir is not None:
run_dir = Path(run_dir)

session_name = ""
if hasattr(self, "session_name_edit"):
session_name = self.session_name_edit.text().strip()

filename = ""
if hasattr(self, "filename_edit"):
filename = self.filename_edit.text().strip()

filename_stem = Path(filename or session_name or "recording").stem

ctx = {
"run_dir": run_dir,
"session_name": session_name,
"filename": filename,
"filename_stem": filename_stem,
"processor_base_path": run_dir / filename_stem if run_dir is not None else None,
}
ctx.update(file_context)
return ctx

def _notify_processor_recording_stopped(self) -> None:
processor = self._get_dlc_processor_instance()
if processor is None:
return False

hook = getattr(processor, "on_recording_stopped", None)
if not callable(hook):
return False

try:
run_dir = getattr(self._rec_manager, "run_dir", None)
context = self._build_processor_recording_context(run_dir)
hook(context)
logger.info("Notified processor recording stopped")
return True
except Exception:
logger.exception("Processor on_recording_stopped hook failed")
return False

def _on_recording_stopped_async(self) -> None:
handled_by_stop_hook = self._notify_processor_recording_stopped()
if not handled_by_stop_hook:
self._save_processor_data_if_available()

self._recording_stopping = False
self.start_record_button.setEnabled(True)
self.stop_record_button.setEnabled(False)
Expand Down Expand Up @@ -1953,33 +2075,70 @@ def _configure_dlc(self) -> bool:
except (ValueError, RuntimeError, json.JSONDecodeError) as exc:
self._show_error(f"Invalid DLCLive settings: {exc}")
return False

if not settings.model_path:
self._show_error("Please select a DLCLive model before starting inference.")
return False

# Instantiate processor if selected
processor = None
processor_spec = None

if self._processor_control_enabled():
selected_key = self.processor_combo.currentData()
self._settings_store.set_processor_key(selected_key)

if selected_key is not None and self._scanned_processors:
try:
# For now, instantiate with no parameters
processor = instantiate_from_scan(self._scanned_processors, selected_key)
processor_name = self._scanned_processors[selected_key]["name"]
processor_info = self._scanned_processors[selected_key]
processor_class = processor_info["class"]
processor_name = processor_info.get("name", processor_class.__name__)

if processor_builds_in_worker(processor_class):
processor_spec = create_spec_from_scan(
self._scanned_processors,
selected_key,
)
processor = None

log_processor_context(
f"MainWindow._configure_dlc - SPEC: {processor_class.__name__}",
logger,
)

else:
processor = instantiate_from_scan(
self._scanned_processors,
selected_key,
)
processor_spec = None

log_processor_context(
f"MainWindow._configure_dlc - INSTANCE: {type(processor).__name__}",
logger,
)

self.statusBar().showMessage(f"Loaded processor: {processor_name}", 3000)

except Exception as e:
error_msg = f"Failed to instantiate processor: {e}"
error_msg = f"Failed to configure processor: {e}"
self._show_error(error_msg)
logger.error(error_msg)
logger.exception(error_msg)
return False

else:
selected_key = self.processor_combo.currentData()
if selected_key is not None:
self.statusBar().showMessage(f"Processor selection ignored (control disabled): {selected_key}", 3000)
self.statusBar().showMessage(
f"Processor selection ignored (control disabled): {selected_key}",
3000,
)

self._dlc.configure(
settings,
processor=processor,
processor_spec=processor_spec,
)

self._dlc.configure(settings, processor=processor)
self._model_path_store.save_if_valid(settings.model_path)
return True

Expand Down
83 changes: 76 additions & 7 deletions dlclivegui/processors/dlc_processor_socket.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ class BaseProcessorSocket(Processor):
PROCESSOR_DESCRIPTION = "Base class for socket-based processors with multi-client support"
PROCESSOR_PARAMS = {}

# Experimental:
# Socket/Teensy/Unity processors often start threads, sockets, serial ports, etc.
# Build them inside the DLCLive worker to match legacy Tk GUI behavior.
PROCESSOR_BUILD_IN_WORKER = False

def __init__(
self,
bind=None,
Expand Down Expand Up @@ -81,6 +86,8 @@ def __init__(
self._vid_recording = Event()
self.curr_step = 0
self.save_original = save_original
self.recording_context = {}
self.save_path = None

# Networking (optional)
self.address = bind
Expand Down Expand Up @@ -260,8 +267,14 @@ def stop_recording(self):
# STOP / SHUTDOWN
# --------------------------------------------------------------------------------------

def stop(self):
def stop(self, save: bool = False, file=None):
"""Gracefully stop listener and clients."""
if save:
try:
self.save(file)
except Exception:
logger.exception("Failed to save processor data on stop")

if self._stop.is_set():
return

Expand Down Expand Up @@ -365,23 +378,79 @@ def _clear_data_queues(self):
self.original_pose.clear()

def save(self, file=None):
if not file:
target = file

if target is None:
target = getattr(self, "save_path", None)

if target is None:
logger.warning("Processor save skipped: no file or save_path provided.")
return 0

try:
save_dict = self.get_data()
path2save = Path(__file__).parent.parent.parent / "data" / file
path2save.parent.mkdir(parents=True, exist_ok=True)
save_path = Path(target)
save_path.parent.mkdir(parents=True, exist_ok=True)

if self.save_original:
original_pose = save_dict.pop("original_pose")
self.save_original_pose(original_pose, save_dict["frame_time"], save_dict["time_stamp"], path2save)
with open(path2save, "wb") as f:
self.save_original_pose(
original_pose,
save_dict["frame_time"],
save_dict["time_stamp"],
save_path,
)

with open(save_path, "wb") as f:
pickle.dump(save_dict, f)
logger.info(f"Saved data to {path2save}")

logger.info(f"Saved processor data to {save_path}")
return 1

except Exception as e:
logger.error(f"Save failed: {e}")
return -1

def set_recording_context(self, context: dict | None) -> None:
"""Set GUI-provided recording context.

This is intentionally generic. Custom processors may use it to derive
processor-specific output paths.

Expected keys may include:
run_dir
session_name
filename
filename_stem
processor_base_path
video_files
timestamp_files
"""
self.recording_context = dict(context or {})

base_path = self.recording_context.get("processor_base_path")
if base_path is not None:
self.save_path = Path(base_path)

def set_save_path(self, path) -> None:
"""Set default save path used by save() when no file is provided."""
self.save_path = Path(path) if path is not None else None

def get_save_path(self):
"""Return default save path, if any."""
return getattr(self, "save_path", None)

def on_recording_started(self, context: dict) -> None:
"""Optional hook called by GUI when recording starts."""
self.set_recording_context(context)

def on_recording_stopped(self, context: dict) -> None:
"""Optional hook called by GUI when recording stops.

Base implementation only updates context. Custom processors can override.
"""
self.set_recording_context(context)

def save_original_pose(
self,
original_pose: np.ndarray,
Expand Down
Loading