├── .editorconfig ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── package.json ├── src ├── index.ts ├── lib │ ├── shared-args.ts │ ├── still-camera.test.ts │ ├── still-camera.ts │ ├── stream-camera.test.ts │ └── stream-camera.ts ├── util.test.ts └── util.ts ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | end_of_line = lf 10 | max_line_length = null 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [*.rs] 16 | indent_size = 4 17 | 18 | [Makefile] 19 | indent_style = tab 20 | trim_trailing_whitespace = false 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | # config 4 | *.secrets.* 5 | 6 | # common build sites 7 | dist/ 8 | dist-types/ 9 | 10 | # build artifacts 11 | *.tsbuildinfo 12 | *.lcov 13 | coverage/ 14 | .eslintcache 15 | 16 | # logs and errors 17 | npm-debug.log* 18 | yarn-debug.log* 19 | yarn-error.log* 20 | lerna-debug.log* 21 | stats.json 22 | 23 | # dev environments 24 | .vscode/* 25 | **/.DS_Store 26 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | yarn.lock 3 | 4 | tsconfig.json 5 | *.tsbuildinfo 6 | .editorconfig 7 | .app-config.* 8 | app-config.* 9 | 10 | .DS_Store 11 | coverage/ 12 | 13 | npm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | lerna-debug.log* 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Launchcode 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/launchcodedev/pi-camera-connect/master/LICENSE) 2 | [![npm](https://img.shields.io/npm/v/pi-camera-connect.svg)](https://www.npmjs.com/package/pi-camera-connect) 3 | 4 | # Pi Camera Connect - for NodeJS 5 | 6 | `pi-camera-connect` is a library to capture and stream Raspberry Pi camera data directly to NodeJS. 7 | 8 | ## Why use this? 9 | 10 | There are many NPM modules for connecting to the Raspberry Pi camera, why use this? 11 | 12 | - **Speed:** JPEG images can be captured in ~33ms using a built in MJPEG parser 13 | - **Efficient:** Pictures and video streams are piped directly into Node as a `Buffer`, keeping all data in memory and eliminating disk I/O 14 | - **Usable:** Video streams are available as [`stream.Readable`](https://nodejs.org/api/stream.html#stream_class_stream_readable) objects that can be piped or listened to 15 | - **Tested:** Contains automated tests using Jest 16 | - **Modern:** Uses the latest ESNext features and up to date development practices 17 | - **Structure**: Ships with TypeScript definition files 18 | 19 | ## Install 20 | 21 | NPM 22 | 23 | ``` 24 | $ npm install --save pi-camera-connect 25 | ``` 26 | 27 | Yarn 28 | 29 | ``` 30 | $ yarn add pi-camera-connect 31 | ``` 32 | 33 | ## Basic Usage 34 | 35 | ### ESNext Syntax 36 | 37 | Image capture: 38 | 39 | ```javascript 40 | import { StillCamera } from 'pi-camera-connect'; 41 | import * as fs from 'fs'; 42 | 43 | // Take still image and save to disk 44 | const runApp = async () => { 45 | const stillCamera = new StillCamera(); 46 | 47 | const image = await stillCamera.takeImage(); 48 | 49 | fs.writeFileSync('still-image.jpg', image); 50 | }; 51 | 52 | runApp(); 53 | ``` 54 | 55 | Video capture: 56 | 57 | ```javascript 58 | import { StreamCamera, Codec } from 'pi-camera-connect'; 59 | import * as fs from 'fs'; 60 | 61 | // Capture 5 seconds of H264 video and save to disk 62 | const runApp = async () => { 63 | const streamCamera = new StreamCamera({ 64 | codec: Codec.H264, 65 | }); 66 | 67 | const videoStream = streamCamera.createStream(); 68 | 69 | const writeStream = fs.createWriteStream('video-stream.h264'); 70 | 71 | videoStream.pipe(writeStream); 72 | 73 | await streamCamera.startCapture(); 74 | 75 | await new Promise(resolve => setTimeout(() => resolve(), 5000)); 76 | 77 | await streamCamera.stopCapture(); 78 | }; 79 | 80 | runApp(); 81 | ``` 82 | 83 | ### Compatible Syntax 84 | 85 | Image capture: 86 | 87 | ```javascript 88 | const { StillCamera } = require('pi-camera-connect'); 89 | 90 | const stillCamera = new StillCamera(); 91 | 92 | stillCamera.takeImage().then(image => { 93 | fs.writeFileSync('still-image.jpg', image); 94 | }); 95 | ``` 96 | 97 | Video capture: 98 | 99 | ```javascript 100 | const { StreamCamera, Codec } = require('pi-camera-connect'); 101 | 102 | const streamCamera = new StreamCamera({ 103 | codec: Codec.H264, 104 | }); 105 | 106 | const writeStream = fs.createWriteStream('video-stream.h264'); 107 | 108 | const videoStream = streamCamera.createStream(); 109 | 110 | videoStream.pipe(writeStream); 111 | 112 | streamCamera.startCapture().then(() => { 113 | setTimeout(() => streamCamera.stopCapture(), 5000); 114 | }); 115 | ``` 116 | 117 | ## Capturing an image 118 | 119 | There are 2 ways to capture an image with `pi-camera-connect`: 120 | 121 | 1. `StillCamera.takeImage()` - _Slow, but higher quality_ 122 | 123 | This is equivalent to running the `raspistill` command. Under the hood, the GPU will run a strong noise reduction algorithm to make the image appear higher quality. 124 | 125 | ```javascript 126 | import { StillCamera } from 'pi-camera-connect'; 127 | 128 | const runApp = async () => { 129 | const stillCamera = new StillCamera(); 130 | 131 | const image = await stillCamera.takeImage(); 132 | 133 | // Process image... 134 | }; 135 | 136 | runApp(); 137 | ``` 138 | 139 | 2. `StreamCamera.takeImage()` - _Fast, but lower quality_ 140 | 141 | This works by grabbing a single JPEG frame from a Motion JPEG (MJPEG) video stream . Images captured from the video port tend to have a grainier appearance due to the lack of a strong noise reduction algorithm. 142 | 143 | Using this method, you can capture a JPEG image at more or less the frame rate of the stream, eg. 30 fps = ~33ms capture times. 144 | 145 | ```javascript 146 | import { StreamCamera, Codec } from 'pi-camera-connect'; 147 | 148 | const runApp = async () => { 149 | const streamCamera = new StreamCamera({ 150 | codec: Codec.MJPEG, 151 | }); 152 | 153 | await streamCamera.startCapture(); 154 | 155 | const image = await streamCamera.takeImage(); 156 | 157 | // Process image... 158 | 159 | await streamCamera.stopCapture(); 160 | }; 161 | 162 | runApp(); 163 | ``` 164 | 165 | ## Capturing a video stream 166 | 167 | Capturing a video stream is easy. There are currently 2 codecs supported: `H264` and `MJPEG`. 168 | 169 | ### Why **H264** and **MJPEG**? 170 | 171 | The GPU on the Raspberry Pi comes with a hardware-accelerated H264 encoder and JPEG encoder. To capture videos in real time, using these hardware encoders are required. 172 | 173 | ### Stream 174 | 175 | A standard NodeJS [readable stream](https://nodejs.org/api/stream.html#stream_class_stream_readable) is available after calling `createStream()`. As with any readable stream, it can be piped or listened to. 176 | 177 | ```javascript 178 | import { StreamCamera, Codec } from 'pi-camera-connect'; 179 | import * as fs from 'fs'; 180 | 181 | const runApp = async () => { 182 | const streamCamera = new StreamCamera({ 183 | codec: Codec.H264, 184 | }); 185 | 186 | const videoStream = streamCamera.createStream(); 187 | 188 | const writeStream = fs.createWriteStream('video-stream.h264'); 189 | 190 | // Pipe the video stream to our video file 191 | videoStream.pipe(writeStream); 192 | 193 | await streamCamera.startCapture(); 194 | 195 | // We can also listen to data events as they arrive 196 | videoStream.on('data', data => console.log('New data', data)); 197 | videoStream.on('end', data => console.log('Video stream has ended')); 198 | 199 | // Wait for 5 seconds 200 | await new Promise(resolve => setTimeout(() => resolve(), 5000)); 201 | 202 | await streamCamera.stopCapture(); 203 | }; 204 | 205 | runApp(); 206 | ``` 207 | 208 | You can test the video by viewing it in `omxplayer` (ships with Raspbian): 209 | 210 | ``` 211 | $ omxplayer video-stream.h264 212 | ``` 213 | 214 | Note that this example produces a raw H264 video. Wrapping it in a video container (eg. MP4, MKV, etc) is out of the scope of this module. 215 | 216 | ## API 217 | 218 | - [`StillCamera`](#stillcamera) 219 | - `constructor(options?: StillOptions): StillCamera` 220 | - `takeImage(): Promise` 221 | - [`StreamCamera`](#streamcamera) 222 | - `constructor(options?: StreamOptions): StreamCamera` 223 | - `startCapture(): Promise` 224 | - `stopCapture(): Promise` 225 | - `createStream(): stream.Readable` 226 | - `takeImage(): Promise` 227 | - [`Rotation`](#rotation) 228 | - [`Flip`](#flip) 229 | - [`Codec`](#codec) 230 | - [`SensorMode`](#sensormode) 231 | - [`ExposureMode`](#exposuremode) 232 | - [`AwbMode`](#awbmode) 233 | 234 | ## `StillCamera` 235 | 236 | A class for taking still images. Equivalent to running the `raspistill` command. 237 | 238 | ### `constructor (options?: StillOptions): StillCamera` 239 | 240 | Instantiates a new `StillCamera` class. 241 | 242 | ```javascript 243 | const stillCamera = new StillCamera({ 244 | ... 245 | }); 246 | ``` 247 | 248 | `StillOptions` are: 249 | 250 | - `width: number` - _Default: Max sensor width_ 251 | - `height: number` - _Default: Max sensor height_ 252 | - [`rotation: Rotation`](#rotation) - _Default: `Rotation.Rotate0`_ 253 | - [`flip: Flip`](#flip) - _Default: `Flip.None`_ 254 | - `delay: number` - _Default: `1` ms_ 255 | - `shutter: number` - _Default: Auto calculated based on framerate (1000000µs/fps). Number is in microseconds_ 256 | - `sharpness: number` - _Range: `-100`-`100`; Default: `0`_ 257 | - `contrast: number` - _Range: `-100`-`100`; Default: `0`_ 258 | - `brightness: number` - _Range: `0`-`100`; Default: `50`_ 259 | - `saturation: number` - _Range: `-100`-`100`; Default: `0`_ 260 | - `iso: number` - _Range: `100`-`800`; Default: Auto_ 261 | - `exposureCompensation: number` - _Range: `-10`-`10`; Default: `0`_ 262 | - [`exposureMode: ExposureMode`](#exposuremode) - _Default: `ExposureMode.Auto`_ 263 | - [`awbMode: AwbMode`](#awbmode) - _Default: `AwbMode.Auto`_ 264 | - `analogGain: number` - _Default: `0`_ 265 | - `digitalGain: number` - _Default: `0`_ 266 | 267 | ### `StillCamera.takeImage(): Promise` 268 | 269 | Takes a JPEG image from the camera. Returns a `Promise` with a `Buffer` containing the image bytes. 270 | 271 | ```javascript 272 | const stillCamera = new StillCamera(); 273 | 274 | const image = await stillCamera.takeImage(); 275 | ``` 276 | 277 | ## `StreamCamera` 278 | 279 | A class for capturing a stream of camera data, either as `H264` or `MJPEG`. 280 | 281 | ### `constructor(options?: StreamOptions): StreamCamera` 282 | 283 | Instantiates a new `StreamCamera` class. 284 | 285 | ```javascript 286 | const streamCamera = new StreamCamera({ 287 | ... 288 | }); 289 | ``` 290 | 291 | `StreamOptions` are: 292 | 293 | - `width: number` - _Default: Max sensor width_ 294 | - `height: number` - _Default: Max sensor height_ 295 | - [`rotation: Rotation`](#rotation) - _Default: `Rotation.Rotate0`_ 296 | - [`flip: Flip`](#flip) - _Default: `Flip.None`_ 297 | - `bitRate: number` - _Default: `17000000` (17 Mbps)_ 298 | - `fps: number` - _Default: `30` fps_ 299 | - [`codec: Codec`](#codec) - _Default: `Codec.H264`_ 300 | - [`sensorMode: SensorMode`](#sensormode) - _Default: `SensorMode.AutoSelect`_ 301 | - `shutter: number` - _Default: Auto calculated based on framerate (1000000µs/fps). Number is in microseconds_ 302 | - `sharpness: number` - _Range: `-100`-`100`; Default: `0`_ 303 | - `contrast: number` - _Range: `-100`-`100`; Default: `0`_ 304 | - `brightness: number` - _Range: `0`-`100`; Default: `50`_ 305 | - `saturation: number` - _Range: `-100`-`100`; Default: `0`_ 306 | - `iso: number` - _Range: `100`-`800`; Default: Auto_ 307 | - `exposureCompensation: number` - _Range: `-10`-`10`; Default: `0`_ 308 | - [`exposureMode: ExposureMode`](#exposuremode) - _Default: `ExposureMode.Auto`_ 309 | - [`awbMode: AwbMode`](#awbmode) - _Default: `AwbMode.Auto`_ 310 | - `analogGain: number` - _Default: `0`_ 311 | - `digitalGain: number` - _Default: `0`_ 312 | 313 | ### `startCapture(): Promise` 314 | 315 | Begins the camera stream. Returns a `Promise` that is resolved when the capture has started. 316 | 317 | ### `stopCapture(): Promise` 318 | 319 | Ends the camera stream. Returns a `Promise` that is resolved when the capture has stopped. 320 | 321 | ### `createStream(): stream.Readable` 322 | 323 | Creates a [`readable stream`](https://nodejs.org/api/stream.html#stream_class_stream_readable) of video data. There is no limit to the number of streams you can create. 324 | 325 | Be aware that, as with any readable stream, data will buffer in memory until it is read. If you create a video stream but do not read its data, your program will quickly run out of memory. 326 | 327 | Ways to read data so that it does not remain buffered in memory include: 328 | 329 | - Switching the stream to 'flowing' mode by calling either `resume()`, `pipe()`, or attaching a listener to the `'data'` event 330 | - Calling `read()` when the stream is in 'paused' mode 331 | 332 | See the [readable stream documentation](https://nodejs.org/api/stream.html#stream_two_modes) for more information on flowing/paused modes. 333 | 334 | ```javascript 335 | const streamCamera = new StreamCamera({ 336 | codec: Codec.H264, 337 | }); 338 | 339 | const videoStream = streamCamera.createStream(); 340 | 341 | await streamCamera.startCapture(); 342 | 343 | videoStream.on('data', data => console.log('New video data', data)); 344 | 345 | // Wait 5 seconds 346 | await new Promise(resolve => setTimeout(() => resolve(), 5000)); 347 | 348 | await streamCamera.stopCapture(); 349 | ``` 350 | 351 | ### `takeImage(): Promise` 352 | 353 | Takes a JPEG image frame from an MJPEG camera stream, resulting in very fast image captures. Returns a `Promise` with a `Buffer` containing the image bytes. 354 | 355 | _Note: `StreamOptions.codec` must be set to `Codec.MJPEG`, otherwise `takeImage()` with throw an error._ 356 | 357 | ```javascript 358 | const streamCamera = new StreamCamera({ 359 | codec: Codec.MJPEG, 360 | }); 361 | 362 | await streamCamera.startCapture(); 363 | 364 | const image = await streamCamera.takeImage(); 365 | 366 | await streamCamera.stopCapture(); 367 | ``` 368 | 369 | ## `Rotation` 370 | 371 | Image rotation options. 372 | 373 | - `Rotation.Rotate0` 374 | - `Rotation.Rotate90` 375 | - `Rotation.Rotate180` 376 | - `Rotation.Rotate270` 377 | 378 | ```javascript 379 | import { Rotation } from 'pi-camera-connect'; 380 | ``` 381 | 382 | ## `Flip` 383 | 384 | Image flip options. 385 | 386 | - `Flip.None` 387 | - `Flip.Horizontal` 388 | - `Flip.Vertical` 389 | - `Flip.Both` 390 | 391 | ```javascript 392 | import { Flip } from 'pi-camera-connect'; 393 | ``` 394 | 395 | ## `Codec` 396 | 397 | Stream codec options. 398 | 399 | - `Codec.H264` 400 | - `Codec.MJPEG` 401 | 402 | ```javascript 403 | import { Codec } from 'pi-camera-connect'; 404 | ``` 405 | 406 | ## `SensorMode` 407 | 408 | Stream sensor mode options. 409 | 410 | - `SensorMode.AutoSelect` 411 | - `SensorMode.Mode1` 412 | - `SensorMode.Mode2` 413 | - `SensorMode.Mode3` 414 | - `SensorMode.Mode4` 415 | - `SensorMode.Mode5` 416 | - `SensorMode.Mode6` 417 | - `SensorMode.Mode7` 418 | 419 | ```javascript 420 | import { SensorMode } from 'pi-camera-connect'; 421 | ``` 422 | 423 | These are slightly different depending on the version of Raspberry Pi camera you are using. 424 | 425 | #### Camera version 1.x (OV5647): 426 | 427 | | Mode | Size | Aspect Ratio | Frame rates | FOV | Binning | 428 | | ---- | ------------------- | ------------ | ----------- | ------- | ------------- | 429 | | 0 | automatic selection | | | | | 430 | | 1 | 1920x1080 | 16:9 | 1-30fps | Partial | None | 431 | | 2 | 2592x1944 | 4:3 | 1-15fps | Full | None | 432 | | 3 | 2592x1944 | 4:3 | 0.1666-1fps | Full | None | 433 | | 4 | 1296x972 | 4:3 | 1-42fps | Full | 2x2 | 434 | | 5 | 1296x730 | 16:9 | 1-49fps | Full | 2x2 | 435 | | 6 | 640x480 | 4:3 | 42.1-60fps | Full | 2x2 plus skip | 436 | | 7 | 640x480 | 4:3 | 60.1-90fps | Full | 2x2 plus skip | 437 | 438 | #### Camera version 2.x (IMX219): 439 | 440 | | Mode | Size | Aspect Ratio | Frame rates | FOV | Binning | 441 | | ---- | ------------------- | ------------ | ----------- | ------- | ------- | 442 | | 0 | automatic selection | | | | | 443 | | 1 | 1920x1080 | 16:9 | 0.1-30fps | Partial | None | 444 | | 2 | 3280x2464 | 4:3 | 0.1-15fps | Full | None | 445 | | 3 | 3280x2464 | 4:3 | 0.1-15fps | Full | None | 446 | | 4 | 1640x1232 | 4:3 | 0.1-40fps | Full | 2x2 | 447 | | 5 | 1640x922 | 16:9 | 0.1-40fps | Full | 2x2 | 448 | | 6 | 1280x720 | 16:9 | 40-90fps | Partial | 2x2 | 449 | | 7 | 640x480 | 4:3 | 40-90fps | Partial | 2x2 | 450 | 451 | ## `ExposureMode` 452 | 453 | Exposure mode options. 454 | 455 | - `ExposureMode.Off` 456 | - `ExposureMode.Auto` 457 | - `ExposureMode.Night` 458 | - `ExposureMode.NightPreview` 459 | - `ExposureMode.Backlight` 460 | - `ExposureMode.Spotlight` 461 | - `ExposureMode.Sports` 462 | - `ExposureMode.Snow` 463 | - `ExposureMode.Beach` 464 | - `ExposureMode.VeryLong` 465 | - `ExposureMode.FixedFps` 466 | - `ExposureMode.AntiShake` 467 | - `ExposureMode.Fireworks` 468 | 469 | ```javascript 470 | import { ExposureMode } from 'pi-camera-connect'; 471 | ``` 472 | 473 | ## `AwbMode` 474 | 475 | White balance mode options. 476 | 477 | - `AwbMode.Off` 478 | - `AwbMode.Auto` 479 | - `AwbMode.Sun` 480 | - `AwbMode.Cloud` 481 | - `AwbMode.Shade` 482 | - `AwbMode.Tungsten` 483 | - `AwbMode.Fluorescent` 484 | - `AwbMode.Incandescent` 485 | - `AwbMode.Flash` 486 | - `AwbMode.Horizon` 487 | - `AwbMode.GreyWorld` 488 | 489 | ```javascript 490 | import { AwbMode } from 'pi-camera-connect'; 491 | ``` 492 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pi-camera-connect", 3 | "version": "0.3.4", 4 | "description": "Library to capture and stream Raspberry Pi camera data directly to NodeJS", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "author": { 8 | "name": "Launchcode", 9 | "email": "admin@lc.dev", 10 | "url": "https://lc.dev" 11 | }, 12 | "license": "MIT", 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/launchcodedev/pi-camera-connect" 16 | }, 17 | "scripts": { 18 | "build": "tsc -b", 19 | "clean": "rm -rf dist *.tsbuildinfo", 20 | "lint": "eslint --ext .ts,.tsx src", 21 | "fix": "eslint --ext .ts,.tsx src --fix", 22 | "test": "jest --runInBand", 23 | "prepublishOnly": "yarn clean && yarn build" 24 | }, 25 | "dependencies": {}, 26 | "devDependencies": { 27 | "@lcdev/eslint-config": "0.2", 28 | "@lcdev/jest": "0.2", 29 | "@lcdev/prettier": "0.1", 30 | "@lcdev/tsconfig": "0.1", 31 | "@types/jest": "25", 32 | "@types/node": "*", 33 | "eslint": "6", 34 | "jest": "25", 35 | "prettier": "1", 36 | "typescript": "3" 37 | }, 38 | "jest": { 39 | "preset": "@lcdev/jest" 40 | }, 41 | "prettier": "@lcdev/prettier", 42 | "eslintConfig": { 43 | "extends": "@lcdev", 44 | "rules": { 45 | "no-constant-condition": 0, 46 | "no-shadow": 0 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as StillCamera, StillOptions } from './lib/still-camera'; 2 | export { default as StreamCamera, StreamOptions, Codec, SensorMode } from './lib/stream-camera'; 3 | 4 | export enum Rotation { 5 | Rotate0 = 0, 6 | Rotate90 = 90, 7 | Rotate180 = 180, 8 | Rotate270 = 270, 9 | } 10 | 11 | export enum Flip { 12 | None = 'none', 13 | Horizontal = 'horizontal', 14 | Vertical = 'vertical', 15 | Both = 'both', 16 | } 17 | 18 | export enum ExposureMode { 19 | Off = 'off', 20 | Auto = 'auto', 21 | Night = 'night', 22 | NightPreview = 'nightpreview', 23 | Backlight = 'backlight', 24 | Spotlight = 'spotlight', 25 | Sports = 'sports', 26 | Snow = 'snow', 27 | Beach = 'beach', 28 | VeryLong = 'verylong', 29 | FixedFps = 'fixedfps', 30 | AntiShake = 'antishake', 31 | Fireworks = 'fireworks', 32 | } 33 | 34 | export enum AwbMode { 35 | Off = 'off', 36 | Auto = 'auto', 37 | Sun = 'sun', 38 | Cloud = 'cloud', 39 | Shade = 'shade', 40 | Tungsten = 'tungsten', 41 | Fluorescent = 'fluorescent', 42 | Incandescent = 'incandescent', 43 | Flash = 'flash', 44 | Horizon = 'horizon', 45 | GreyWorld = 'greyworld', 46 | } 47 | -------------------------------------------------------------------------------- /src/lib/shared-args.ts: -------------------------------------------------------------------------------- 1 | import { StillOptions } from './still-camera'; 2 | import { StreamOptions } from './stream-camera'; 3 | import { Flip } from '..'; 4 | 5 | /** 6 | * Get the command line arguments for `raspistill` or `raspivid` that are common among both 7 | * 8 | * @param options Camera options 9 | */ 10 | export function getSharedArgs(options: StillOptions | StreamOptions): string[] { 11 | return [ 12 | /** 13 | * Width 14 | */ 15 | ...(options.width ? ['--width', options.width.toString()] : []), 16 | 17 | /** 18 | * Height 19 | */ 20 | ...(options.height ? ['--height', options.height.toString()] : []), 21 | 22 | /** 23 | * Rotation 24 | */ 25 | ...(options.rotation ? ['--rotation', options.rotation.toString()] : []), 26 | 27 | /** 28 | * Horizontal flip 29 | */ 30 | ...(options.flip && (options.flip === Flip.Horizontal || options.flip === Flip.Both) 31 | ? ['--hflip'] 32 | : []), 33 | 34 | /** 35 | * Vertical flip 36 | */ 37 | ...(options.flip && (options.flip === Flip.Vertical || options.flip === Flip.Both) 38 | ? ['--vflip'] 39 | : []), 40 | 41 | /** 42 | * Shutter Speed 43 | */ 44 | ...(options.shutter ? ['--shutter', options.shutter.toString()] : []), 45 | 46 | /** 47 | * Sharpness (-100 to 100; default 0) 48 | */ 49 | ...(options.sharpness ? ['--sharpness', options.sharpness.toString()] : []), 50 | 51 | /** 52 | * Contrast (-100 to 100; default 0) 53 | */ 54 | ...(options.contrast ? ['--contrast', options.contrast.toString()] : []), 55 | 56 | /** 57 | * Brightness (0 to 100; default 50) 58 | */ 59 | ...(options.brightness || options.brightness === 0 60 | ? ['--brightness', options.brightness.toString()] 61 | : []), 62 | 63 | /** 64 | * Saturation (-100 to 100; default 0) 65 | */ 66 | ...(options.saturation ? ['--saturation', options.saturation.toString()] : []), 67 | 68 | /** 69 | * ISO 70 | */ 71 | ...(options.iso ? ['--ISO', options.iso.toString()] : []), 72 | 73 | /** 74 | * EV Compensation 75 | */ 76 | ...(options.exposureCompensation ? ['--ev', options.exposureCompensation.toString()] : []), 77 | 78 | /** 79 | * Exposure Mode 80 | */ 81 | ...(options.exposureMode ? ['--exposure', options.exposureMode.toString()] : []), 82 | 83 | /** 84 | * Auto White Balance Mode 85 | */ 86 | ...(options.awbMode ? ['--awb', options.awbMode.toString()] : []), 87 | 88 | /** 89 | * Analog Gain 90 | */ 91 | ...(options.analogGain ? ['--analoggain', options.analogGain.toString()] : []), 92 | 93 | /** 94 | * Digital Gain 95 | */ 96 | ...(options.digitalGain ? ['--digitalgain', options.digitalGain.toString()] : []), 97 | ]; 98 | } 99 | -------------------------------------------------------------------------------- /src/lib/still-camera.test.ts: -------------------------------------------------------------------------------- 1 | import StillCamera from './still-camera'; 2 | 3 | test('takeImage() returns JPEG', async () => { 4 | const stillCamera = new StillCamera(); 5 | 6 | const jpegImage = await stillCamera.takeImage(); 7 | 8 | expect(jpegImage.indexOf(StillCamera.jpegSignature)).toBe(0); 9 | }); 10 | -------------------------------------------------------------------------------- /src/lib/still-camera.ts: -------------------------------------------------------------------------------- 1 | import { AwbMode, ExposureMode, Flip, Rotation } from '..'; 2 | import { spawnPromise } from '../util'; 3 | import { getSharedArgs } from './shared-args'; 4 | 5 | export interface StillOptions { 6 | width?: number; 7 | height?: number; 8 | rotation?: Rotation; 9 | flip?: Flip; 10 | delay?: number; 11 | shutter?: number; 12 | sharpness?: number; 13 | contrast?: number; 14 | brightness?: number; 15 | saturation?: number; 16 | iso?: number; 17 | exposureCompensation?: number; 18 | exposureMode?: ExposureMode; 19 | awbMode?: AwbMode; 20 | analogGain?: number; 21 | digitalGain?: number; 22 | } 23 | 24 | export default class StillCamera { 25 | private readonly options: StillOptions; 26 | 27 | static readonly jpegSignature = Buffer.from([0xff, 0xd8, 0xff, 0xe1]); 28 | 29 | constructor(options: StillOptions = {}) { 30 | this.options = { 31 | rotation: Rotation.Rotate0, 32 | flip: Flip.None, 33 | delay: 1, 34 | ...options, 35 | }; 36 | } 37 | 38 | async takeImage() { 39 | try { 40 | return await spawnPromise('raspistill', [ 41 | /** 42 | * Add the command-line arguments that are common to both `raspivid` and `raspistill` 43 | */ 44 | ...getSharedArgs(this.options), 45 | 46 | /** 47 | * Capture delay (ms) 48 | */ 49 | '--timeout', 50 | this.options.delay!.toString(), 51 | 52 | /** 53 | * Do not display preview overlay on screen 54 | */ 55 | '--nopreview', 56 | 57 | /** 58 | * Output to stdout 59 | */ 60 | '--output', 61 | '-', 62 | ]); 63 | } catch (err) { 64 | if (err.code === 'ENOENT') { 65 | throw new Error( 66 | "Could not take image with StillCamera. Are you running on a Raspberry Pi with 'raspistill' installed?", 67 | ); 68 | } 69 | 70 | throw err; 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/lib/stream-camera.test.ts: -------------------------------------------------------------------------------- 1 | import StreamCamera, { Codec } from './stream-camera'; 2 | 3 | test('Method takeImage() grabs JPEG from MJPEG stream', async () => { 4 | const streamCamera = new StreamCamera({ 5 | codec: Codec.MJPEG, 6 | }); 7 | 8 | await streamCamera.startCapture(); 9 | 10 | const jpegImage = await streamCamera.takeImage(); 11 | 12 | await streamCamera.stopCapture(); 13 | 14 | expect(jpegImage.indexOf(StreamCamera.jpegSignature)).toBe(0); 15 | }); 16 | 17 | test('Method createStream() returns a stream of video data', async () => { 18 | const streamCamera = new StreamCamera({ 19 | codec: Codec.MJPEG, 20 | }); 21 | 22 | await streamCamera.startCapture(); 23 | 24 | const videoStream = streamCamera.createStream(); 25 | 26 | // Wait 300 ms for data to arrive 27 | await new Promise(resolve => setTimeout(() => resolve(), 300)); 28 | 29 | const data = videoStream.read(); 30 | 31 | await streamCamera.stopCapture(); 32 | 33 | expect(data).not.toBeNull(); 34 | expect(data.length).toBeGreaterThan(0); 35 | }); 36 | 37 | test('StreamCamera can push to multiple streams', async () => { 38 | const streamCamera = new StreamCamera({ 39 | codec: Codec.MJPEG, 40 | }); 41 | 42 | await streamCamera.startCapture(); 43 | 44 | const videoStream1 = streamCamera.createStream(); 45 | const videoStream2 = streamCamera.createStream(); 46 | 47 | // Wait 300 ms for data to arrive 48 | await new Promise(resolve => setTimeout(() => resolve(), 300)); 49 | 50 | const data1 = videoStream1.read(); 51 | const data2 = videoStream2.read(); 52 | 53 | await streamCamera.stopCapture(); 54 | 55 | expect(data1).not.toBeNull(); 56 | expect(data1.length).toBeGreaterThan(0); 57 | 58 | expect(data2).not.toBeNull(); 59 | expect(data2.length).toBeGreaterThan(0); 60 | }); 61 | 62 | test('Method stopCapture() ends all streams', async () => { 63 | const streamCamera = new StreamCamera({ 64 | codec: Codec.MJPEG, 65 | }); 66 | 67 | await streamCamera.startCapture(); 68 | 69 | const videoStream1 = streamCamera.createStream(); 70 | const videoStream2 = streamCamera.createStream(); 71 | 72 | // Readable streams will only call "end" when all data has been read 73 | // Calling resume() turns on flowing mode, which auto-reads data as it arrives 74 | videoStream1.resume(); 75 | videoStream2.resume(); 76 | 77 | const stream1EndPromise = new Promise(resolve => videoStream1.on('end', () => resolve())); 78 | const stream2EndPromise = new Promise(resolve => videoStream2.on('end', () => resolve())); 79 | 80 | await streamCamera.stopCapture(); 81 | 82 | return Promise.all([ 83 | expect(stream1EndPromise).resolves.toBeUndefined(), 84 | expect(stream2EndPromise).resolves.toBeUndefined(), 85 | ]); 86 | }); 87 | -------------------------------------------------------------------------------- /src/lib/stream-camera.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcessWithoutNullStreams, spawn } from 'child_process'; 2 | import { EventEmitter } from 'events'; 3 | import * as stream from 'stream'; 4 | import { AwbMode, ExposureMode, Flip, Rotation } from '..'; 5 | import { getSharedArgs } from './shared-args'; 6 | 7 | export enum Codec { 8 | H264 = 'H264', 9 | MJPEG = 'MJPEG', 10 | } 11 | 12 | export enum SensorMode { 13 | AutoSelect = 0, 14 | Mode1 = 1, 15 | Mode2 = 2, 16 | Mode3 = 3, 17 | Mode4 = 4, 18 | Mode5 = 5, 19 | Mode6 = 6, 20 | Mode7 = 7, 21 | } 22 | 23 | export interface StreamOptions { 24 | width?: number; 25 | height?: number; 26 | rotation?: Rotation; 27 | flip?: Flip; 28 | bitRate?: number; 29 | fps?: number; 30 | codec?: Codec; 31 | sensorMode?: SensorMode; 32 | shutter?: number; 33 | sharpness?: number; 34 | contrast?: number; 35 | brightness?: number; 36 | saturation?: number; 37 | iso?: number; 38 | exposureCompensation?: number; 39 | exposureMode?: ExposureMode; 40 | awbMode?: AwbMode; 41 | analogGain?: number; 42 | digitalGain?: number; 43 | } 44 | 45 | declare interface StreamCamera { 46 | on(event: 'frame', listener: (image: Buffer) => void): this; 47 | once(event: 'frame', listener: (image: Buffer) => void): this; 48 | } 49 | 50 | class StreamCamera extends EventEmitter { 51 | private readonly options: StreamOptions; 52 | private childProcess?: ChildProcessWithoutNullStreams; 53 | private streams: Array = []; 54 | 55 | static readonly jpegSignature = Buffer.from([0xff, 0xd8, 0xff, 0xdb, 0x00, 0x84, 0x00]); 56 | 57 | constructor(options: StreamOptions = {}) { 58 | super(); 59 | 60 | this.options = { 61 | rotation: Rotation.Rotate0, 62 | flip: Flip.None, 63 | bitRate: 17000000, 64 | fps: 30, 65 | codec: Codec.H264, 66 | sensorMode: SensorMode.AutoSelect, 67 | ...options, 68 | }; 69 | } 70 | 71 | startCapture(): Promise { 72 | // eslint-disable-next-line no-async-promise-executor 73 | return new Promise(async (resolve, reject) => { 74 | // TODO: refactor promise logic to be more ergonomic 75 | // so that we don't need to try/catch here 76 | try { 77 | const args: Array = [ 78 | /** 79 | * Add the command-line arguments that are common to both `raspivid` and `raspistill` 80 | */ 81 | ...getSharedArgs(this.options), 82 | 83 | /** 84 | * Bit rate 85 | */ 86 | ...(this.options.bitRate ? ['--bitrate', this.options.bitRate.toString()] : []), 87 | 88 | /** 89 | * Frame rate 90 | */ 91 | ...(this.options.fps ? ['--framerate', this.options.fps.toString()] : []), 92 | 93 | /** 94 | * Codec 95 | * 96 | * H264 or MJPEG 97 | * 98 | */ 99 | ...(this.options.codec ? ['--codec', this.options.codec.toString()] : []), 100 | 101 | /** 102 | * Sensor mode 103 | * 104 | * Camera version 1.x (OV5647): 105 | * 106 | * | Mode | Size | Aspect Ratio | Frame rates | FOV | Binning | 107 | * |------|---------------------|--------------|-------------|---------|---------------| 108 | * | 0 | automatic selection | | | | | 109 | * | 1 | 1920x1080 | 16:9 | 1-30fps | Partial | None | 110 | * | 2 | 2592x1944 | 4:3 | 1-15fps | Full | None | 111 | * | 3 | 2592x1944 | 4:3 | 0.1666-1fps | Full | None | 112 | * | 4 | 1296x972 | 4:3 | 1-42fps | Full | 2x2 | 113 | * | 5 | 1296x730 | 16:9 | 1-49fps | Full | 2x2 | 114 | * | 6 | 640x480 | 4:3 | 42.1-60fps | Full | 2x2 plus skip | 115 | * | 7 | 640x480 | 4:3 | 60.1-90fps | Full | 2x2 plus skip | 116 | * 117 | * 118 | * Camera version 2.x (IMX219): 119 | * 120 | * | Mode | Size | Aspect Ratio | Frame rates | FOV | Binning | 121 | * |------|---------------------|--------------|-------------|---------|---------| 122 | * | 0 | automatic selection | | | | | 123 | * | 1 | 1920x1080 | 16:9 | 0.1-30fps | Partial | None | 124 | * | 2 | 3280x2464 | 4:3 | 0.1-15fps | Full | None | 125 | * | 3 | 3280x2464 | 4:3 | 0.1-15fps | Full | None | 126 | * | 4 | 1640x1232 | 4:3 | 0.1-40fps | Full | 2x2 | 127 | * | 5 | 1640x922 | 16:9 | 0.1-40fps | Full | 2x2 | 128 | * | 6 | 1280x720 | 16:9 | 40-90fps | Partial | 2x2 | 129 | * | 7 | 640x480 | 4:3 | 40-90fps | Partial | 2x2 | 130 | * 131 | */ 132 | ...(this.options.sensorMode ? ['--mode', this.options.sensorMode.toString()] : []), 133 | 134 | /** 135 | * Capture time (ms) 136 | * 137 | * Zero = forever 138 | * 139 | */ 140 | '--timeout', 141 | (0).toString(), 142 | 143 | /** 144 | * Do not display preview overlay on screen 145 | */ 146 | '--nopreview', 147 | 148 | /** 149 | * Output to stdout 150 | */ 151 | '--output', 152 | '-', 153 | ]; 154 | 155 | // Spawn child process 156 | this.childProcess = spawn('raspivid', args); 157 | 158 | // Listen for error event to reject promise 159 | this.childProcess.once('error', () => 160 | reject( 161 | new Error( 162 | "Could not start capture with StreamCamera. Are you running on a Raspberry Pi with 'raspivid' installed?", 163 | ), 164 | ), 165 | ); 166 | 167 | // Wait for first data event to resolve promise 168 | this.childProcess.stdout.once('data', () => resolve()); 169 | 170 | let stdoutBuffer = Buffer.alloc(0); 171 | 172 | // Listen for image data events and parse MJPEG frames if codec is MJPEG 173 | this.childProcess.stdout.on('data', (data: Buffer) => { 174 | this.streams.forEach(stream => stream.push(data)); 175 | 176 | if (this.options.codec !== Codec.MJPEG) return; 177 | 178 | stdoutBuffer = Buffer.concat([stdoutBuffer, data]); 179 | 180 | // Extract all image frames from the current buffer 181 | while (true) { 182 | const signatureIndex = stdoutBuffer.indexOf(StreamCamera.jpegSignature, 0); 183 | 184 | if (signatureIndex === -1) break; 185 | 186 | // Make sure the signature starts at the beginning of the buffer 187 | if (signatureIndex > 0) stdoutBuffer = stdoutBuffer.slice(signatureIndex); 188 | 189 | const nextSignatureIndex = stdoutBuffer.indexOf( 190 | StreamCamera.jpegSignature, 191 | StreamCamera.jpegSignature.length, 192 | ); 193 | 194 | if (nextSignatureIndex === -1) break; 195 | 196 | this.emit('frame', stdoutBuffer.slice(0, nextSignatureIndex)); 197 | 198 | stdoutBuffer = stdoutBuffer.slice(nextSignatureIndex); 199 | } 200 | }); 201 | 202 | // Listen for error events 203 | this.childProcess.stdout.on('error', err => this.emit('error', err)); 204 | this.childProcess.stderr.on('data', data => this.emit('error', new Error(data.toString()))); 205 | this.childProcess.stderr.on('error', err => this.emit('error', err)); 206 | 207 | // Listen for close events 208 | this.childProcess.stdout.on('close', () => this.emit('close')); 209 | } catch (err) { 210 | return reject(err); 211 | } 212 | }); 213 | } 214 | 215 | async stopCapture() { 216 | if (this.childProcess) { 217 | this.childProcess.kill(); 218 | } 219 | 220 | // Push null to each stream to indicate EOF 221 | // tslint:disable-next-line no-null-keyword 222 | this.streams.forEach(stream => stream.push(null)); 223 | 224 | this.streams = []; 225 | } 226 | 227 | createStream() { 228 | const newStream = new stream.Readable({ 229 | read: () => {}, 230 | }); 231 | 232 | this.streams.push(newStream); 233 | 234 | return newStream; 235 | } 236 | 237 | takeImage() { 238 | if (this.options.codec !== Codec.MJPEG) throw new Error("Codec must be 'MJPEG' to take image"); 239 | 240 | return new Promise(resolve => this.once('frame', data => resolve(data))); 241 | } 242 | } 243 | 244 | export default StreamCamera; 245 | -------------------------------------------------------------------------------- /src/util.test.ts: -------------------------------------------------------------------------------- 1 | import { spawnPromise } from './util'; 2 | 3 | test('spawnPromise() returns stdout from child process', async () => { 4 | const textToPrint = 'Hello world!'; 5 | 6 | const echoData = await spawnPromise('printf', [textToPrint]); 7 | 8 | expect(echoData.toString('ascii')).toBe(textToPrint); 9 | }); 10 | 11 | test('spawnPromise() throws error when child process prints to stderr', () => { 12 | const textToPrint = 'This should be an error!'; 13 | 14 | const promise = spawnPromise('/bin/sh', ['-c', `printf "${textToPrint}" 1>&2`]); 15 | 16 | expect(promise).rejects.toMatchObject(new Error(textToPrint)); 17 | }); 18 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import { spawn, SpawnOptions } from 'child_process'; 2 | 3 | export const spawnPromise = (command: string, args?: Array, options?: SpawnOptions) => 4 | new Promise((resolve, reject) => { 5 | const childProcess = spawn(command, args ?? [], options ?? {}); 6 | 7 | let stdoutData = Buffer.alloc(0); 8 | let stderrData = Buffer.alloc(0); 9 | 10 | if (!childProcess.stdout) { 11 | throw new Error(`No 'stdout' available on spawned process '${command}'`); 12 | } 13 | 14 | if (!childProcess.stderr) { 15 | throw new Error(`No 'stderr' available on spawned process '${command}'`); 16 | } 17 | 18 | childProcess.once('error', (err: Error) => reject(err)); 19 | 20 | childProcess.stdout.on( 21 | 'data', 22 | (data: Buffer) => (stdoutData = Buffer.concat([stdoutData, data])), 23 | ); 24 | childProcess.stdout.once('error', (err: Error) => reject(err)); 25 | 26 | childProcess.stderr.on( 27 | 'data', 28 | (data: Buffer) => (stderrData = Buffer.concat([stderrData, data])), 29 | ); 30 | childProcess.stderr.once('error', (err: Error) => reject(err)); 31 | 32 | childProcess.stdout.on('close', () => { 33 | if (stderrData.length > 0) return reject(new Error(stderrData.toString())); 34 | 35 | return resolve(stdoutData); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@lcdev/tsconfig", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "outDir": "./dist" 6 | }, 7 | "include": ["src/**/*.ts"], 8 | "exclude": ["node_modules"], 9 | "references": [] 10 | } 11 | --------------------------------------------------------------------------------