Skip to content

Add WebSerial Camera Preview #822

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 38 commits into from
Jan 18, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
229f167
Send bytes in chunks
sebromero May 23, 2023
ea9c545
Add web serial camera app
sebromero May 23, 2023
940cf82
Catch edge case
sebromero May 23, 2023
70f6b23
Remove unused code
sebromero May 24, 2023
d54dba8
Working version for RGB
sebromero May 24, 2023
3d0fb5e
Working version for both
sebromero May 24, 2023
599e764
Add image processor
sebromero May 24, 2023
66f1a02
Add save function
sebromero May 24, 2023
5ced73c
Implement connection handler
sebromero May 24, 2023
e2789f4
Add callbacks for connect / disconnect
sebromero May 24, 2023
a85053c
Add documentation to serial connection handler
sebromero May 24, 2023
fe1cef1
Better error reporting
sebromero May 26, 2023
a7a35a0
Change order of config bytes
sebromero Jun 30, 2023
2313636
Add auto config for camera
sebromero Jun 30, 2023
fb8a54f
Better error handling
sebromero Dec 29, 2023
7149a1b
Extract magic strings
sebromero Dec 29, 2023
e2e949b
Add references
sebromero Dec 29, 2023
815151d
Stream works, disconnect broken
sebromero Dec 29, 2023
084393b
Fix deadlock
sebromero Dec 29, 2023
302c8a6
Add documentation to image processor
sebromero Dec 29, 2023
8d7a3fa
Add docs to config file
sebromero Dec 29, 2023
1e5cdc8
Use default serial values
sebromero Jan 8, 2024
8a3ca3b
Remove dependency on 2D Context
sebromero Jan 8, 2024
29adcd8
Working with new transformer
sebromero Jan 8, 2024
d7d0819
Add documentation to app file
sebromero Jan 8, 2024
c656793
Add more documentation
sebromero Jan 8, 2024
28d4e37
Add dedicated sketch for WebSerial
sebromero Jan 9, 2024
b6d20c4
Add filters
sebromero Jan 9, 2024
41fbc7a
Fix incorrect variable name
sebromero Jan 9, 2024
e343588
Add documentation
sebromero Jan 9, 2024
354a32b
Merge branch 'main' into sebromero/web-camera
sebromero Jan 9, 2024
4c58a74
Restore original sketch
sebromero Jan 9, 2024
e9aa024
Add setter for connection timeout
sebromero Jan 17, 2024
c9299d4
Add start stop sequence transformer
sebromero Jan 17, 2024
4f72204
Better handle data in transformer
sebromero Jan 17, 2024
5aa18d7
Refactor example sketch
sebromero Jan 17, 2024
a6ea68f
Use red LED to indicate errors
sebromero Jan 17, 2024
b696b59
Clarify sketch instructions
sebromero Jan 18, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Add more documentation
  • Loading branch information
