From 40313f3422f5cde384f90570e6e69fc3b68dd565 Mon Sep 17 00:00:00 2001 From: Alexandros Evangelou Date: Wed, 13 May 2026 12:15:14 +0300 Subject: [PATCH 01/10] feat: support openfield camera lookup in multi-camera setups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ethopy_package's Interface now exposes self.cameras (a dict keyed by f"{video_aim}_{camera_idx}") instead of a single self.camera. Adapt the openfield behavior to look up its DLC-feeding camera via the new API, and reject ambiguous configurations explicitly. - New _get_openfield_camera() helper filters cameras by the "openfield_" prefix. Returns the single match, raises ValueError with an actionable message if zero or more-than-one openfield-aim cameras are configured. - _initialize_dlc and get_corners now consume the helper's result instead of self.interface.camera.process_queue (which no longer exists). The old generic "Camera is not initialized" guard is removed — the helper's error covers it with better diagnostics. Requires ethopy_package's multi-camera-support branch to be merged first (or both merged together): this commit would crash against the previous single-camera API. Co-Authored-By: Claude Opus 4.7 --- behaviors/openfield.py | 41 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/behaviors/openfield.py b/behaviors/openfield.py index b1d9dac..de49420 100644 --- a/behaviors/openfield.py +++ b/behaviors/openfield.py @@ -138,17 +138,49 @@ def setup(self, exp) -> None: self._initialize_dlc() + def _get_openfield_camera(self): + """Return the openfield camera (the one DLC consumes frames from). + + DLC's corner detector and pose estimator both pull from a single + camera — the one with ``video_aim='openfield'``. Other cameras + configured for this setup (e.g. a passive ``'eye'`` camera) just + record to disk and must not be confused with the openfield camera. + + Cameras are keyed as ``f"{video_aim}_{camera_idx}"`` in + ``Interface.cameras`` (stable per-physical-camera identity across + sessions), so we filter by the ``openfield_`` prefix. + + Raises: + ValueError: if no camera with ``video_aim='openfield'`` is + configured, or if more than one is (DLC requires exactly one). + """ + cameras = self.interface.cameras + matches = {k: c for k, c in cameras.items() if k.startswith("openfield_")} + if not matches: + configured = list(cameras.keys()) or "" + raise ValueError( + "No camera with video_aim='openfield' is configured for this " + f"setup. Configured keys: {configured}. Add a row to " + "SetupConfiguration.Camera with video_aim='openfield' for the " + "camera DLC should track." + ) + if len(matches) > 1: + raise ValueError( + f"Multiple openfield cameras configured: {list(matches.keys())}. " + "DLC requires exactly one camera with video_aim='openfield'." + ) + return next(iter(matches.values())) + def _initialize_dlc(self) -> None: """Initialize the DeepLabCut (DLC) object for pose estimation.""" - if self.interface.camera is None: - raise ValueError("Camera is not initialized") + openfield_cam = self._get_openfield_camera() corners, affine_matrix = self.get_corners() dlc_body_path = self.logger.get(schema='interface', table='SetupConfigurationArena.Models', fields=['path'], key={'setup_conf_idx': self.exp.session_params['setup_conf_idx'], 'target': 'bodyparts'})[0] - self.dlc = DLCContinuousPoseEstimator(frame_queue=self.interface.camera.process_queue, + self.dlc = DLCContinuousPoseEstimator(frame_queue=openfield_cam.process_queue, model_path=dlc_body_path, logger=self.logger, shared_memory_conf=self.shm_conf, @@ -362,7 +394,8 @@ def get_corners(self): fields=['path'], key={'setup_conf_idx': self.exp.session_params['setup_conf_idx'], 'target': 'corners'})[0] - dlcCorners = DLCCornerDetector(frame_queue=self.interface.camera.process_queue, + openfield_cam = self._get_openfield_camera() + dlcCorners = DLCCornerDetector(frame_queue=openfield_cam.process_queue, model_path=dlc_corners_path, arena_size=self.arena_size, result=corners_dict, From dd486ff4c797318e9e041aa4bd4e9f114a1b0d9a Mon Sep 17 00:00:00 2001 From: Alexandros Evangelou Date: Tue, 23 Jun 2026 12:41:39 +0300 Subject: [PATCH 02/10] get incremental_punishment from curr_cond --- experiments/approach.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/experiments/approach.py b/experiments/approach.py index 179e9cc..bcef69e 100644 --- a/experiments/approach.py +++ b/experiments/approach.py @@ -176,7 +176,7 @@ def entry(self): self.beh.punish() super().entry() self.punish_period = self.curr_cond["punish_duration"] - if self.params.get("incremental_punishment"): + if self.curr_cond["incremental_punishment"]: self.punish_period *= self.beh.get_false_history() def run(self): From 9e5cecd9a4c4826427158250eb71d183e347ef5b Mon Sep 17 00:00:00 2001 From: Alexandros Evangelou Date: Tue, 23 Jun 2026 12:45:07 +0300 Subject: [PATCH 03/10] fix: correct OpenField class name to Openfield --- tasks/openfield_task.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tasks/openfield_task.py b/tasks/openfield_task.py index 009f3f6..5a16394 100644 --- a/tasks/openfield_task.py +++ b/tasks/openfield_task.py @@ -2,7 +2,7 @@ from scipy import interpolate from ethopy.core import logger -from ethopy.behaviors.openField import OpenField +from ethopy.behaviors.openfield import OpenField from ethopy.experiments.approach import Experiment from ethopy.stimuli.panda import Panda From bd256f0a113e5723a18c4a9ccc6303b6627c2f85 Mon Sep 17 00:00:00 2001 From: Alexandros Evangelou Date: Wed, 24 Jun 2026 16:24:52 +0300 Subject: [PATCH 04/10] fix: use openfield_panda instead of panda --- tasks/openfield_task.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tasks/openfield_task.py b/tasks/openfield_task.py index 5a16394..435e6d0 100644 --- a/tasks/openfield_task.py +++ b/tasks/openfield_task.py @@ -4,7 +4,7 @@ from ethopy.core import logger from ethopy.behaviors.openfield import OpenField from ethopy.experiments.approach import Experiment -from ethopy.stimuli.panda import Panda +from ethopy.stimuli.openfield_panda import Panda def interp(x): From 84c8e1b2fa183b0a35fa1105fc21052d4d07e2f9 Mon Sep 17 00:00:00 2001 From: Alexandros Evangelou Date: Wed, 24 Jun 2026 16:39:32 +0300 Subject: [PATCH 05/10] Improve get_cond scalar/array handling and docs Add a detailed docstring for Panda.get_cond explaining prefix stripping, per-object selection, and return shape. Replace the previous type-based scalar check (int/float) with np.ndim(v) == 0 so numpy scalar types are treated as scalars while sequences/arrays are indexed by idx. This makes get_cond more robust when fields use numpy types and clarifies its behavior and arguments. --- stimuli/openfield_panda.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/stimuli/openfield_panda.py b/stimuli/openfield_panda.py index 4cb7e7d..8d2265f 100644 --- a/stimuli/openfield_panda.py +++ b/stimuli/openfield_panda.py @@ -361,10 +361,28 @@ def create_movies(self): os.remove(f) def get_cond(self, cond_name, idx=0): + """Extract the cond_name prefix conditions. + + ``self.curr_cond`` is a flat dict holding the whole trial (lights, + background, and every object field prefixed with ``cond_name``, e.g. + ``"obj_"``). This returns just the fields for object ``idx``, with the + prefix stripped, so an ``Agent`` can read ``cond["mag"]`` instead of + ``cond["obj_mag"]``. + + For each matching field: + - a scalar value is shared by every object (returned as-is); + - a sequence (tuple/list/array) is treated as one value per object, + so object ``idx`` gets ``v[idx]``. + + Args: + cond_name: Field prefix to select and strip (e.g. ``"obj_"``). + idx: Index of the object whose values to pick from sequence fields. + + Returns: + A dict of de-prefixed field names mapped to this object's value. + """ return { - k.split(cond_name, 1)[1]: v - if type(v) is int or type(v) is float - else v[idx] + k.split(cond_name, 1)[1]: (v if np.ndim(v) == 0 else v[idx]) for k, v in self.curr_cond.items() if k.startswith(cond_name) } From dd77634a600c3c2d62f3907b149ef6dc45522c67 Mon Sep 17 00:00:00 2001 From: Alexandros Evangelou Date: Thu, 25 Jun 2026 14:13:52 +0300 Subject: [PATCH 06/10] update self.params to self.curr_cond or session_params accordingly --- experiments/approach.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/experiments/approach.py b/experiments/approach.py index bcef69e..8ba8e8e 100644 --- a/experiments/approach.py +++ b/experiments/approach.py @@ -196,13 +196,15 @@ def exit(self): class InterTrial(Experiment): def run(self): - if self.beh.is_licking() and self.params.get("noresponse_intertrial"): + if self.beh.is_licking() and self.curr_cond["noresponse_intertrial"]: self.state_timer.start() def next(self): if self.is_stopped(): return "Exit" - elif self.beh.is_sleep_time() and not self.beh.is_hydrated(self.params['min_reward']): + elif self.beh.is_sleep_time() and not self.beh.is_hydrated( + self.session_params["min_reward"] + ): return 'Hydrate' elif self.beh.is_sleep_time() or self.beh.is_hydrated(): return 'Offtime' From b02fd649e0b604817fb86b16a8d5453b73bdb5d1 Mon Sep 17 00:00:00 2001 From: Alexandros Evangelou Date: Wed, 8 Jul 2026 13:49:25 +0300 Subject: [PATCH 07/10] fix: moved the three attributes into __init__ These instance assignments shadow class attributes. Olfactory defined cond_tables/required_fields/default_key only as class attributes, and its __init__ called super().__init__() without restoring them, so every Olfactory() instance had them empty. The core stimuli (grating.py, dot.py, bar.py) all avoid this by setting them inside __init__, the plugin was the outlier. Empty cond_tables, the hash is computed over zero fields, every condition = make_hash({}) = vNiwwusfznFOq2zvDXcazA== --- stimuli/olfactory.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/stimuli/olfactory.py b/stimuli/olfactory.py index 687411a..277c8a2 100644 --- a/stimuli/olfactory.py +++ b/stimuli/olfactory.py @@ -36,12 +36,11 @@ class Channel(dj.Part): dutycycle : int # odor dutycycle """ - cond_tables = ['Olfactory', 'Olfactory.Channel'] - required_fields = ['odor_duration', 'odorant_id', 'delivery_port'] - default_key = {'dutycycle': 50} - def __init__(self): super().__init__() + self.cond_tables = ['Olfactory', 'Olfactory.Channel'] + self.required_fields = ['odor_duration', 'odorant_id', 'delivery_port'] + self.default_key = {'dutycycle': 50} self.fill_colors.set({'background': (0, 0, 0), 'start': (0.2, 0.2, 0.2), 'ready': (0.3, 0.3, 0.3), From e32f4a766f4efaf4b55807e34ee536ef893ce072 Mon Sep 17 00:00:00 2001 From: Alexandros Evangelou Date: Thu, 27 Aug 2026 15:32:42 +0300 Subject: [PATCH 08/10] do not import logger --- tasks/openfield_task.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tasks/openfield_task.py b/tasks/openfield_task.py index 435e6d0..223f8e7 100644 --- a/tasks/openfield_task.py +++ b/tasks/openfield_task.py @@ -1,7 +1,6 @@ import numpy as np from scipy import interpolate -from ethopy.core import logger from ethopy.behaviors.openfield import OpenField from ethopy.experiments.approach import Experiment from ethopy.stimuli.openfield_panda import Panda From 391fa50f95bdfa1509ec2ec725c4fc93221992e2 Mon Sep 17 00:00:00 2001 From: Alexandros Evangelou Date: Thu, 27 Aug 2026 15:33:14 +0300 Subject: [PATCH 09/10] get min_reward from the session_params --- experiments/approach.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/experiments/approach.py b/experiments/approach.py index 8ba8e8e..5ca04fc 100644 --- a/experiments/approach.py +++ b/experiments/approach.py @@ -202,9 +202,7 @@ def run(self): def next(self): if self.is_stopped(): return "Exit" - elif self.beh.is_sleep_time() and not self.beh.is_hydrated( - self.session_params["min_reward"] - ): + elif self.beh.is_sleep_time() and not self.beh.is_hydrated(self.params['min_reward']): return 'Hydrate' elif self.beh.is_sleep_time() or self.beh.is_hydrated(): return 'Offtime' From fd9244ffde0cb73046f8a650662147355702b870 Mon Sep 17 00:00:00 2001 From: Alexandros Evangelou Date: Tue, 1 Sep 2026 14:24:20 +0300 Subject: [PATCH 10/10] fix: imports and table definitions --- behaviors/openfield.py | 2 +- behaviors/vr_ball.py | 7 ++----- stimuli/olfactory.py | 2 +- stimuli/psycho_grating.py | 2 +- stimuli/vr_odors.py | 2 +- 5 files changed, 6 insertions(+), 9 deletions(-) diff --git a/behaviors/openfield.py b/behaviors/openfield.py index de49420..f86c850 100644 --- a/behaviors/openfield.py +++ b/behaviors/openfield.py @@ -456,7 +456,7 @@ class ConfigurationArena(dj.Manual): definition = """ # Camera information arena_idx : tinyint - -> behavior.Configuration + -> interface.Configuration --- size : int discription : varchar(256) diff --git a/behaviors/vr_ball.py b/behaviors/vr_ball.py index 98f6b77..abbe297 100644 --- a/behaviors/vr_ball.py +++ b/behaviors/vr_ball.py @@ -1,12 +1,9 @@ -from core.Behavior import * - - import datajoint as dj import numpy as np -from ethopy.core.behavior import Behavior +from ethopy.core.behavior import Behavior, BehCondition from ethopy.core.logger import behavior -from ethopy.interfaces.ball import Ball +from ethopy.interfaces.Ball import Ball diff --git a/stimuli/olfactory.py b/stimuli/olfactory.py index 277c8a2..ca8bd5c 100644 --- a/stimuli/olfactory.py +++ b/stimuli/olfactory.py @@ -1,7 +1,7 @@ import datajoint as dj from ethopy.core.logger import stimulus -from ethopy.core.stimulus import Stimulus +from ethopy.core.stimulus import Stimulus, StimCondition @stimulus.schema diff --git a/stimuli/psycho_grating.py b/stimuli/psycho_grating.py index 2ae9697..9c50675 100644 --- a/stimuli/psycho_grating.py +++ b/stimuli/psycho_grating.py @@ -2,7 +2,7 @@ import datajoint as dj from ethopy.core.logger import stimulus -from ethopy.core.stimulus import Stimulus +from ethopy.core.stimulus import Stimulus, StimCondition from ethopy.stimuli.psycho_presenter import Presenter from ethopy.utils.helper_functions import iterable diff --git a/stimuli/vr_odors.py b/stimuli/vr_odors.py index c5de9a4..0989aa3 100644 --- a/stimuli/vr_odors.py +++ b/stimuli/vr_odors.py @@ -3,7 +3,7 @@ from ethopy.stimuli.olfactory import Odorants, Olfactory from ethopy.core.logger import stimulus -from ethopy.core.stimulus import Stimulus +from ethopy.core.stimulus import Stimulus, StimCondition @stimulus.schema