Skip to content

Multi camera support#12

Open
alexevag wants to merge 16 commits into
mainfrom
multi-camera-support
Open

Multi camera support#12
alexevag wants to merge 16 commits into
mainfrom
multi-camera-support

Conversation

@alexevag

@alexevag alexevag commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Description

Refactor camera handling to support multiple cameras per setup and makes video recording share the session's data paths. Also folds in a correctness fix and refactor for condition-row generation that the branch had accumulated(this bug was latent on the old Python/numpy but surfaced once the code was run on newer versions).

Camera:

  • Support multiple cameras per setup, keyed by video_aim + camera_idx, each recording to its own file. Interface.camera becomes Interface.cameras (a dict), and release() tears every camera down.
  • Add device_id so a camera can be addressed by a stable /dev/v4l/by-id identifier (survives reboots / re-plugging) instead of an enumeration index; falls back to camera_num when empty. New device_id column on SetupConfiguration.Camera.
  • Co-locate each video with the session's timestamp/DLC h5 files by deriving the Camera paths from the Logger's source_path/target_path (renamed from video_source_path/video_target_path); clear_local_videos moves only this camera's own files and no longer risks copying a file onto itself.
  • Make stop_rec idempotent and safe to call before startup finishes.
  • Pass the configured server.port to the MJPEG stream (previously hardcoded to 8000) and let WebCam run without a logger (write timestamps to a txt file).

DLC:

  • Replace print() debugging with throttled logging.

Conditions (log_conditions):

  • Expand a condition into rows when any primary key holds a sequence (not just
    the first key), correctly treating strings and numpy scalars as single values.

  • Validate that all sequence fields share one length and raise a clear error
    instead of a raw IndexError / silent truncation.

  • Extract the expansion into task_helper_funcs.expand_condition_rows for
    readability.

    These fixes were prompted by running on newer Python/numpy, which exposed
    latent bugs in the old logic: hasattr(x, "__iter__") treated strings as
    expandable, and the scalar test (int/float/str) misclassified numpy
    scalars (e.g. np.int64) as sequences and raised IndexError on indexing.
    The new code keys off explicit sequence types (list/tuple/np.ndarray)
    so strings and numpy scalars are correctly treated as single values.

Tests:

  • Add tests/test_task_helper_funcs.py covering expand_condition_rows: scalar passthrough, single- and parallel-sequence expansion, trigger on any primary key, non-primary sequences left whole, strings/numpy scalars treated as single
    values, and the unequal-length ValueError.
  • Fix the pre-existing test_is_hydrated failure (a test-setup bug: it assigned max_reward into params while is_hydrated reads session_params, and the fixture left session_params as None).

Type of change

Note: the source_path/target_path config keys replace
video_source_path/video_target_path. Old keys are silently ignored rather
than erroring — non-camera setups are unaffected since the Logger already uses
source_path/target_path.

Note: on a machine with no database reachable, pytest tests/ can hang at
import time (the logger opens a DB connection on import). Run the test files
individually, or on a host that can reach the database.

Checklist:

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

alexevag and others added 16 commits May 12, 2026 16:11
Interface previously held a single self.camera. Replace with self.cameras
dict keyed by f"{video_aim}_{camera_idx}", so two cameras can coexist in
the same setup (e.g. one openfield cam feeding DLC plus one passive eye
cam recording to disk). Always including camera_idx in the key keeps the
mapping "physical camera -> file" stable across config edits.

- Interface._initialize_camera loops over all SetupConfiguration.Camera
  rows for the setup_conf_idx, builds Camera instances with per-camera
  filenames (f"{animal}_{session}_{aim}_{idx}").
- Interface.release iterates over self.cameras.
- WebCam and PiCamera now accept camera_num (defaults to 0) and use it
  for cv2.VideoCapture / Picamera2 device selection instead of the
  hard-coded 0.
- Camera.__init__ derives the h5 timestamp filename from self.filename
  (which carries the per-camera disambiguator), so two cameras in the
  same session no longer overwrite each other's h5.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous release() guarded each stop_rec call with
`if cam.recording.is_set()`. With multi-camera setups that race fires:
between mp.Process.start() and the child reaching `recording.set()`, a
spawned-but-not-yet-recording subprocess holds resources (open file
handles, FFmpegWriter, possibly a /dev/videoN FD) but is_set() returns
False, so stop_rec is skipped and the subprocess leaks.

- Camera.stop_rec is now idempotent: returns early if camera_process is
  None, swallows the rare ValueError from close() racing with terminate,
  joins twice (timeout=30 + timeout=5 after terminate), and clears
  self.camera_process so a second call is a no-op.
- Interface.release calls stop_rec unconditionally — the stop event is
  sufficient to signal shutdown regardless of subprocess lifecycle step.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two review-flagged issues, both now visible because of multi-camera:

1. WebCam.__init__ previously opened /dev/videoN via cv2.VideoCapture
   in the parent, released it, then forked a child that re-opened the
   same device. V4L2 drivers don't always tear down state synchronously
   on close(), so two cameras initialized back-to-back could race —
   second child opens before kernel finishes cleaning up first parent.
   Replaced with os.path.exists("/dev/videoN") — race-free, still
   catches the common config errors (missing device, wrong camera_num).
   Permission / device-busy errors surface from the child's open in
   recording_init() as before.