sebromero committed Jan 8, 2024
commit c6567937479a048d74b6df12849fede52e739a3e
18 changes: 3 additions & 15 deletions libraries/Camera/extras/WebSerialCamera/imageDataProcessor.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
/**
* Represents an image data processor that converts raw image data to a specified pixel format.
* This could be turned into a transform stream and be used in the serial connection handler.
* See example here: https://github.com/mdn/dom-examples/blob/main/streams/png-transform-stream/png-transform-stream.js
*
* @author Sebastian Romero
*/
Expand All @@ -27,14 +25,13 @@ class ImageDataProcessor {

/**
* Creates a new instance of the imageDataProcessor class.
* @param {CanvasRenderingContext2D} context - The 2D rendering context of the canvas.
* @param {string|null} mode - The image mode of the image data processor. (Optional)
* Possible values: RGB565, GRAYSCALE, RGB888, BAYER
* @param {number|null} width - The width of the image data processor. (Optional)
* @param {number|null} height - The height of the image data processor. (Optional)
*/
constructor(mode = null, width = null, height = null) {
if(mode) this.setMode(mode);
if(mode) this.setImageMode(mode);
if(width && height) this.setResolution(width, height);
}

Expand All @@ -44,7 +41,7 @@ class ImageDataProcessor {
*
* @param {string} mode - The image mode of the image data processor.
*/
setMode(mode) {
setImageMode(mode) {
this.mode = mode;
this.bytesPerPixel = this.pixelFormatInfo[mode].bytesPerPixel;
}
Expand All @@ -69,15 +66,6 @@ class ImageDataProcessor {
return this.width * this.height * this.bytesPerPixel;
}

/**
* Checks if the image data processor is configured.
* This is true if the image mode and resolution are set.
* @returns {boolean} True if the image data processor is configured, false otherwise.
*/
isConfigured() {
return this.mode && this.width && this.height;
}

/**
* Resets the state of the imageDataProcessor.
* This resets the image mode, resolution, and bytes per pixel.
Expand Down Expand Up @@ -152,7 +140,7 @@ class ImageDataProcessor {
* @param {Uint8Array} bytes - The raw byte array containing the image data.
* @returns {Uint8ClampedArray} The image data as a Uint8ClampedArray containing RGBA values.
*/
getImageData(bytes) {
convertToPixelData(bytes) {
const BYTES_PER_ROW = this.width * this.bytesPerPixel;
const dataContainer = new Uint8ClampedArray(this.width * this.height * 4); // 4 channels: R, G, B, A

Expand Down
80 changes: 56 additions & 24 deletions libraries/Camera/extras/WebSerialCamera/serialConnectionHandler.js
Original file line number Diff line number Diff line change
@@ -1,23 +1,40 @@
/**
* @fileoverview This file contains the SerialConnectionHandler class.
* It handles the connection between the browser and the Arduino board via Web Serial.
* @author Sebastian Romero
*/

const ArduinoUSBVendorId = 0x2341;
const UserActionAbortError = 8;

/**
* Handles the connection between the browser and the Arduino board via Web Serial.
* Please note that for board with software serial over USB, the baud rate and other serial settings have no effect.
*/
class SerialConnectionHandler {
/**
* Represents a serial connection handler.
* @constructor
* @param {number} [baudRate=115200] - The baud rate of the serial connection.
* @param {number} [dataBits=8] - The number of data bits.
* @param {number} [stopBits=1] - The number of stop bits.
* @param {string} [parity="none"] - The parity setting.
* @param {string} [flowControl="none"] - The flow control setting.
* @param {number} [bufferSize=2097152] - The size of the buffer in bytes. Max buffer size is 16MB
* @param {number} [timeout=2000] - The connection timeout value in milliseconds.
*/
constructor(baudRate = 115200, dataBits = 8, stopBits = 1, parity = "none", flowControl = "none", bufferSize = 2 * 1024 * 1024, timeout = 2000) {
this.baudRate = baudRate;
this.dataBits = dataBits;
this.stopBits = stopBits;
this.flowControl = flowControl;
// Max buffer size is 16MB
this.bufferSize = bufferSize;
this.parity = parity;
this.timeout = timeout;
this.currentPort = null;
this.currentReader = null;
this.currentTransformer = null;
this.readableStreamClosed = null;
this.transformer = new BytesWaitTransformer();
this.registerEvents();
}

Expand All @@ -38,14 +55,6 @@ class SerialConnectionHandler {
}
}

/**
* Sets the transformer that is used to convert bytes into higher-level data types.
* @param {*} transformer
*/
setTransformer(transformer) {
this.transformer = transformer;
}

/**
* Checks if the browser is connected to a serial port.
* @returns {boolean} True if the browser is connected, false otherwise.
Expand Down Expand Up @@ -93,7 +102,7 @@ class SerialConnectionHandler {
this.currentPort = null;
await this.currentReader?.cancel();
await this.readableStreamClosed.catch(() => { }); // Ignores the error
this.transformer.flush();
this.currentTransformer?.flush();
await port.close();
console.log('🔌 Disconnected from serial port.');
if(this.onDisconnect) this.onDisconnect();
Expand Down Expand Up @@ -126,21 +135,31 @@ class SerialConnectionHandler {
return false;
}


/**
* Reads a specified number of bytes from the serial connection.
* @param {number} numBytes - The number of bytes to read.
* @returns {Promise<Uint8Array>} - A promise that resolves to a Uint8Array containing the read bytes.
*/
async readBytes(numBytes) {
return await this.readData(new BytesWaitTransformer(numBytes));
}

/**
* Reads the specified number of bytes from the serial port.
* @param {number} numBytes The number of bytes to read.
* @param {number} timeout The timeout in milliseconds.
* @param {Transformer} transformer The transformer that is used to process the bytes.
* If the timeout is reached, the reader will be canceled and the read lock will be released.
*/
async readBytes(numBytes, timeout = null) {
async readData(transformer) {
if(!transformer) throw new Error('Transformer is null');
if(!this.currentPort) return null;
if(this.currentPort.readable.locked) {
console.log('🔒 Stream is already locked. Ignoring request...');
return null;
}

this.transformer.setBytesToWait(numBytes);
const transformStream = new TransformStream(this.transformer);
const transformStream = new TransformStream(transformer);
this.currentTransformer = transformer;
// pipeThrough() cannot be used because we need a promise that resolves when the stream is closed
// to be able to close the port. pipeTo() returns such a promise.
// SEE: https://stackoverflow.com/questions/71262432/how-can-i-close-a-web-serial-port-that-ive-piped-through-a-transformstream
Expand All @@ -150,12 +169,12 @@ class SerialConnectionHandler {
let timeoutID = null;

try {
if (timeout) {
if (this.timeout) {
timeoutID = setTimeout(() => {
console.log('⌛️ Timeout occurred while reading.');
if (this.currentPort?.readable) reader?.cancel();
this.transformer.flush();
}, timeout);
}, this.timeout);
}
const { value, done } = await reader.read();
if (timeoutID) clearTimeout(timeoutID);
Expand All @@ -173,9 +192,16 @@ class SerialConnectionHandler {
await this.readableStreamClosed.catch(() => { }); // Ignores the error
reader?.releaseLock();
this.currentReader = null;
this.currentTransformer = null;
}
}

/**
* Sends the provided byte array data through the current serial port.
*
* @param {ArrayBuffer} byteArray - The byte array data to send.
* @returns {Promise<void>} - A promise that resolves when the data has been sent.
*/
async sendData(byteArray) {
if (!this.currentPort?.writable) {
console.log('🚫 Port is not writable. Ignoring request...');
Expand All @@ -196,10 +222,19 @@ class SerialConnectionHandler {
return this.sendData([1]);
}

/**
* Requests the camera configuration from the board by writing a 2 to the serial port.
* @returns {Promise} A promise that resolves with the configuration data.
*/
async requestConfig() {
return this.sendData([2]);
}

/**
* Requests the camera resolution from the board and reads it back from the serial port.
* The configuration simply consists of two bytes: the mode and the resolution.
* @returns {Promise<ArrayBuffer>} The raw configuration data as an ArrayBuffer.
*/
async getConfig() {
if (!this.currentPort) return;

Expand All @@ -211,15 +246,12 @@ class SerialConnectionHandler {
/**
* Requests a frame from the Arduino board and reads the specified number of bytes from the serial port afterwards.
* Times out after the timeout in milliseconds specified in the constructor.
* @param {number} totalBytes The number of bytes to read.
* @param {Transformer} transformer The transformer that is used to process the bytes.
*/
async getFrame(totalBytes) {
async getFrame(transformer) {
if (!this.currentPort) return;

await this.requestFrame();
// console.log(`Trying to read ${totalBytes} bytes...`);
// Read the given amount of bytes
return await this.readBytes(totalBytes, this.timeout);
return await this.readData(transformer, this.timeout);
}

/**
Expand Down
111 changes: 92 additions & 19 deletions libraries/Camera/extras/WebSerialCamera/transformers.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
/**
* A transformer class that waits for a specific number of bytes before processing them.
*/
class BytesWaitTransformer {
constructor(waitBytes = 1) {
this.waitBytes = waitBytes;
this.buffer = new Uint8Array(0);
this.controller = undefined;
}

/**
* Sets the number of bytes to wait before processing the data.
* @param {number} waitBytes - The number of bytes to wait.
*/
setBytesToWait(waitBytes) {
this.waitBytes = waitBytes;
}
Expand All @@ -19,7 +26,13 @@ class BytesWaitTransformer {
return bytes;
}


/**
* Transforms the incoming chunk of data and enqueues the processed bytes to the controller.
* It does so when the buffer contains at least the specified number of bytes.
* @param {Uint8Array} chunk - The incoming chunk of data.
* @param {TransformStreamDefaultController} controller - The controller for enqueuing processed bytes.
* @returns {Promise<void>} - A promise that resolves when the transformation is complete.
*/
async transform(chunk, controller) {
this.controller = controller;

Expand All @@ -38,47 +51,107 @@ class BytesWaitTransformer {
}
}

/**
* Flushes the buffer and processes any remaining bytes when the stream is closed.
*
* @param {WritableStreamDefaultController} controller - The controller for the writable stream.
*/
flush(controller) {
if (this.buffer.length > 0) {
// Handle remaining bytes (if any) when the stream is closed
const remainingBytes = this.buffer.slice();
console.log("Remaining bytes:", remainingBytes);

// Notify the controller that remaining bytes have been processed
controller.enqueue(remainingBytes);
controller?.enqueue(remainingBytes);
}
}
}


/**
* Represents an Image Data Transformer that converts bytes into image data.
* See other example for PNGs here: https://github.com/mdn/dom-examples/blob/main/streams/png-transform-stream/png-transform-stream.js
* @extends BytesWaitTransformer
*/
class ImageDataTransformer extends BytesWaitTransformer {
constructor(context, width, height, imageMode) {
super(1);
this.width = width;
this.height = height;
/**
* Creates a new instance of the Transformer class.
* @param {CanvasRenderingContext2D} context - The canvas rendering context.
* @param {number} [width=null] - The width of the image.
* @param {number} [height=null] - The height of the image.
* @param {string} [imageMode=null] - The image mode.
*/
constructor(context, width = null, height = null, imageMode = null) {
super();
this.context = context;
this.imageDataProcessor = new ImageDataProcessor();
if (width && height){
this.setResolution(width, height);
}
if (imageMode){
this.setImageMode(imageMode);
}
}

/**
* Sets the resolution of the camera image that is being processed.
*
* @param {number} width - The width of the resolution.
* @param {number} height - The height of the resolution.
*/
setResolution(width, height) {
this.width = width;
this.height = height;
this.imageDataProcessor.setResolution(width, height);
if(this.isConfigured()){
this.setBytesToWait(this.imageDataProcessor.getTotalBytes());
}
}

/**
* Sets the image mode of the camera image that is being processed.
* Possible values: RGB565, GRAYSCALE, RGB888, BAYER
*
* @param {string} imageMode - The image mode to set.
*/
setImageMode(imageMode) {
this.imageMode = imageMode;
this.imageDataProcessor.setImageMode(imageMode);
if(this.isConfigured()){
this.setBytesToWait(this.imageDataProcessor.getTotalBytes());
}
}

convertBytes(bytes) {
console.log("Converting bytes");
let a = new Uint8Array(bytes);
// Iterate over UInt8Array
for (let i = 0; i < a.length; i++) {
a[i] = a[i] * 2;
}
/**
* Checks if the image data processor is configured.
* This is true if the image mode and resolution are set.
* @returns {boolean} True if the image data processor is configured, false otherwise.
*/
isConfigured() {
return this.imageMode && this.width && this.height;
}

// const imageData = new ImageData(this.width, this.height);
// for (let i = 0; i < bytes.length; i++) {
// imageData.data[i] = bytes[i];
// }
// return imageData;
return bytes;
/**
* Resets the state of the transformer.
*/
reset() {
this.imageMode = null;
this.width = null;
this.height = null;
this.imageDataProcessor.reset();
}

/**
* Converts the given raw bytes into an ImageData object by using the ImageDataProcessor.
*
* @param {Uint8Array} bytes - The bytes to convert.
* @returns {ImageData} The converted ImageData object.
*/
convertBytes(bytes) {
const pixelData = this.imageDataProcessor.convertToPixelData(bytes);
const imageData = this.context.createImageData(imageDataTransfomer.width, imageDataTransfomer.height);
imageData.data.set(pixelData);
return imageData;
}
}