diff --git a/.gitignore b/.gitignore index ad90b97..5ce8b8d 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ stats.json # dev environments .vscode/* **/.DS_Store +test_images/*.jpeg diff --git a/.npmignore b/.npmignore index 87e583d..543b066 100644 --- a/.npmignore +++ b/.npmignore @@ -1,4 +1,5 @@ src/ +test_images/ yarn.lock tsconfig.json diff --git a/README.md b/README.md index 4bd419a..8c0a793 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,8 @@ Note that this example produces a raw H264 video. Wrapping it in a video contain - [`SensorMode`](#sensormode) - [`ExposureMode`](#exposuremode) - [`AwbMode`](#awbmode) +- [`DynamicRange`](#dynamicRange) +- [`ImageEffectMode`](#imageEffectMode) ## `StillCamera` @@ -261,8 +263,25 @@ const stillCamera = new StillCamera({ - `exposureCompensation: number` - _Range: `-10`-`10`; Default: `0`_ - [`exposureMode: ExposureMode`](#exposuremode) - _Default: `ExposureMode.Auto`_ - [`awbMode: AwbMode`](#awbmode) - _Default: `AwbMode.Auto`_ -- `analogGain: number` - _Default: `0`_ -- `digitalGain: number` - _Default: `0`_ +- `awbGains: [number, number]` - _Range: `0.0`-`8.0`; Default: `undefined`_ +- `analogGain: number` - _Range: `1.0`-`12.0` (OV5647: `1.0`-`8.0`); Default: `0`_ +- `digitalGain: number` - _Range: `1.0`-`64.0`; Default: `0`_ +- `quality: number` - _Range: `0`-`100`; Default: `100`_ +- `colorEffect: [number, number]` (U, V) - _Range: `0-255`; Default: `undefined`_ +- [`imageEffectMode: ImageEffectMode`](#imageeffectmode) - _Default: `ImageEffectMode.None`_ +- [`dynamicRange: DynamicRange`](#dynamicrange) - _Default: `DynamicRange.Off`_ +- `videoStabilization: boolean` - _Default: `false`_ +- `raw: boolean` - _Default: `false`_ +- [`meteringMode`](#meteringMode) - _Default: `MeteringMode.Off`_ +- `thumbnail: [number, number, number] | false` (X, Y, Q) - _Default: `[64, 48, 35]`; `false` to dismiss thumbnail_ +- [`flickerMode`](#flickerMode) - _Default: `FlickerMode.Off`_ +- `burst: boolean` - _Default: `false`_ +- `roi: [number, number, number, number]` (X, Y, W, H) - _Range: `0.0`-`1.0`; Default: `[0, 0, 1, 1]` (Full sensor)_ +- `statistics: boolean` - _Default: `false`_ +- `exif: { [key:string]: string | number } | false` - _Default: Default camera values; `false` to dissmis default exif_ +- `gpsExif: boolean` - _Default: `false`_ +- `annotate: (number | string)[]` - _Default: No annotations_ +- `annotateExtra: [number, string, string]` (fontSize, fontColor, backgroundColor) - _Default: `[32, '0xff', '0x808000']`_ ### `StillCamera.takeImage(): Promise` @@ -307,8 +326,19 @@ const streamCamera = new StreamCamera({ - `exposureCompensation: number` - _Range: `-10`-`10`; Default: `0`_ - [`exposureMode: ExposureMode`](#exposuremode) - _Default: `ExposureMode.Auto`_ - [`awbMode: AwbMode`](#awbmode) - _Default: `AwbMode.Auto`_ -- `analogGain: number` - _Default: `0`_ -- `digitalGain: number` - _Default: `0`_ +- `awbGains: [number, number]` - _Range: `0.0`-`8.0`; Default: `undefined`_ +- `analogGain: number` - _Range: `1.0`-`12.0` (OV5647: `1.0`-`8.0`); Default: `0`_ +- `digitalGain: number` - _Range: `1.0`-`64.0`; Default: `0`_ +- `colorEffect: [number, number]` (U, V) - _Range: `0-255`; Default: `undefined`_ +- [`imageEffectMode: ImageEffectMode`](#imageeffectmode) - _Default: `ImageEffectMode.None`_ +- [`dynamicRange: DynamicRange`](#dynamicrange) - _Default: `DynamicRange.Off`_ +- `videoStabilization: boolean` - _Default: `false`_ +- [`meteringMode`](#meteringMode) - _Default: `MeteringMode.Off`_ +- [`flickerMode`](#flickerMode) - _Default: `FlickerMode.Off`_ +- `roi: [number, number, number, number]` (X, Y, W, H) - _Range: `0.0`-`1.0`; Default: `[0, 0, 1, 1]` (Full sensor)_ +- `statistics: boolean` - _Default: `false`_ +- `annotate: (number | string)[]` - _Default: No annotations_ +- `annotateExtra: [number, string, string]` (fontSize, fontColor, backgroundColor) - _Default: `[32, '0xff', '0x808000']`_ ### `startCapture(): Promise` @@ -446,7 +476,19 @@ These are slightly different depending on the version of Raspberry Pi camera you | 4 | 1640x1232 | 4:3 | 0.1-40fps | Full | 2x2 | | 5 | 1640x922 | 16:9 | 0.1-40fps | Full | 2x2 | | 6 | 1280x720 | 16:9 | 40-90fps | Partial | 2x2 | -| 7 | 640x480 | 4:3 | 40-90fps | Partial | 2x2 | +| 7 | 640x480 | 4:3 | 40-200fps* | Partial | 2x2 | + +*For frame rates over 120fps, it is necessary to turn off automatic exposure and gain control using -ex off. Doing so should achieve the higher frame rates, but exposure time and gains will need to be set to fixed values supplied by the user. + +#### HQ Camera (IMX477): + +| Mode | Size | Aspect Ratio | Frame rates | FOV | Binning | +|------|---------------------|--------------|-------------|---------|-------------| +| 0 | automatic selection | | | | | +| 1 | 2028x1080 | 169:90 | 0.1-50fps | Partial | 2x2 binned | +| 2 | 2028x1520 | 4:3 | 0.1-50fps | Full | 2x2 binned | +| 3 | 4056x3040 | 4:3 | 0.005-10fps | Full | None | +| 4 | 1332x990 | 74:55 | 50.1-120fps | Partial | 2x2 binned | ## `ExposureMode` @@ -489,3 +531,71 @@ White balance mode options. ```javascript import { AwbMode } from 'pi-camera-connect'; ``` + +## `ImageEffectMode` + +Image Effect options. + +- `ImageEffectMode.None` +- `ImageEffectMode.Negative` +- `ImageEffectMode.Solarise` +- `ImageEffectMode.Sketch` +- `ImageEffectMode.Denoise` +- `ImageEffectMode.Emboss` +- `ImageEffectMode.OilPaint` +- `ImageEffectMode.Hatch` +- `ImageEffectMode.GPen` +- `ImageEffectMode.Pastel` +- `ImageEffectMode.Watercolour` +- `ImageEffectMode.Film` +- `ImageEffectMode.Blur` +- `ImageEffectMode.Saturation` +- `ImageEffectMode.ColourSwap` +- `ImageEffectMode.WashedOut` +- `ImageEffectMode.Posterise` +- `ImageEffectMode.ColourPoint` +- `ImageEffectMode.ColourBalance` +- `ImageEffectMode.Cartoon` + +```javascript +import { ImageEffectMode } from 'pi-camera-connect'; +``` + +## `DynamicRange` + +Dynamic Range options. + +- `DynamicRange.Off` +- `DynamicRange.Low` +- `DynamicRange.Medium` +- `DynamicRange.High` + +```javascript +import { DynamicRange } from 'pi-camera-connect'; +``` + +## `MeteringMode` + +Dynamic Range options. + +- `MeteringMode.Average` +- `MeteringMode.Spot` +- `MeteringMode.Backlit` +- `MeteringMode.Matrix` + +```javascript +import { MeteringMode } from 'pi-camera-connect'; +``` + +## `FlickerMode` + +Dynamic Range options. + +- `FlickerMode.Off` +- `FlickerMode.Auto` +- `FlickerMode.Frq50hz` +- `FlickerMode.Frq60hz` + +```javascript +import { FlickerMode } from 'pi-camera-connect'; +``` diff --git a/src/index.ts b/src/index.ts index b35ddd9..58ddf71 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,3 +44,47 @@ export enum AwbMode { Horizon = 'horizon', GreyWorld = 'greyworld', } + +export enum ImageEffectMode { + None = 'none', + Negative = 'negative', + Solarise = 'solarise', + Sketch = 'sketch', + Denoise = 'denoise', + Emboss = 'emboss', + OilPaint = 'oilpaint', + Hatch = 'hatch', + GPen = 'gpen', + Pastel = 'pastel', + Watercolour = 'watercolour', + Film = 'film', + Blur = 'blur', + Saturation = 'saturation', + ColourSwap = 'colourswap', + WashedOut = 'washedout', + Posterise = 'posterise', + ColourPoint = 'colourpoint', + ColourBalance = 'colourbalance', + Cartoon = 'cartoon', +} + +export enum DynamicRange { + Off = 'off', + Low = 'low', + Medium = 'medium', + High = 'high', +} + +export enum MeteringMode { + Average = 'average', + Spot = 'spot', + Backlit = 'backlit', + Matrix = 'matrix', +} + +export enum FlickerMode { + Off = 'off', + Auto = 'auto', + Frq50hz = '50hz', + Frq60hz = '60hz', +} diff --git a/src/lib/shared-args.ts b/src/lib/shared-args.ts index aa02fba..50aeadc 100644 --- a/src/lib/shared-args.ts +++ b/src/lib/shared-args.ts @@ -66,12 +66,12 @@ export function getSharedArgs(options: StillOptions | StreamOptions): string[] { ...(options.saturation ? ['--saturation', options.saturation.toString()] : []), /** - * ISO + * ISO (100 to 800) */ ...(options.iso ? ['--ISO', options.iso.toString()] : []), /** - * EV Compensation + * EV Compensation (-10 to 10; default 0) */ ...(options.exposureCompensation ? ['--ev', options.exposureCompensation.toString()] : []), @@ -85,14 +85,98 @@ export function getSharedArgs(options: StillOptions | StreamOptions): string[] { */ ...(options.awbMode ? ['--awb', options.awbMode.toString()] : []), + /** + * Sets the blue and red channel gains if awbMode is Off + */ + ...(options.awbGains ? ['--awbgains', options.awbGains.toString()] : []), + /** * Analog Gain + * Sets the analog gain value directly on the sensor (floating point value from + * 1.0 to 8.0 for the OV5647 sensor on Camera Module V1, and 1.0 to 12.0 for the + * IMX219 sensor on Camera Module V2 and the IMX447 on the HQ Camera). */ ...(options.analogGain ? ['--analoggain', options.analogGain.toString()] : []), /** * Digital Gain + * Sets the digital gain value applied by the ISP + * (floating point value from 1.0 to 64.0, + * but values over about 4.0 willproduce overexposed images) */ ...(options.digitalGain ? ['--digitalgain', options.digitalGain.toString()] : []), + + /** + * Image Effect + */ + ...(options.imageEffectMode ? ['--imxfx', options.imageEffectMode.toString()] : []), + + /** + * Dynamic Range Control + */ + ...(options.dynamicRange ? ['--drc', options.dynamicRange] : []), + + /** + * Color Effects + * The supplied U and V parameters (range 0 to 255) are applied to + * the U and Y channels of the image. For example, --colfx 128:128 + * should result in a monochrome image. + */ + ...(options.colorEffect ? ['--colfx', options.colorEffect.join(':')] : []), + + /** + * Metering + * Specify the metering mode used for the preview and capture. + */ + ...(options.meteringMode ? ['--metering', options.meteringMode] : []), + + /** + * Flicker Avoid Mode + * Set a mode to compensate for lights flickering at the mains frequency, + * which can be seen as a dark horizontal band across an image. + * Flicker avoidance locks the exposure time to a multiple of the mains + * flicker frequency (8.33ms for 60Hz, or 10ms for 50Hz). + * This means that images can be noisier as the control algorithm has to + * increase the gain instead of exposure time should it wish for an + * intermediate exposure value. auto can be confused by external factors, + * therefore it is preferable to leave this setting off unless actually required. + */ + ...(options.flickerMode ? ['--flicker', options.flickerMode] : []), + + /** + * Video Stabilization + * In video mode only, turn on video stabilization. + */ + ...(options.videoStabilization ? ['--vstab'] : []), + + /** + * Statistics + * Force recomputation of statistics on stills capture pass. Digital gain and AWB are + * recomputed based on the actual capture frame statistics, + * rather than the preceding preview frame. + */ + ...(options.statistics ? ['--stats'] : []), + + /** + * Sensor region of interest + * Allows the specification of the area of the sensor to be used as + * the source for the preview and capture. This is defined as x,y for + * the top left corner, and a width and height, all values in + * normalised coordinates (0.0-1.0). + */ + ...(options.roi ? ['--roi', options.roi.toString()] : []), + + /** + * Annotate + * Adds some text and/or metadata to the picture. + */ + ...(options.annotate ? options.annotate.flatMap(e => ['--annotate', e.toString()]) : []), + + /** + * Annotate extra + * Specifies annotation size, text colour, and background color. + * Colors are in hex YUV format. Size ranges from 6 - 160; default is 32 + */ + ...(options.annotateExtra ? ['--annotateex', options.annotateExtra.toString()] : []), ]; } diff --git a/src/lib/still-camera.test.ts b/src/lib/still-camera.test.ts index 9485e6a..a71d227 100644 --- a/src/lib/still-camera.test.ts +++ b/src/lib/still-camera.test.ts @@ -1,9 +1,41 @@ +import * as fs from 'fs'; +import { performance } from 'perf_hooks'; +import { ImageEffectMode } from '..'; + import StillCamera from './still-camera'; +const TEST_IMAGES_DIR = 'test_images'; + +if (!fs.existsSync(TEST_IMAGES_DIR)) { + fs.mkdirSync(TEST_IMAGES_DIR); +} + test('takeImage() returns JPEG', async () => { - const stillCamera = new StillCamera(); + const t0 = performance.now(); + + const stillCamera = new StillCamera({ + imageEffectMode: ImageEffectMode.Sketch, + + // 2X zoom + roi: [0.25, 0.25, 0.5, 0.5], + + // Size 50 black text on white background + annotateExtra: [50, '0x00', '0x8080FF'], + + // Custom text and Date/Time + annotate: [4, 'pi-camera-connect %Y-%m-%d %X'], + + exif: { + 'IFD0.Artist': 'pi-camera-connect', + 'IFD0.ImageDescription': 'This is a custom description', + }, + }); const jpegImage = await stillCamera.takeImage(); + const t1 = performance.now(); + + const time = ((t1 - t0) / 1000).toFixed(2); + await fs.promises.writeFile(`test_images/stillCapture_(${time}-secs).jpeg`, jpegImage, 'binary'); expect(jpegImage.indexOf(StillCamera.jpegSignature)).toBe(0); }); diff --git a/src/lib/still-camera.ts b/src/lib/still-camera.ts index df7ee14..77ed62b 100644 --- a/src/lib/still-camera.ts +++ b/src/lib/still-camera.ts @@ -1,4 +1,13 @@ -import { AwbMode, ExposureMode, Flip, Rotation } from '..'; +import { + AwbMode, + DynamicRange, + ExposureMode, + FlickerMode, + Flip, + ImageEffectMode, + MeteringMode, + Rotation, +} from '..'; import { spawnPromise } from '../util'; import { getSharedArgs } from './shared-args'; @@ -17,8 +26,25 @@ export interface StillOptions { exposureCompensation?: number; exposureMode?: ExposureMode; awbMode?: AwbMode; + awbGains?: [number, number]; analogGain?: number; digitalGain?: number; + imageEffectMode?: ImageEffectMode; + colorEffect?: [number, number]; // U,V + dynamicRange?: DynamicRange; + videoStabilization?: boolean; + raw?: boolean; + quality?: number; + statistics?: boolean; + thumbnail?: [number, number, number] | false; // X, Y, Q + meteringMode?: MeteringMode; + flickerMode?: FlickerMode; + burst?: boolean; + roi?: [number, number, number, number]; // X, Y, W, H + exif?: { [key: string]: string | number } | false; + gpsExif?: boolean; + annotate?: (number | string)[]; + annotateExtra?: [number, string, string]; // fontSize, fontColor, backgroundColor } export default class StillCamera { @@ -54,6 +80,58 @@ export default class StillCamera { */ '--nopreview', + /** + * RAW (Save Bayer Data) + * This option inserts the raw Bayer data from the camera in to the + * JPEG metadata. + */ + ...(this.options.raw ? ['--raw'] : []), + + /** + * JPEG Quality + * Quality 100 is almost completely uncompressed. 75 is a good allround value. + */ + ...(this.options.quality ? ['--quality', this.options.quality.toString()] : []), + + /** + * Burst + * This prevents the camera from returning to preview mode in between captures, + * meaning that captures can be taken closer together. + */ + ...(this.options.burst ? ['--burst'] : []), + + /** + * Thumbnail Settings (x:y:quality) + * Allows specification of the thumbnail image inserted in to the JPEG file. + * If not specified, defaults are a size of 64x48 at quality 35. + * `false` will remove the default thumbnail + */ + ...(Array.isArray(this.options.thumbnail) || this.options.thumbnail === false + ? ['--thumb', !this.options.thumbnail ? 'none' : this.options.thumbnail.join(':')] + : []), + + /** + * Exif information + * Allows the insertion of specific EXIF tags into the JPEG image. + * You can have up to 32 EXIF tag entries. + * Will overwrite any EXIF tag set automatically by the camera. + */ + ...(this.options.exif + ? Object.keys(this.options.exif).flatMap(key => [ + '--exif', + `${key}=${(this.options.exif as any)[key as keyof StillOptions['exif']]}`, + ]) + : []), + // `false` will remove all the default EXIF information + ...(this.options.exif === false ? ['--exif', 'none'] : []), + + /** + * GPS Exif + * Applies real-time EXIF information from any attached GPS dongle (using GSPD) to the image + * (requires libgps.so to be installed) + */ + ...(this.options.gpsExif ? ['--gpsexif'] : []), + /** * Output to stdout */ @@ -61,7 +139,7 @@ export default class StillCamera { '-', ]); } catch (err) { - if (err.code === 'ENOENT') { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { throw new Error( "Could not take image with StillCamera. Are you running on a Raspberry Pi with 'raspistill' installed?", ); diff --git a/src/lib/stream-camera.test.ts b/src/lib/stream-camera.test.ts index 22f1b6c..3d96bef 100644 --- a/src/lib/stream-camera.test.ts +++ b/src/lib/stream-camera.test.ts @@ -1,6 +1,17 @@ +import * as fs from 'fs'; +import { performance } from 'perf_hooks'; + import StreamCamera, { Codec } from './stream-camera'; +const TEST_IMAGES_DIR = 'test_images'; + +if (!fs.existsSync(TEST_IMAGES_DIR)) { + fs.mkdirSync(TEST_IMAGES_DIR); +} + test('Method takeImage() grabs JPEG from MJPEG stream', async () => { + const t0 = performance.now(); + const streamCamera = new StreamCamera({ codec: Codec.MJPEG, }); @@ -10,6 +21,10 @@ test('Method takeImage() grabs JPEG from MJPEG stream', async () => { const jpegImage = await streamCamera.takeImage(); await streamCamera.stopCapture(); + const t1 = performance.now(); + + const time = ((t1 - t0) / 1000).toFixed(2); + await fs.promises.writeFile(`test_images/streamCapture_(${time}-secs).jpeg`, jpegImage, 'binary'); expect(jpegImage.indexOf(StreamCamera.jpegSignature)).toBe(0); }); diff --git a/src/lib/stream-camera.ts b/src/lib/stream-camera.ts index 693ab3a..848e27c 100644 --- a/src/lib/stream-camera.ts +++ b/src/lib/stream-camera.ts @@ -1,7 +1,16 @@ import { ChildProcessWithoutNullStreams, spawn } from 'child_process'; import { EventEmitter } from 'events'; import * as stream from 'stream'; -import { AwbMode, ExposureMode, Flip, Rotation } from '..'; +import { + AwbMode, + DynamicRange, + ExposureMode, + FlickerMode, + Flip, + ImageEffectMode, + MeteringMode, + Rotation, +} from '..'; import { getSharedArgs } from './shared-args'; export enum Codec { @@ -38,8 +47,19 @@ export interface StreamOptions { exposureCompensation?: number; exposureMode?: ExposureMode; awbMode?: AwbMode; + awbGains?: [number, number]; analogGain?: number; digitalGain?: number; + imageEffectMode?: ImageEffectMode; + colorEffect?: [number, number]; // U,V + dynamicRange?: DynamicRange; + videoStabilization?: boolean; + statistics?: boolean; + meteringMode?: MeteringMode; + flickerMode?: FlickerMode; + roi?: [number, number, number, number]; // X, Y, W, H + annotate?: (number | string)[]; + annotateExtra?: [number, string, string]; // fontSize, fontColor, backgroundColor } declare interface StreamCamera { @@ -126,7 +146,22 @@ class StreamCamera extends EventEmitter { * | 4 | 1640x1232 | 4:3 | 0.1-40fps | Full | 2x2 | * | 5 | 1640x922 | 16:9 | 0.1-40fps | Full | 2x2 | * | 6 | 1280x720 | 16:9 | 40-90fps | Partial | 2x2 | - * | 7 | 640x480 | 4:3 | 40-90fps | Partial | 2x2 | + * | 7 | 640x480 | 4:3 | 40-200fps* | Partial | 2x2 | + * + * *For frame rates over 120fps, it is necessary to turn off automatic exposure and gain + * control using -ex off. Doing so should achieve the higher frame rates, but exposure + * time and gains will need to be set to fixed values supplied by the user. + * + * + * HQ Camera (IMX477): + * + * | Mode | Size | Aspect Ratio | Frame rates | FOV | Binning | + * |------|---------------------|--------------|-------------|---------|-------------| + * | 0 | automatic selection | | | | | + * | 1 | 2028x1080 | 169:90 | 0.1-50fps | Partial | 2x2 binned | + * | 2 | 2028x1520 | 4:3 | 0.1-50fps | Full | 2x2 binned | + * | 3 | 4056x3040 | 4:3 | 0.005-10fps | Full | None | + * | 4 | 1332x990 | 74:55 | 50.1-120fps | Partial | 2x2 binned | * */ ...(this.options.sensorMode ? ['--mode', this.options.sensorMode.toString()] : []),