2. The h5 timestamp Recording row was logged with source_path/
   target_path pointing at the video directory, but the file actually
   lives under logger.source_path + "Recordings/<animal>_<session>/"
   (per Logger.createDataset). With per-camera h5 filenames now in
   play, the mismatch is more impactful — downstream consumers that
   resolve h5 paths via Recording rows would look in the wrong place.
   Mirror createDataset's path logic in Camera.__init__ so the row
   matches reality.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
cameras producing multiple source directories to drain:

- copy_file: routine "transferred" and "deleted" logs demoted from info to debug (they fire per file; info-level for two cameras with hundreds of frame files each was overwhelming).
- clear_local_videos: explicit error log when source/target paths are missing, with an actionable "Create it with: mkdir -p ..." hint; warning if no files were found to transfer; file count visible in the transfer start log; warning if the source folder isn't empty after transfer (indicates a partial failure that previously went silent).
Add stable camera addressing and robustness fixes.

- schema: add device_id (varchar) to SetupConfiguration with empty default, allowing cameras to be addressed by /dev/v4l/by-id symlink or a serial substring (falls back to camera index when empty).
- WebCam: accept and store device_id
- Replace simple /dev/videoN stat check with _resolve_device() that: returns camera_num when device_id is empty, accepts an existing path, or matches a single by-id index0 symlink under /dev/v4l/by-id. Sets self.device before spawning child to avoid race conditions.
- Use self.device when opening cv2.VideoCapture and pin capture options (FOURCC=YUYV, CONVERT_RGB, BUFFERSIZE=1) to ensure consistent frames and reduce buffering/staleness.
- Ensure captured grayscale frames are cast to uint8 and improve error/warning messages (fix typo, warn when property set() is rejected).

These changes improve stable camera selection across reboots/USB re-plugs and make capture initialization more robust and predictable.
Clean up docstrings and comments, reduce noisy prints, and add throttled logging and robustness fixes across interface, Camera, and DLC modules.

- src/ethopy/core/interface.py: Simplified and clarified docstrings for camera initialization and release, emphasize stable camera->file mapping and explain unconditional stop_rec call to avoid races.
- src/ethopy/interfaces/Camera.py: Clarified comments around camera naming and device resolution; made per-camera timestamp filename behavior explicit; added _last_frame_err_log to throttle per-frame read-error logs; simplified stop_rec and process-close handling; improved device-resolution error messages and camera property comments; ensured consistent behavior for capture format and buffer settings.
- src/ethopy/interfaces/dlc.py: Added logging (logger import and module logger); convert frames to float32 before passing to DLCLive; replace prints with structured logging (debug/warning/exception); introduce a _throttled_log helper to avoid spamming logs in tight loops; add debug/warning checks around frame transfer delays, corner detection, and process lifecycle; handle case of no confident corner frames gracefully; use debug for shared memory unlink messages.

Overall these changes improve observability, reduce console spam, make DLC inputs type-consistent, and clarify camera/device handling to make startup and shutdown more robust.
Replace the truthiness check 'if not recs' with 'if len(recs) == 0' when computing rec_idx in Logger. This avoids ambiguous truth-value errors for array-like objects (e.g., numpy arrays) and ensures rec_idx is set to 1 for empty collections, otherwise max(recs) + 1.
write the .mp4 into the same Recordings/{animal}_{session}/ folder as the timestamp/DLC h5 (drops the now-unused video_source_path keys), and make clear_local_videos move only this camera's own files without rmdir, so it can't clobber the Writer-owned h5 files or other cameras' videos.
get_table_keys can return a scalar primary key first (e.g. response_port before response_loc_x), so the old core[0] check missed the iterable and inserted tuples into scalar columns. Scan all non-hash primary keys for a list/tuple instead.

It used hasattr(..., "__iter__"), which treats strings as expandable, and the inner split used a negative scalar test (int/float/str) that misclassified numpy scalars as sequences and crashed on indexing.
…amps

The MJPEG stream ignored server.port: HTTPServerThread was constructed
without serve_port, so it always bound 8000 even when a different port was
configured. Pass self.serve_port through.

WebCam wrote timestamps only via self.dataset, which setup() leaves None on
the txt path (logger=None), so a logger-less WebCam crashed with
AttributeError in write_frame and at the end of rec(). Guard both o
tmst_type, mirroring PiCamera.
The inline expansion in log_conditions mixed three concerns (detect a
sequence primary key, validate lengths, split into rows) in a nested loop
that was hard to follow. Move it to task_helper_funcs.expand_condition_rows
with a docstring explaining the semantics, so the call site is just "build
the rows, insert each."

The helper also validates that all sequence fields share one length and
raises a clear error instead of the previous raw IndexError / silent
truncation on mismatched lengths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Unit tests for the condition-row expansion helper: scalar passthrough,
single- and parallel-sequence expansion, trigger on any primary key,
non-primary sequences left whole, strings/numpy scalars treated as single
values, and the unequal-length ValueError.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
test_is_hydrated assigns into behavior.session_params, but the fixture built
a Behavior() without calling setup(), leaving session_params as None (its
__init__ default). Initialize it to an empty dict in the fixture, matching
how the other mocked attributes are set up.
…_rows

Add cases for an empty sequence (zero rows) and a tuple secondary field
(split element-by-element). Both lock in the existing behavior, which a
differential check confirmed is identical to the pre-refactor inline logic;
the tuple case also documents that it intentionally differs from factorize.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant