High-performance camera module for Raspberry Pi 5 using native libcamera bindings. Zero-copy memory architecture and hardware-accelerated JPEG encoding for efficient camera capture and streaming.
Dual-Channel Magic: Capture 720p JPEG for recording while simultaneously processing 480p RGB for computer vision - all at just 20% CPU usage! This unique capability enables efficient architectures impossible with traditional single-stream solutions.
- Zero-Copy Architecture - Direct memory mapping eliminates buffer copies
- Hardware JPEG Encoding - TurboJPEG acceleration for fast compression
- Dual-Channel Streams - Simultaneous capture at different resolutions (e.g., 720p JPEG + 480p RGB)
- Full Camera Control - Exposure, focus, white balance, and image adjustments
- TypeScript Support - Complete type definitions included
- Event-Driven - Non-blocking async operation with EventEmitter
- Fluent API - Intuitive builder pattern for configuration
- Low CPU Usage - Only 20% CPU for 720p at 30fps
- Raspberry Pi 5 (ARM64)
- Node.js >= 20.0.0
- Raspberry Pi OS (64-bit)
- libcamera and development packages
# Install system dependencies
sudo apt update
sudo apt install -y libcamera-dev libturbojpeg0-dev
# Install the package
npm install @nodify/picamera.js
import { createJpegCamera } from '@nodify/picamera.js';
// Create a simple JPEG camera
const camera = createJpegCamera(1920, 1080, 30);
// Handle frames
camera.on('jpeg', (frame) => {
console.log(`Frame ${frame.sequence}: ${frame.data.length} bytes`);
// frame.data is a Buffer containing JPEG data
});
// Handle errors
camera.on('error', (error) => {
console.error('Camera error:', error);
});
// Start capturing
camera.start();
// Stop when needed
camera.stop();
One of the most powerful features is simultaneous dual-channel capture at different resolutions. This enables efficient architectures where you can record/stream high quality while processing lower resolution:
import { builder } from '@nodify/picamera.js';
const camera = builder()
.jpeg(1280, 720) // 720p JPEG for recording/streaming
.rgb(640, 480) // 480p RGB for real-time processing
.fps(30)
.quality(85)
.build();
// High-quality JPEG stream for recording
camera.on('jpeg', (frame) => {
// Save to file, stream to clients, etc.
saveToFile(frame.data);
streamToClients(frame.data);
});
// Lower resolution RGB for computer vision
camera.on('rgb', (frame) => {
// Real-time processing without affecting recording
detectObjects(frame.data, 640, 480);
});
camera.start();
This dual-channel approach uses only ~20% CPU while maintaining 30fps on both streams!
💡 Pro Tip: Using dual channels (720p JPEG + 480p RGB) is more efficient than a single 1080p stream when you need both recording and processing. The hardware handles both streams in parallel with minimal overhead.
Create a camera using the fluent builder pattern:
import { builder, controls } from '@nodify/picamera.js';
const camera = builder()
// Configure streams
.jpeg(1920, 1080) // JPEG stream at 1920x1080
.rgb(640, 480) // RGB stream at 640x480
.raw() // RAW sensor data (optional)
// Set capture parameters
.fps(30) // Target frame rate
.quality(85) // JPEG quality (1-100)
.queueSize(10) // Frame buffer queue size
// Build the camera
.build();
Configure camera controls during initialization:
const camera = builder()
.jpeg(1920, 1080)
// Exposure controls
.exposure(controls.ExposureMode.NORMAL, 10000) // Mode and time in microseconds
.gain(2.0) // Analogue gain (ISO equivalent)
// Focus controls
.focus(controls.AfMode.CONTINUOUS) // Autofocus mode
// White balance
.whiteBalance(controls.AwbMode.DAYLIGHT) // AWB mode
// Image quality adjustments (-1.0 to 1.0)
.imageQuality({
brightness: 0.0,
contrast: 0.0,
saturation: 0.0,
sharpness: 0.0
})
.build();
Starts camera capture. Returns true
on success.
if (camera.start()) {
console.log('Camera started successfully');
}
Stops camera capture and releases resources.
camera.stop();
Updates camera controls while running.
camera.setControls({
exposureTime: 20000, // 20ms exposure
analogueGain: 4.0, // Increase gain
brightness: 0.5, // Increase brightness
contrast: 0.2
});
Returns current control values.
const controls = camera.getControls();
console.log('Current exposure time:', controls.exposureTime);
Returns camera hardware capabilities and limits.
const caps = camera.getCapabilities();
console.log('Exposure range:', caps.exposureTime);
// Output: { min: 14, max: 11767556, default: 1000 }
Emitted when a JPEG frame is ready.
camera.on('jpeg', (frame: FrameData) => {
// frame.data: Buffer containing JPEG data
// frame.timestamp: bigint nanosecond timestamp
// frame.sequence: number frame sequence
});
Emitted when an RGB frame is ready.
camera.on('rgb', (frame: FrameData) => {
// frame.data: Buffer containing BGR888 pixel data
// Width and height match configured stream size
});
Emitted when an error occurs.
camera.on('error', (error: CameraError) => {
console.error('Camera error:', error.message, error.code);
});
import { controls } from '@nodify/picamera.js';
// Exposure modes
controls.ExposureMode.NORMAL // Standard auto exposure
controls.ExposureMode.SHORT // Prefer shorter exposures
controls.ExposureMode.LONG // Prefer longer exposures
controls.ExposureMode.CUSTOM // Manual exposure control
// Autofocus modes
controls.AfMode.MANUAL // Manual focus control
controls.AfMode.AUTO // Single autofocus
controls.AfMode.CONTINUOUS // Continuous autofocus
// White balance modes
controls.AwbMode.AUTO // Automatic white balance
controls.AwbMode.INCANDESCENT // Indoor incandescent lighting
controls.AwbMode.TUNGSTEN // Tungsten lighting
controls.AwbMode.FLUORESCENT // Fluorescent lighting
controls.AwbMode.INDOOR // Generic indoor lighting
controls.AwbMode.DAYLIGHT // Daylight
controls.AwbMode.CLOUDY // Cloudy daylight
controls.AwbMode.CUSTOM // Custom white balance
import { createJpegCamera } from '@nodify/picamera.js';
import fs from 'fs/promises';
const camera = createJpegCamera(1920, 1080, 30);
camera.on('jpeg', async (frame) => {
// Save frame to file
await fs.writeFile(`frame_${frame.sequence}.jpg`, frame.data);
});
camera.start();
import { builder } from '@nodify/picamera.js';
const camera = builder()
.jpeg(1920, 1080) // High-resolution JPEG
.rgb(640, 480) // Lower resolution RGB for processing
.fps(30)
.build();
// Handle different streams
camera.on('jpeg', (frame) => {
// Save or stream JPEG data
});
camera.on('rgb', (frame) => {
// Process RGB data for computer vision
const width = 640;
const height = 480;
const channels = 3; // BGR format
});
camera.start();
import { builder, controls } from '@nodify/picamera.js';
const camera = builder().jpeg(1920, 1080).build();
// Start with auto exposure
camera.start();
// Switch to manual exposure after 5 seconds
setTimeout(() => {
camera.setControls({
exposureMode: controls.ExposureMode.CUSTOM,
exposureTime: 10000, // 10ms
analogueGain: 2.0
});
}, 5000);
// Trigger autofocus
camera.setControls({
afTrigger: controls.AfTrigger.START
});
import { builder } from '@nodify/picamera.js';
import { WebSocketServer } from 'ws';
const camera = builder()
.jpeg(1280, 720)
.fps(30)
.quality(80)
.build();
const wss = new WebSocketServer({ port: 8080 });
camera.on('jpeg', (frame) => {
// Broadcast to all connected clients
wss.clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(frame.data);
}
});
});
camera.start();
The dual-channel capability enables powerful architectures:
const camera = builder()
.jpeg(1280, 720) // HD recording
.rgb(416, 416) // YOLO input size
.fps(20)
.build();
// Record everything in HD
camera.on('jpeg', frame => {
recordToNVR(frame);
});
// Run AI detection on optimized stream
camera.on('rgb', frame => {
const detections = runYOLOv5(frame);
if (detections.includes('person')) {
sendAlert();
}
});
const camera = builder()
.jpeg(1280, 720) // For streaming
.rgb(800, 480) // For local LCD display
.fps(30)
.build();
// Stream to remote viewers
camera.on('jpeg', frame => {
rtmpStream.write(frame.data);
});
// Display on local screen with overlays
camera.on('rgb', frame => {
addTimestamp(frame);
addWatermark(frame);
displayOnLCD(frame);
});
Typical resource usage on Raspberry Pi 5:
- 1280x720 @ 30 FPS (JPEG): ~20% CPU, ~35 MB RAM
- 720p JPEG + 480p RGB @ 30 FPS: ~20% CPU, ~45 MB RAM
- 1920x1080 @ 30 FPS: < 30% CPU, ~50 MB RAM
- 640x480 @ 60 FPS: < 15% CPU, ~28 MB RAM
Measured with JPEG encoding at 85% quality
// ✅ GOOD: Use appropriate resolution for each task
const camera = builder()
.jpeg(1280, 720) // 720p is sufficient for most streaming
.rgb(640, 480) // Lower resolution for CV processing
.build();
// ❌ AVOID: Over-provisioning resolution
const camera = builder()
.jpeg(3840, 2160) // 4K might be overkill for streaming
.rgb(1920, 1080) // Too high for real-time processing
.build();
Leverage dual channels for efficient processing:
// Recording + Analysis Pattern
const camera = builder()
.jpeg(1280, 720) // High quality for archive
.rgb(320, 240) // Ultra-low res for motion detection
.fps(30)
.build();
let recording = false;
let motionBuffer = [];
camera.on('rgb', (frame) => {
// Continuous motion detection on low-res stream
if (detectMotion(frame)) {
recording = true;
setTimeout(() => recording = false, 30000); // Record for 30s
}
});
camera.on('jpeg', (frame) => {
// Only save high-quality when motion detected
if (recording) {
motionBuffer.push(frame);
}
});
// ✅ GOOD: Release resources properly
const camera = builder().jpeg(1280, 720).build();
process.on('SIGINT', () => {
camera.stop();
process.exit(0);
});
// ✅ GOOD: Handle backpressure
const frameQueue = [];
const MAX_QUEUE_SIZE = 30;
camera.on('jpeg', (frame) => {
if (frameQueue.length < MAX_QUEUE_SIZE) {
frameQueue.push(frame);
} else {
console.warn('Frame dropped - queue full');
}
});
// ❌ AVOID: Unbounded queuing
camera.on('jpeg', async (frame) => {
await slowDatabaseWrite(frame); // This will cause memory issues!
});
// Adaptive Quality Based on Network
let networkQuality = 'good';
setInterval(() => {
const quality = networkQuality === 'good' ? 85 : 60;
camera.setControls({ jpegQuality: quality });
}, 5000);
// Scene-Based Adjustments
camera.on('rgb', (frame) => {
const brightness = calculateAverageBrightness(frame);
if (brightness < 50) {
// Dark scene - increase exposure
camera.setControls({
exposureTime: 30000,
analogueGain: 4.0
});
} else if (brightness > 200) {
// Bright scene - decrease exposure
camera.setControls({
exposureTime: 5000,
analogueGain: 1.0
});
}
});
// ✅ GOOD: Process frames asynchronously
const processQueue = [];
const worker = new Worker('./frame-processor.js');
camera.on('jpeg', (frame) => {
// Non-blocking handoff to worker
worker.postMessage({ frame: frame.data });
});
// ✅ GOOD: Skip frames if needed
let frameCounter = 0;
camera.on('rgb', (frame) => {
if (frameCounter++ % 3 === 0) { // Process every 3rd frame
analyzeFrame(frame);
}
});
// ✅ GOOD: Use appropriate queue sizes
const camera = builder()
.jpeg(1280, 720)
.queueSize(5) // Smaller queue for real-time apps
.build();
class ResilientCamera {
constructor() {
this.restartAttempts = 0;
this.maxRestarts = 5;
this.initCamera();
}
initCamera() {
this.camera = builder()
.jpeg(1280, 720)
.fps(30)
.build();
this.camera.on('error', (error) => {
console.error('Camera error:', error);
this.handleError(error);
});
this.camera.on('jpeg', this.onFrame.bind(this));
}
async handleError(error) {
if (error.code === ErrorCodes.START_FAILED &&
this.restartAttempts < this.maxRestarts) {
console.log(`Attempting restart ${++this.restartAttempts}`);
await this.restart();
}
}
async restart() {
try {
this.camera.stop();
await new Promise(resolve => setTimeout(resolve, 1000));
if (this.camera.start()) {
this.restartAttempts = 0;
console.log('Camera restarted successfully');
}
} catch (err) {
console.error('Restart failed:', err);
}
}
onFrame(frame) {
// Process frame
}
}
const camera = builder()
.jpeg(1280, 720)
.fps(30)
.quality(80)
.build();
// Monitor client bandwidth and adjust
function adjustQualityForClient(clientId, bandwidth) {
if (bandwidth < 1000000) { // < 1 Mbps
camera.setControls({ jpegQuality: 60 });
} else if (bandwidth < 2000000) { // < 2 Mbps
camera.setControls({ jpegQuality: 70 });
} else {
camera.setControls({ jpegQuality: 85 });
}
}
const camera = builder()
.jpeg(1920, 1080)
.quality(95)
.build();
let exposureTime = 10000; // Start at 10ms
async function captureTimelapse() {
// Gradually increase exposure as it gets darker
camera.setControls({
exposureMode: controls.ExposureMode.CUSTOM,
exposureTime: exposureTime
});
camera.once('jpeg', async (frame) => {
await saveFrame(frame);
camera.stop();
// Adjust exposure for next frame
exposureTime = Math.min(exposureTime * 1.1, 100000);
// Wait and capture next frame
setTimeout(() => {
camera.start();
captureTimelapse();
}, 60000); // 1 minute interval
});
camera.start();
}
The library provides detailed error information through error codes:
import { CameraError, ErrorCodes } from '@nodify/picamera.js';
camera.on('error', (error: CameraError) => {
switch (error.code) {
case ErrorCodes.ALREADY_RUNNING:
console.log('Camera is already running');
break;
case ErrorCodes.START_FAILED:
console.log('Failed to start camera');
break;
case ErrorCodes.INVALID_DIMENSION:
console.log('Invalid resolution requested');
break;
// ... handle other error codes
}
});
Full TypeScript definitions are included:
import { Camera, FrameData, Controls, CameraError } from '@nodify/picamera.js';
// All types are fully defined
const handleFrame = (frame: FrameData): void => {
const data: Buffer = frame.data;
const timestamp: bigint = frame.timestamp;
const sequence: number = frame.sequence;
};
// Type-safe control configuration
const controls: Controls = {
exposureTime: 10000,
analogueGain: 2.0,
jpegQuality: 90
};
# Verify camera is connected
libcamera-hello --list-cameras
# Enable camera interface
sudo raspi-config
# Navigate to: Interface Options -> Camera -> Enable
# Add user to video group
sudo usermod -a -G video $USER
# Log out and back in for changes to take effect
- Reduce stream resolution
- Use only required streams (disable RAW if not needed)
- Adjust queue size based on your application needs
# Ensure all dependencies are installed
sudo apt install -y libcamera-dev libturbojpeg0-dev build-essential
# Clear npm cache and rebuild
npm cache clean --force
npm rebuild
This project is licensed under the MIT License - see the LICENSE file for details.
Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature
) - Commit your changes (
git commit -m 'Add amazing feature'
) - Push to the branch (
git push origin feature/amazing-feature
) - Open a Pull Request
- 📧 Email: office@nodify.at
- 🐛 Issues: GitHub Issues
- 💬 Discussions: GitHub Discussions
Built with ❤️ by Nodify for the Raspberry Pi community