Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f3705eb
feat: multi-camera support keyed by video_aim+camera_idx
alexevag May 12, 2026
9b9e40a
fix: make Camera.stop_rec idempotent and drop release() TOCTOU race
alexevag May 12, 2026
aebe6dc
fix: stat-based device probe and correct Recording row h5 paths
alexevag May 12, 2026
f80ded3
feat: improve clear_local_videos diagnostics
alexevag May 14, 2026
fd61143
WebCam: add device_id support and resolver
alexevag Jun 24, 2026
878f087
Refactor docs, logging, and camera handling
alexevag Jun 25, 2026
422ebb3
Use explicit length check for recs
alexevag Jun 25, 2026
2e7399b
co-locate camera video with session h5 files;
alexevag Jun 25, 2026
fbb8b13
expand conditions on any iterable primary key, not just core[0]
alexevag Jun 25, 2026
a9d008c
Update dlc.py
alexevag Jun 25, 2026
df91814
fix: pass configured serve_port and support logger-less WebCam timest…
alexevag Jul 20, 2026
af59f23
refactor: extract condition-row expansion into a documented helper
alexevag Jul 20, 2026
a20d4ed
Merge remote-tracking branch 'origin/main' into multi-camera-support
alexevag Jul 20, 2026
e6b0c52
test: cover expand_condition_rows
alexevag Jul 20, 2026
0ec33b5
test: initialize session_params in the behavior fixture
alexevag Jul 20, 2026
d2d6e02
test: pin empty-sequence and tuple-split behavior of expand_condition…
alexevag Jul 20, 2026
8dc817f
Ensure source/target paths end with separator
alexevag Aug 13, 2026
ce245c8
Harden file transfer and camera error handling
alexevag Aug 13, 2026
f627124
Start the camera HTTP server after the camera is initialised
alexevag Aug 19, 2026
65e864a
Throttle the MJPEG stream and stop holding the frame lock during writes
alexevag Aug 19, 2026
a64a6ce
add log in errors in the camera threads
alexevag Aug 21, 2026
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
33 changes: 9 additions & 24 deletions src/ethopy/core/experiment.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@

from ethopy.core.logger import Logger, experiment
from ethopy.utils.helper_functions import factorize, make_hash
from ethopy.utils.task_helper_funcs import format_params_print, get_parameters
from ethopy.utils.task_helper_funcs import (
expand_condition_rows,
format_params_print,
get_parameters,
)
from ethopy.utils.timer import Timer

