Vision — Cameras¶
Camera abstraction layer for the vision module. CameraFactory opens any supported camera
behind a common interface; ImageHandler turns a camera into a callback-driven ROS 2 stream.
All cameras implement AbstractCam (start / get_frame / close); depth-capable cameras add
DepthCam (get_depth_frame / get_distance).
See also: Algorithms · ROS 2 nodes · Vision overview.
Class hierarchy¶
classDiagram
class CameraFactory {
<<singleton>>
-_builders Dict~str,Type~
+register(key, builder)$
+from_source(source, config, node)$ AbstractCam
}
class AbstractCam {
<<abstract>>
-_name str
-_is_running bool
+name str
+is_running bool
+start()*
+get_frame()* Optional~ndarray~
+close()*
}
class DepthCam {
<<abstract>>
+get_depth_frame()* Optional~ndarray~
+get_distance(u, v)* Optional~float~
}
class OpenCVCam {
-_cap VideoCapture
-_config OpenCVConfig
+actual_settings dict
+start()
+get_frame() Optional~ndarray~
+close()
}
class RealsenseCam {
-_pipeline Pipeline
-_align Align
-_depth_scale float
-_use_ros_topics bool
+start()
+get_frame(wait_for_new, timeout) Optional~ndarray~
+get_depth_frame(wait_for_new, timeout) Optional~ndarray~
+get_distance(u, v) Optional~float~
+close()
}
class OakdCam {
-_pipeline Pipeline
-_config OakDConfig
+start()
+get_frame() Optional~ndarray~
+get_depth_frame() Optional~ndarray~
+get_distance(u, v) Optional~float~
+close()
}
class ROSCam {
-_node Node
-_config ROSConfig
-_bridge CvBridge
-_frame Optional~ndarray~
-_sub Subscription
+start()
+get_frame(wait_for_new, timeout) Optional~ndarray~
+close()
}
class ROSDepthCam {
-_node Node
-_config ROSDepthConfig
-_color_cam ROSCam
-_depth Optional~ndarray~
+start()
+get_frame(wait_for_new, timeout) Optional~ndarray~
+get_depth_frame(wait_for_new, timeout) Optional~ndarray~
+get_distance(u, v, color_shape) Optional~float~
+close()
}
class FileImageCam {
-_config FileImageConfig
-_frame Optional~ndarray~
+start()
+get_frame() Optional~ndarray~
+reload() bool
+close()
}
class C920Cam {
-_config C920Config
+start()
+get_frame() Optional~ndarray~
+close()
}
class IMX219Cam {
-_config IMX219Config
+start()
+get_frame() Optional~ndarray~
+close()
}
class T265Cam {
-_pipeline Pipeline
-_stereo StereoSGBM
-_config T265Config
+start()
+get_frame() Optional~ndarray~
+get_depth_frame() Optional~ndarray~
+get_distance(u, v) Optional~float~
+get_stereo_frames() tuple
+get_pose() T265Pose
+get_rectified_frames() tuple
+close()
}
class ImageHandler {
+node Node
+image_source str
+img ndarray
+camera AbstractCam
+poll_interval float
-_frame_timeout float
-cam_timer Timer
+open()
+close()
+run()
+take_photo(timeout, wait_for_new) Any
+process()
+cleanup()
}
AbstractCam <|-- DepthCam
AbstractCam <|-- OpenCVCam
AbstractCam <|-- ROSCam
AbstractCam <|-- FileImageCam
AbstractCam <|-- C920Cam
AbstractCam <|-- IMX219Cam
DepthCam <|-- RealsenseCam
DepthCam <|-- OakdCam
DepthCam <|-- ROSDepthCam
DepthCam <|-- T265Cam
ROSDepthCam *-- ROSCam
CameraFactory ..> AbstractCam : creates
ImageHandler o-- AbstractCam
ImageHandler ..> CameraFactory : uses
CameraFactory¶
Creates camera instances from a source identifier, auto-detecting the source type.
CameraFactory.from_source(source: str, *, config: CameraConfig = None, node: Node = None) -> AbstractCam
CameraFactory.register(key: str, builder: Type[AbstractCam])
Registered sources:
| Key | Camera class | Description |
|---|---|---|
webcam |
OpenCVCam |
Generic USB webcams |
opencv |
OpenCVCam |
Alias for webcam |
realsense |
RealsenseCam |
Intel RealSense D4xx (color + depth via pyrealsense2) |
t265 |
T265Cam |
Intel RealSense T265 (fisheye + stereo depth + 6DOF pose) |
oakd |
OakdCam |
Luxonis OAK-D cameras |
c920 |
C920Cam |
Logitech C920/C920e |
imx219 |
IMX219Cam |
Raspberry Pi Camera v2 (Jetson) |
ros |
ROSCam |
ROS 2 image topics |
ros_depth |
ROSDepthCam |
ROS 2 color + depth image topics |
file |
FileImageCam |
Static image files |
Auto-detection: a file path resolves to FileImageCam, a source starting with / to ROSCam,
and any registered key to its camera.
from nectar.vision import CameraFactory, OpenCVConfig
camera = CameraFactory.from_source("webcam")
camera = CameraFactory.from_source("webcam", config=OpenCVConfig(width=1280, height=720, fps=30))
camera = CameraFactory.from_source("/camera/image_raw", node=node) # ROS topic
camera = CameraFactory.from_source("/path/to/image.jpg") # file
ImageHandler¶
Timer-based camera interface backed by an internal ROS 2 Node (registered with the SDK runtime
executor — see nectar.runtime). Invokes a processing callback on every frame
and optionally renders to an OpenCV window.
ImageHandler(
image_source: str,
image_processing_callback: Callable = None,
show_result: str = None, # OpenCV window name
*,
config: CameraConfig = None,
camera: AbstractCam = None, # pre-configured camera
poll_interval: float = 0.01, # timer period (seconds)
frame_timeout: float = 0.1, # frame wait timeout (async)
executor: Executor = None, # defaults to nectar.runtime executor
)
| Method | Purpose |
|---|---|
run() |
Start continuous frame capture with the timer |
open() |
Manual camera initialization |
close() |
Stop the camera |
take_photo(timeout_sec=1.0, wait_for_new=True) |
Single-shot capture |
cleanup() |
Release the camera, destroy the timer, unregister the internal node |
import nectar
from nectar.vision import ImageHandler, OpenCVConfig
nectar.init()
handler = ImageHandler(
image_source="webcam",
config=OpenCVConfig(width=1280, height=720),
image_processing_callback=lambda frame: detector.detect(frame),
show_result="Camera View",
)
handler.run()
nectar.spin()
AbstractCam and DepthCam¶
AbstractCam is the base class for every driver; DepthCam extends it for RGB-D cameras.
class AbstractCam(ABC):
name: str # camera identifier
is_running: bool # capture status
def start() -> None # initialize camera
def get_frame() -> Optional[ndarray] # capture BGR frame
def close() -> None # release resources
class DepthCam(AbstractCam):
def get_depth_frame() -> Optional[ndarray] # depth in meters (float32)
def get_distance(u: int, v: int) -> Optional[float] # distance at pixel
Configuration classes¶
Each driver takes a dataclass config subclassing CameraConfig.
classDiagram
class CameraConfig {
<<dataclass>>
+name str
}
class OpenCVConfig {
<<dataclass>>
+device_index int
+width Optional~int~
+height Optional~int~
+fps Optional~int~
+fourcc Optional~str~
+autofocus Optional~bool~
+focus Optional~int~
+buffer_size Optional~int~
+threaded bool
}
class RealSenseConfig {
<<dataclass>>
+color_res Tuple~int,int~
+depth_res Tuple~int,int~
+fps int
+align_to_color bool
+enable_depth bool
+use_ros_topics bool
+color_topic str
+depth_topic str
+color_compressed bool
+depth_compressed bool
}
class OakDConfig {
<<dataclass>>
+cam_num int
+enable_depth bool
}
class ROSConfig {
<<dataclass>>
+topic str
+compressed bool
+reliability QoSReliability
+durability QoSDurability
+history_depth int
+encoding str
}
class ROSDepthConfig {
<<dataclass>>
+depth_topic str
+depth_compressed bool
+depth_encoding str
+enable_depth bool
}
class C920Config {
<<dataclass>>
+profile int
+fallback_device_index int
}
class IMX219Config {
<<dataclass>>
+sensor_id int
+width int
+height int
+fps int
+flip int
}
class FileImageConfig {
<<dataclass>>
+path str
}
class T265Config {
<<dataclass>>
+enable_pose bool
+enable_depth bool
+stereo_fov_deg float
+stereo_height_px int
+num_disparities int
+block_size int
+use_ros_topics bool
+fisheye1_topic str
+fisheye2_topic str
+pose_topic str
}
CameraConfig <|-- T265Config
CameraConfig <|-- OpenCVConfig
CameraConfig <|-- RealSenseConfig
CameraConfig <|-- OakDConfig
CameraConfig <|-- ROSConfig
CameraConfig <|-- C920Config
CameraConfig <|-- IMX219Config
CameraConfig <|-- FileImageConfig
ROSConfig <|-- ROSDepthConfig
T265 tracking camera¶
The T265 has two 848x800 global-shutter fisheye cameras (30 Hz), a BMI055 IMU (gyro 200 Hz, accel 62 Hz), and an onboard Movidius Myriad 2 VPU running V-SLAM that outputs 6DOF pose at 200 Hz.
It is not a depth camera. T265Cam computes stereo depth on the host from the fisheye pair using
OpenCV's StereoSGBM with Kannala-Brandt fisheye undistortion, following the
librealsense t265_stereo.py
reference. Effective depth range is ~0.3-3 m given the 64 mm baseline.
Access modes:
| Mode | use_ros_topics |
When to use |
|---|---|---|
| Direct | False |
T265 dedicated to the SDK. Uses the pyrealsense2 callback-based pipeline. |
| ROS topics | True |
realsense2_camera already running. Subscribes to fisheye + CameraInfo + pose topics. |
Warning: when
realsense2_cameraholds the USB device, direct mode fails. Use ROS topic mode to share the camera.
The stereo depth pipeline runs once on start() (Kannala-Brandt intrinsics/extrinsics from
pyrealsense2 profiles or CameraInfo, undistort/rectify maps, projection/reprojection matrices),
then per frame in get_depth_frame() (remap the fisheye pair, run cv2.StereoSGBM, convert
disparity to depth via depth = focal * baseline / disparity).
T265Config:
| Parameter | Type | Default | Description |
|---|---|---|---|
enable_pose |
bool | True |
Subscribe to / capture 6DOF pose stream |
enable_depth |
bool | True |
Compute stereo depth from fisheye pair |
stereo_fov_deg |
float | 90.0 |
Output FOV for rectified stereo images |
stereo_height_px |
int | 300 |
Output height in pixels (width = height + max_disp) |
num_disparities |
int | 96 |
StereoSGBM max disparity search range (divisible by 16) |
block_size |
int | 16 |
StereoSGBM block size |
use_ros_topics |
bool | False |
True: subscribe to ROS topics. False: direct pyrealsense2. |
fisheye1_topic |
str | /camera/fisheye1/image_raw |
Left fisheye topic (ROS mode) |
fisheye2_topic |
str | /camera/fisheye2/image_raw |
Right fisheye topic (ROS mode) |
pose_topic |
str | /camera/pose/sample |
Pose topic (ROS mode, Odometry or PoseStamped) |
T265Pose (returned by get_pose()):
| Field | Type | Description |
|---|---|---|
translation |
ndarray (3,) | Position [x, y, z] in meters |
rotation |
ndarray (4,) | Quaternion [x, y, z, w] |
velocity |
ndarray (3,) | Linear velocity [vx, vy, vz] m/s |
angular_velocity |
ndarray (3,) | Angular velocity [wx, wy, wz] rad/s |
tracker_confidence |
int | 0 (failed) to 3 (high) |
timestamp |
float | Host-synchronized timestamp in seconds |
from nectar.vision import T265Cam, T265Config
# Direct mode (T265 dedicated to the SDK)
cam = T265Cam(T265Config(enable_depth=True, enable_pose=True))
cam.start()
frame = cam.get_frame() # left fisheye (BGR, 800x848x3)
depth = cam.get_depth_frame() # stereo depth (float32, meters, 300x300)
pose = cam.get_pose() # T265Pose
left, right = cam.get_stereo_frames()
# ROS topic mode (realsense2_camera already running)
cam = T265Cam(T265Config(use_ros_topics=True), node=node)
cam.start()
V-SLAM options (direct mode only, set on the pose sensor before pipeline.start()):
| Option | pyrealsense2 name | Default | Description |
|---|---|---|---|
| Mapping | rs.option.enable_mapping |
1 (on) | Internal feature map. Reduces drift via small loop closures. |
| Relocalization | rs.option.enable_relocalization |
1 (on) | Reconnects to map after large drift. Can cause large pose jumps. |
| Pose jumping | rs.option.enable_pose_jumping |
1 (on) | Allows discontinuous translation corrections. |
| Map preservation | rs.option.enable_map_preservation |
0 (off) | Preserve map across stop/start cycles. |
Warning: for drone flight with the ArduPilot EKF, disable relocalization and pose jumping (the EKF cannot handle discontinuous position teleports); keep mapping enabled. See PR #4321, realsense-ros #779.
Note — T265 and RealSense versions: RealSense support uses two independent stacks:
- System / ROS (
make realsense) — builds librealsense and realsense-ros from source. Override versions withLIBREALSENSE_VERSIONandREALSENSE_ROS_TAG(defaults per ROS distro are inscripts/lib/config.sh). For the discontinued T265 on Humble:LIBREALSENSE_VERSION=v2.53.1 REALSENSE_ROS_TAG=4.51.1 make realsense. That build also installs a matching pyrealsense2 under/usr/local/lib(added toPYTHONPATH).- Pip extra (
nectar-sdk[realsense]) — installs PyPI pyrealsense2 ≥2.55 for D4xx direct mode (RealsenseCam). PyPI dropped T265 (TM2) after 2.53.x; the pip extra does not target the T265.For T265: use ROS topic mode (
T265Config(use_ros_topics=True)) with the T265make realsenseversions above, or direct mode with pyrealsense2 from a v2.53.1 source build — not the pip extra alone. See RealSense setup.
Camera calibration¶
Intrinsic calibration computes the 3×3 camera matrix (fx, fy, cx, cy) and distortion coefficients
(k1, k2, p1, p2, k3). A single CameraCalibration node handles both board patterns and writes the
shared output files (camera_matrix.txt, camera_distortion.txt) loaded via load_calibration().
| Pattern | pattern value |
Notes |
|---|---|---|
| ChArUco (default, recommended) | charuco |
Subpixel chessboard accuracy plus ArUco robustness to occlusion and partial views, so frames at the image edges still contribute |
| Chessboard | chessboard |
Plain printed chessboard, fully visible per frame, with cornerSubPix refinement |
Capture modes:
| Mode | mode value |
Behavior |
|---|---|---|
| Auto (default) | auto |
Accepts a view automatically when the board is detected with at least min_corners_per_frame corners and auto_interval seconds have passed. Stops after target_views. |
| Manual | manual |
Key-driven from the preview window: c capture, u undo last, r reset all, Enter finish + calibrate, q quit. Requires a GUI window; falls back to auto when none is available. |
The preview window is optional (show_preview), so the node runs headless (e.g. on a Jetson over
SSH). Progress is logged to the terminal in both modes.
Print the board (ChArUco): any board PDF from
carlosmccosta/charuco_detector
at 100% scale. The defaults match the A4, 5×7, 40 mm square, 30 mm marker, DICT_4X4_1000 PDF. Glue
the print to a rigid flat surface. Printers rescale by ~0.5-2%, so measure N squares with a caliper
after printing and compute the real square size; the marker length scales by the same factor
(30/40 = 0.75 for this board).
ros2 run nectar calibration.py # auto ChArUco (default)
ros2 run nectar calibration.py --ros-args -p width:=1920 -p height:=1080 # match production resolution
ros2 run nectar calibration.py --ros-args \
-p pattern:=chessboard -p mode:=manual \
-p chessboard_cols:=9 -p chessboard_rows:=7 -p square_length:=0.025 # manual chessboard
ros2 run nectar calibration.py --ros-args -p show_preview:=false # headless
Calibrate at the same resolution you will use in production (intrinsics are resolution-specific). Move the board to fill different regions of the image and use strong tilts (pitch/yaw ±30°), especially near the edges where distortion is strongest.
Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
pattern |
string | charuco |
Board pattern: charuco or chessboard |
mode |
string | auto |
Capture mode: auto or manual |
image_source |
string | webcam |
Camera source (any CameraFactory value) |
device_index |
int | 0 |
OpenCV VideoCapture index (only for webcam/opencv) |
width |
int | 0 |
Requested capture width; 0 keeps the camera default |
height |
int | 0 |
Requested capture height; 0 keeps the camera default |
chessboard_cols |
int | 9 |
Inner corners in X (chessboard only) |
chessboard_rows |
int | 7 |
Inner corners in Y (chessboard only) |
squares_x |
int | 5 |
Board squares in X (charuco only) |
squares_y |
int | 7 |
Board squares in Y (charuco only) |
square_length |
float | 0.040 |
Measured square side in meters (caliper after printing) |
marker_length |
float | 0.030 |
Measured marker side in meters (charuco; scale with square_length) |
aruco_dict |
string | DICT_4X4_1000 |
Predefined dictionary name (charuco); shorthand 4X4_1000 accepted |
min_corners_per_frame |
int | 6 |
Minimum corners required to accept a frame |
target_views |
int | 20 |
Accepted views to collect before auto calibration |
auto_interval |
float | 0.75 |
Seconds between automatic captures (auto mode) |
show_preview |
bool | true |
Live preview window; auto-disabled when no GUI is available |
save_dataset |
bool | true |
Also write accepted frames to dataset/ |
output_dir |
string | "" |
Output directory; empty resolves to the calibration package dir so load_calibration() finds it |
Aim for reprojection_error < 0.5 px (logged after calibration). Values above 1.0 px trigger a
warning and usually mean a flexed board, motion blur, or poor pose coverage.
from nectar.vision.camera import CameraCalibration
matrix, distortion = CameraCalibration.load_calibration()
Image geometry (ImageCalculus)¶
Pixel-to-world coordinate transformations for downward-facing cameras, using a pinhole camera model with small-angle approximations for pitch/roll compensation.
from nectar.vision.utils import ImageCalculus
calc = ImageCalculus(
camera_offset={"forward": 0.1, "right": 0.0, "up": 0.0}, # meters
camera_resolution={"width": 1920, "height": 1080},
pixels_per_degree={"horizontal": 30, "vertical": 30},
)
# Ground intersection from a pixel -> (forward, right, down) in meters
vector = calc.calculate_vector_from_drone_to_ground(
target_pixel=(640, 360), height=10.0, pitch=5.0, roll=2.0
)
# Pixel -> GPS
lat, lon = ImageCalculus.estimate_pixel_gps(
origin_lat=-27.1234, origin_lon=-48.4567,
origin_row=540, origin_col=960,
target_row=300, target_col=800,
gsd=0.05, image_bearing=45.0,
)
# Meters -> pixel offset
offset_px = ImageCalculus.calculate_offset_pixels(
offset_meters=0.1, height_meters=10.0, fov_degrees=60.0, image_pixels=1920
)
References¶
| SDK | Documentation | Camera class |
|---|---|---|
| Intel RealSense (D4xx) | RealSense SDK 2.0 | RealsenseCam |
| Intel RealSense (T265) | T265 docs, t265_stereo.py | T265Cam |
| DepthAI | Luxonis DepthAI | OakdCam |
| GStreamer | nvarguscamerasrc | IMX219Cam |
cv2.VideoCapture |
Video I/O | OpenCVCam |
cv2.calibrateCamera |
Camera Calibration | CameraCalibration |
cv2.aruco.CharucoDetector |
ChArUco Detection | CameraCalibration |
sensor_msgs/Image, CompressedImage |
sensor_msgs | ROSCam, camera publisher node |
cv_bridge |
cv_bridge | ROSCam |