Custom Camera Interaction
Camera Triggers
Every time a frame is captured from the box camera, if CAM_BOX_TRACKING_POSITION 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
Note
cam.areaN_is_triggered also works transparently for a BOX area whose shape
has been replaced by a CameraAreaBase (see
Custom Detection Area) — it checks the actual
polygon/circle, not just its bounding box.
Custom Detection Area
The 4 BOX areas are rectangles by default, positioned and resized from the
GUI. If one of them needs to track the animal through a non-rectangular
region (e.g. an L-shaped corridor between two zones, or a circular zone),
subclass CameraAreaBase to replace that area’s shape with a union of
polygons and/or circles defined in code.
Note
This replaces the shape of one of the 4 existing areas — it does not add a
5th area. Its status (ALLOWED/NOT_ALLOWED/TRIGGER/OFF) and its
threshold keep working exactly as before, still set from the GUI (SETTINGS
-> box area usage/threshold). Only its position controls are hidden from the
GUI, since the shape is no longer a plain rectangle — a “defined with code”
label is shown in their place instead, listing the area’s polygons/
circles coordinates (there’s no draggable position control for them).
Create a file inside your project’s code directory and define a class that
inherits from CameraAreaBase, setting area_index to the area it replaces
(1-4). The system detects it automatically. Create the area by adding polygons
and/or circles — any number of each (zero or more), in any combination.
from village.custom_classes.camera_area_base import CameraAreaBase
class LCorridor(CameraAreaBase):
name = "L_CORRIDOR"
area_index = 2 # replaces AREA2_BOX
# Polygons of [x, y] pixel vertices. Concave shapes are fine,
# and several polygons make a disjoint area (here: an L as two rectangles).
polygons = [
[[100, 50], [300, 50], [300, 100], [100, 100]], # top bar
[[100, 100], [150, 100], [150, 250], [100, 250]], # side
]
# Circles, each (x, y, radius). Combined with polygons in the same area.
circles = [(200, 300, 40)]
Note
Only one CameraAreaBase per area_index is used — if two subclasses target
the same area, only the first one found is registered and an error is
logged. An invalid area_index (not 1-4) is also rejected and logged.
Moving the shape at runtime
polygons/circles don’t have to stay fixed for the whole session — from
within a task, reach the registered instance through
self.custom_areas[area_index] and reassign them (e.g. to relocate a “home
zone” the animal must enter to start each trial). After changing either one,
call update_area() so the change is picked up on the next frame:
area = self.custom_areas[2]
area.circles = [(new_x, new_y, 40)]
area.update_area()
Note
The mask/bounding box/outline are cached and only rebuilt automatically when
the camera’s frame size changes — update_area() marks that cache stale so it
rebuilds from the new polygons/circles instead. Without it, the shape keeps
reacting to its old position until something else happens to trigger a rebuild.
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 |
|---|---|---|
|
cv2 (modifies |
disk and screen |
|
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)))
Passing data from the task
cam.items_to_draw is a plain dict the task can populate at any point to pass
coordinates, labels, or any other data (like "reward_zone" below).
# 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
Both methods draw and draw_preview receive these attributes on cam
(updated every frame):
Identity & state
cam.name—'BOX'or'CORRIDOR'.cam.task_is_running—Trueif a task is active,Falseif not.cam.view_detection— whether detection overlays are enabled in the GUI.cam.tracking— whether animal position tracking is active (BOX only).cam.color—Color.BLACKorColor.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— currentwrite_text()string; persists until changed.cam.items_to_draw— plaindictfor passing arbitrary data from the task (see above).
Detection areas
cam.number_of_areas— number of configurable areas (always 4).cam.areas— list of[x1, y1, x2, y2]pixel coordinates per area. For a BOX area overridden by aCameraAreaBase, this is the shape’s bounding box, not a real detection rectangle.cam.areas_active— bool list, whether each area is enabled (not OFF).cam.areas_allowed— bool list,Trueonly for ALLOWED areas.cam.areas_not_allowed— bool list,Trueonly for NOT_ALLOWED areas.cam.areas_trigger— bool list,Trueonly for TRIGGER areas.cam.custom_areas—dict[int, CameraAreaBase], BOX area index (1-4) to theCameraAreaBaseoverriding that area’s shape, if any (see Custom Detection 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 (-1if not detected).
Task access
self.task— the current task instance.