log = logging.getLogger(__name__)
Expand Down Expand Up @@ -591,30 +595,11 @@ def log_conditions(
log.warning(f"Skipping {ctable}, Missing keys:{missing_keys}")
continue

# check if there is a primary key which is not hash and it is iterable
if core and hasattr(condition[core[0]], "__iter__"):
# TODO make a function for this and clarify it
# If any of the primary keys is iterable all the rest should be.
# The first element of the iterable will be matched with the first
# element of the rest of the keys
for idx, _ in enumerate(condition[core[0]]):
cond_key = {}
for k in fields:
if isinstance(condition[k], (int, float, str)):
cond_key[k] = condition[k]
else:
cond_key[k] = condition[k][idx]

self.logger.put(
table=ctable,
tuple=cond_key,
schema=schema,
priority=_priority,
)

else:
# A condition normally maps to one row, but a sequence-valued
# primary key expands it into several (see expand_condition_rows).
for row in expand_condition_rows(condition, fields, core):
self.logger.put(
table=ctable, tuple=condition, schema=schema, priority=_priority
table=ctable, tuple=row, schema=schema, priority=_priority
)

# Increment the priority for each subsequent table
Expand Down
57 changes: 36 additions & 21 deletions src/ethopy/core/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ def __init__(
self.logger = exp.logger if exp else None
self.position = Port()
self.position_tmst: int = 0
self.camera = None
self.cameras: Dict[str, Any] = {}
self.ports: List[Port] = []
self.pulse_rew: Dict[int, Dict] = {}
self.duration: Dict[int, float] = {}
Expand Down Expand Up @@ -119,32 +119,46 @@ def _initialize_hardware(self) -> None:
self._initialize_camera()

def _initialize_camera(self) -> None:
"""Initialize camera if configured in setup."""
"""Initialize each configured camera into ``self.cameras``, keyed by
``f"{video_aim}_{camera_idx}"`` so the camera->file mapping stays stable
across config edits.
"""
setup_cameras = self.logger.get(
schema="interface",
table="SetupConfiguration.Camera",
fields=["setup_conf_idx"],
)

if self.exp.setup_conf_idx in setup_cameras:
camera_params = self.logger.get(
schema="interface",
table="SetupConfiguration.Camera",
key=f"setup_conf_idx={self.exp.setup_conf_idx}",
as_dict=True,
)[0]
if self.exp.setup_conf_idx not in setup_cameras:
return

camera_rows = self.logger.get(
schema="interface",
table="SetupConfiguration.Camera",
key=f"setup_conf_idx={self.exp.setup_conf_idx}",
as_dict=True,
)

filename_base = (
f"{self.logger.trial_key['animal_id']}"
f"_{self.logger.trial_key['session']}"
)

for camera in camera_rows:
video_aim = camera.pop("video_aim")
# camera_idx is the /dev/videoN (V4L2) or libcamera index.
camera_num = camera.pop("camera_idx")
key = f"{video_aim}_{camera_num}"
camera_class = getattr(
import_module("ethopy.interfaces.Camera"), camera_params["discription"]
import_module("ethopy.interfaces.Camera"), camera["discription"]
)

self.camera = camera_class(
filename=f"{self.logger.trial_key['animal_id']}"
f"_{self.logger.trial_key['session']}",
self.cameras[key] = camera_class(
filename=f"{filename_base}_{key}",
logger=self.logger,
logger_timer=self.logger.logger_timer,
video_aim=camera_params.pop("video_aim"),
**camera_params,
video_aim=video_aim,
camera_num=camera_num,
**camera,
)

def give_liquid(self, port: int, duration: Optional[float] = 0) -> None:
Expand Down Expand Up @@ -213,11 +227,11 @@ def cleanup(self) -> None:
"""Clean up interface resources."""

def release(self) -> None:
"""Release hardware resources, especially camera."""
if self.camera:
log.info("Releasing camera resources.")
if self.camera.recording.is_set():
self.camera.stop_rec()
"""Release hardware resources. stop_rec is idempotent, so it is called
unconditionally rather than racing a recording.is_set() check."""
for aim, cam in self.cameras.items():
log.info("Releasing camera resources (video_aim=%s).", aim)
cam.stop_rec()

def load_calibration(self) -> None:
"""Load port calibration data from database.
Expand Down Expand Up @@ -441,6 +455,7 @@ class Camera(dj.Lookup, dj.Part):
iso : smallint
file_format : varchar(256)
video_aim : enum('eye','body','openfield')
device_id="" : varchar(256) # stable /dev/v4l/by-id symlink or serial substring; empty = use camera_idx
discription : varchar(256)
"""

Expand Down
9 changes: 6 additions & 3 deletions src/ethopy/core/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,9 +153,12 @@ def __init__(self, task: bool = False) -> None:
self.update_status.clear()

# source path is the local path that data are saved
self.source_path = local_conf.get("source_path")
# target path is the path that data will be moved after the session ends
self.target_path = local_conf.get("target_path")
# Both are joined to subfolders/filenames by string concatenation (here, in
# Writer and in Camera), so they must end with a separator; os.path.join
# with "" appends one only when it is missing.
self.source_path = os.path.join(local_conf.get("source_path"), "")
self.target_path = os.path.join(local_conf.get("target_path"), "")

# inserter_thread read the queue and insert the data in the database
self.thread_end, self.thread_lock = threading.Event(), threading.Lock()
Expand Down Expand Up @@ -1014,7 +1017,7 @@ def log_recording(self, rec_key: Dict, **kwargs) -> None:
key=self.trial_key,
fields=["rec_idx"],
)
rec_idx = 1 if not recs else max(recs) + 1
rec_idx = 1 if len(recs) == 0 else max(recs) + 1
self.log("Recording", data={**rec_key, "rec_idx": rec_idx},
schema="recording", **kwargs)

Expand Down
Loading
Loading