Custom Camera Interaction

Camera Triggers

Every time a frame is captured from the box camera, if CAM_BOX_TRACKING is enabled and a task is running, the (x, y) position of the animal inside the operant box is computed and the trigger method of CameraTriggerBase is called.

The default implementation checks each of the four configurable areas (the rectangles defined in the GUI). For any area set to TRIGGER status, if the animal is detected inside it, the text “Area X triggered” is overlaid on the camera feed.

Note

Active areas can be set to ALLOWED, NOT_ALLOWED, or TRIGGER.

You can override this behavior by creating a custom class in your project. Create a file named camera_trigger inside your project’s code directory and define a class named CameraTrigger that inherits from CameraTriggerBase. If the system detects a class inheriting from CameraTriggerBase in your project, it will use your custom class instead of the default base class.

In the following example, a subclass is created that writes a text when the subject is detected in Area1, executes direct function number 2 when detected in Area2, and triggers function number 6 when detected within a circular region at the center of the image.

from village.custom_classes.camera_trigger_base import CameraTriggerBase

class CameraTrigger(CameraTriggerBase):
    """Implement specific actions when an animal enters
    a detection area or reaches a specific position in the camera feed.

    You have access to self.task, so any variable or function defined in the
    task can be used to specify the actions to perform.
    """

    def __init__(self) -> None:
        super().__init__()

    def trigger(self, cam: Camera) -> None:
        """Evaluates camera triggers and performs corresponding actions.

        Called on every frame to check whether the subject has entered any
        defined area or reached a specific position in the camera feed.

        Available area triggers (return True if the area is marked as a TRIGGER area
        and a subject is detected within it):
        - cam.area1_is_triggered
        - cam.area2_is_triggered
        - cam.area3_is_triggered
        - cam.area4_is_triggered

        Available position properties:
        - cam.x_position, cam.y_position (subject position in pixels)
        - cam.width, cam.height (camera feed dimensions in pixels)

        Other:
        - cam.write_text("text") to overlay text on the camera feed

        Args:
            cam (Camera): The camera instance providing trigger status
            and position data.
        """

        # Check if the animal is in some of the defined areas
        if cam.area1_is_triggered:
            cam.write_text("Area 1 triggered") # write an annotation text to the cam

        if cam.area2_is_triggered:
            self.task.execute_function(2) # execute the direct function number 2

        # Check if the animal is within a 50-pixel radius of the center
        distance = ((cam.x_position - cam.width / 2) ** 2 +
                    (cam.y_position - cam.height / 2) ** 2) ** 0.5
        if distance < 50:
            self.task.execute_function(6) # execute the direct function number 6

Custom Camera Drawing

Annotating the frame from a task

The simplest way to overlay text on the camera feed is to call write_text() from the task. The text is rendered on every subsequent frame at a fixed position in the status bar and stays visible until it is changed or cleared.

from village.devices.camera import cam_box

cam_box.write_text("reward delivered")   # persists across frames until changed
cam_box.write_text("")                   # clear

The annotation is also saved frame-by-frame in the session CSV alongside the position data, so it is useful for marking task events for post-hoc analysis.

For anything beyond plain text — shapes, coloured overlays, position-dependent graphics — use the CameraDrawBase subclass described below.


Drawing with CameraDrawBase

CameraDrawBase exposes two methods that are called automatically on every frame:

Method

Renderer

Destination

draw(cam)

cv2 (modifies cam.frame)

disk and screen

draw_preview(cam, painter)

QPainter (widget overlay)

screen only

The default implementation of draw writes the status bar texts and pixel counts for both cameras, and for the CORRIDOR camera also draws the thresholded detection mask and area rectangles (if VIEW_DETECTION is active). The default draw_preview draws those same overlays for the BOX camera via QPainter so they appear on screen but are not encoded into the video file.

Create a file named camera_draw inside your project’s code directory and define a class named CameraDraw that inherits from CameraDrawBase. The system detects it automatically and uses it instead of the base class.

Overriding draw — runs on every frame, changes are saved to disk:

from village.custom_classes.camera_draw_base import CameraDrawBase
import cv2

class CameraDraw(CameraDrawBase):

    def __init__(self) -> None:
        super().__init__()

    def draw(self, cam) -> None:
        super().draw(cam)   # keep default status bar and detection overlays

        # Add a red circle at the animal's position (BOX only, task running)
        if cam.name == "BOX" and cam.task_is_running and cam.x_position != -1:
            cv2.circle(
                cam.frame,
                (cam.x_position, cam.y_position),
                radius=20,
                color=(0, 0, 255),   # BGR
                thickness=2,
            )

Overriding draw_preview — runs at preview framerate, screen only:

from PyQt5.QtCore import QRect
from PyQt5.QtGui import QBrush, QColor, QPainter

    def draw_preview(self, cam, painter) -> None:
        super().draw_preview(cam, painter)   # keep default detection overlays

        # Highlight the reward zone on screen (never saved to video)
        zone = cam.items_to_draw.get("reward_zone")
        if zone and cam.name == "BOX":
            device = painter.device()
            sx = device.width() / cam.width
            sy = device.height() / cam.height
            x, y, w, h = zone
            painter.setPen(QColor(0, 255, 0))
            painter.setBrush(QBrush())
            painter.drawRect(QRect(int(x*sx), int(y*sy), int(w*sx), int(h*sy)))

Both methods receive these attributes on cam (updated every frame):

Identity & state

  • cam.name'BOX' or 'CORRIDOR'.

  • cam.task_is_runningTrue if a task is active, False if not.

  • cam.view_detection — whether detection overlays are enabled in the GUI.

  • cam.tracking — whether animal position tracking is active (BOX only).

  • cam.colorColor.BLACK or Color.WHITE: which pixels to detect.

Frame data

  • cam.frame — current frame as a BGR numpy array (read/write, cv2 only).

  • cam.width, cam.height — frame dimensions in pixels.

  • cam.frame_number — frames captured since recording started.

  • cam.timing — milliseconds elapsed since recording started.

Session info

  • cam.filename — base name of the current video file (empty if not recording).

  • cam.trial — current trial number (0 if no task running or CORRIDOR camera).

  • cam.annotation — current write_text() string; persists until changed.

  • cam.items_to_draw — plain dict for passing arbitrary data from the task (see below).

Detection areas

  • cam.number_of_areas — number of configurable areas (always 4).

  • cam.areas — list of [x1, y1, x2, y2] pixel coordinates per area.

  • cam.areas_active — bool list, whether each area is enabled.

  • cam.areas_allowed — bool list, whether animals are allowed in each area.

Detection results

  • cam.masks — grayscale numpy arrays with the thresholded mask per area.

  • cam.counts — detected pixel count per area.

  • cam.x_position, cam.y_position — tracked animal position in pixels (-1 if not detected).

Task access

  • self.task — the current task instance.

Passing data from the task via items_to_draw

cam.items_to_draw is a plain dictionary. The task can populate it at any point to pass coordinates, labels, or any other data to the drawing methods without coupling the task directly to this class:

# Inside the task, at any point:
from village.devices.camera import cam_box

cam_box.items_to_draw["reward_zone"] = (200, 150, 80, 80)   # x, y, w, h
cam_box.items_to_draw["reward_zone"] = None                  # remove