Skip to content

Commit f2af60a

Browse files
authoredJan 18, 2024
Merge pull request #822 from sebromero/sebromero/web-camera
Add WebSerial Camera Preview
2 parents 3b56169 + b696b59 commit f2af60a

File tree

10 files changed

+1434
-0
lines changed

10 files changed

+1434
-0
lines changed
 
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/*
2+
* This example shows how to capture images from the camera and send them over Web Serial.
3+
*
4+
* There is a companion web app that receives the images and displays them in a canvas.
5+
* It can be found in the "extras" folder of this library.
6+
* The on-board LED lights up while the image is being sent over serial.
7+
*
8+
* Instructions:
9+
* 1. Make sure the correct camera is selected in the #include section below by uncommenting the correct line.
10+
* 2. Upload this sketch to your camera-equipped board.
11+
* 3. Open the web app in a browser (Chrome or Edge) by opening the index.html file
12+
* in the "WebSerialCamera" folder which is located in the "extras" folder.
13+
*
14+
* Initial author: Sebastian Romero @sebromero
15+
*/
16+
17+
#include "camera.h"
18+
19+
#ifdef ARDUINO_NICLA_VISION
20+
#include "gc2145.h"
21+
GC2145 galaxyCore;
22+
Camera cam(galaxyCore);
23+
#define IMAGE_MODE CAMERA_RGB565
24+
#elif defined(ARDUINO_PORTENTA_H7_M7)
25+
// uncomment the correct camera in use
26+
#include "hm0360.h"
27+
HM0360 himax;
28+
// #include "himax.h";
29+
// HM01B0 himax;
30+
Camera cam(himax);
31+
#define IMAGE_MODE CAMERA_GRAYSCALE
32+
#elif defined(ARDUINO_GIGA)
33+
#include "ov767x.h"
34+
// uncomment the correct camera in use
35+
OV7670 ov767x;
36+
// OV7675 ov767x;
37+
Camera cam(ov767x);
38+
#define IMAGE_MODE CAMERA_RGB565
39+
#else
40+
#error "This board is unsupported."
41+
#endif
42+
43+
/*
44+
Other buffer instantiation options:
45+
FrameBuffer fb(0x30000000);
46+
FrameBuffer fb(320,240,2);
47+
48+
If resolution higher than 320x240 is required, please use external RAM via
49+
#include "SDRAM.h"
50+
FrameBuffer fb(SDRAM_START_ADDRESS);
51+
...
52+
// and adding in setup()
53+
SDRAM.begin();
54+
*/
55+
constexpr uint16_t CHUNK_SIZE = 512; // Size of chunks in bytes
56+
constexpr uint8_t RESOLUTION = CAMERA_R320x240; // CAMERA_R160x120
57+
constexpr uint8_t CONFIG_SEND_REQUEST = 2;
58+
constexpr uint8_t IMAGE_SEND_REQUEST = 1;
59+
60+
uint8_t START_SEQUENCE[4] = { 0xfa, 0xce, 0xfe, 0xed };
61+
uint8_t STOP_SEQUENCE[4] = { 0xda, 0xbb, 0xad, 0x00 };
62+
FrameBuffer fb;
63+
64+
/**
65+
* Blinks the LED a specified number of times.
66+
* @param ledPin The pin number of the LED.
67+
* @param count The number of times to blink the LED. Default is 0xFFFFFFFF.
68+
*/
69+
void blinkLED(int ledPin, uint32_t count = 0xFFFFFFFF) {
70+
while (count--) {
71+
digitalWrite(ledPin, LOW); // turn the LED on (HIGH is the voltage level)
72+
delay(50); // wait for a second
73+
digitalWrite(ledPin, HIGH); // turn the LED off by making the voltage LOW
74+
delay(50); // wait for a second
75+
}
76+
}
77+
78+
void setup() {
79+
pinMode(LED_BUILTIN, OUTPUT);
80+
pinMode(LEDR, OUTPUT);
81+
digitalWrite(LED_BUILTIN, HIGH);
82+
digitalWrite(LEDR, HIGH);
83+
84+
// Init the cam QVGA, 30FPS
85+
if (!cam.begin(RESOLUTION, IMAGE_MODE, 30)) {
86+
blinkLED(LEDR);
87+
}
88+
89+
blinkLED(LED_BUILTIN, 5);
90+
}
91+
92+
/**
93+
* Sends a chunk of data over a serial connection.
94+
*
95+
* @param buffer The buffer containing the data to be sent.
96+
* @param bufferSize The size of the buffer.
97+
*/
98+
void sendChunk(uint8_t* buffer, size_t bufferSize){
99+
Serial.write(buffer, bufferSize);
100+
Serial.flush();
101+
delay(1); // Optional: Add a small delay to allow the receiver to process the chunk
102+
}
103+
104+
/**
105+
* Sends a frame of camera image data over a serial connection.
106+
*/
107+
void sendFrame(){
108+
// Grab frame and write to serial
109+
if (cam.grabFrame(fb, 3000) == 0) {
110+
byte* buffer = fb.getBuffer();
111+
size_t bufferSize = cam.frameSize();
112+
digitalWrite(LED_BUILTIN, LOW);
113+
114+
sendChunk(START_SEQUENCE, sizeof(START_SEQUENCE));
115+
116+
// Split buffer into chunks
117+
for(size_t i = 0; i < bufferSize; i += CHUNK_SIZE) {
118+
size_t chunkSize = min(bufferSize - i, CHUNK_SIZE);
119+
sendChunk(buffer + i, chunkSize);
120+
}
121+
122+
sendChunk(STOP_SEQUENCE, sizeof(STOP_SEQUENCE));
123+
124+
digitalWrite(LED_BUILTIN, HIGH);
125+
} else {
126+
blinkLED(20);
127+
}
128+
}
129+
130+
/**
131+
* Sends the camera configuration over a serial connection.
132+
* This is used to configure the web app to display the image correctly.
133+
*/
134+
void sendCameraConfig(){
135+
Serial.write(IMAGE_MODE);
136+
Serial.write(RESOLUTION);
137+
Serial.flush();
138+
delay(1);
139+
}
140+
141+
void loop() {
142+
if(!Serial) {
143+
Serial.begin(115200);
144+
while(!Serial);
145+
}
146+
147+
if(!Serial.available()) return;
148+
149+
byte request = Serial.read();
150+
151+
switch(request){
152+
case IMAGE_SEND_REQUEST:
153+
sendFrame();
154+
break;
155+
case CONFIG_SEND_REQUEST:
156+
sendCameraConfig();
157+
break;
158+
}
159+
160+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# 📹 WebSerial Camera Stream
2+
3+
This folder contains a web application that provides a camera stream over WebSerial.
4+
This is an experimental feature not supported in all browsers. It's recommended to use Google Chrome.
5+
See [Browser Compatibility](https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility)
6+
7+
## Instructions
8+
9+
1. Upload the companion [Arduino sketch](../../examples/CameraCaptureWebSerial/CameraCaptureWebSerial.ino) to your board.
10+
2. Open the web app either by directly opening the index.html file or serving it via a webserver and opening the URL provided by the webserver.
11+
3. Click "Connect". Your board's serial port should show up in the popup. Select it. Click once again "Connect". The camera feed should start. If the board has been previously connected to the browser, it will connect automatically.
12+
4. (Optional) click "Save Image" if you want to save individual camera frames to your computer.
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/**
2+
* @fileoverview This file contains the main application logic.
3+
*
4+
* The application uses the Web Serial API to connect to the serial port.
5+
* Check the following links for more information on the Web Serial API:
6+
* https://developer.chrome.com/articles/serial/
7+
* https://wicg.github.io/serial/
8+
*
9+
* The flow of the application is as follows:
10+
* 1. The user clicks the "Connect" button or the browser automatically connects
11+
* to the serial port if it has been previously connected.
12+
* 2. The application requests the camera configuration (mode and resolution) from the board.
13+
* 3. The application starts reading the image data stream from the serial port.
14+
* It waits until the expected amount of bytes have been read and then processes the data.
15+
* 4. The processed image data is rendered on the canvas.
16+
*
17+
* @author Sebastian Romero
18+
*/
19+
20+
const connectButton = document.getElementById('connect');
21+
const refreshButton = document.getElementById('refresh');
22+
const startButton = document.getElementById('start');
23+
const saveImageButton = document.getElementById('save-image');
24+
const canvas = document.getElementById('bitmapCanvas');
25+
const ctx = canvas.getContext('2d');
26+
27+
const imageDataTransfomer = new ImageDataTransformer(ctx);
28+
imageDataTransfomer.setStartSequence([0xfa, 0xce, 0xfe, 0xed]);
29+
imageDataTransfomer.setStopSequence([0xda, 0xbb, 0xad, 0x00]);
30+
31+
// 🐣 Uncomment one of the following lines to apply a filter to the image data
32+
// imageDataTransfomer.filter = new GrayScaleFilter();
33+
// imageDataTransfomer.filter = new BlackAndWhiteFilter();
34+
// imageDataTransfomer.filter = new SepiaColorFilter();
35+
// imageDataTransfomer.filter = new PixelateFilter(8);
36+
// imageDataTransfomer.filter = new BlurFilter(8);
37+
const connectionHandler = new SerialConnectionHandler();
38+
39+
40+
// Connection handler event listeners
41+
42+
connectionHandler.onConnect = async () => {
43+
connectButton.textContent = 'Disconnect';
44+
cameraConfig = await connectionHandler.getConfig();
45+
if(!cameraConfig){
46+
console.error('🚫 Could not read camera configuration. Aborting...');
47+
return;
48+
}
49+
const imageMode = CAMERA_MODES[cameraConfig[0]];
50+
const imageResolution = CAMERA_RESOLUTIONS[cameraConfig[1]];
51+
if(!imageMode || !imageResolution){
52+
console.error(`🚫 Invalid camera configuration: ${cameraConfig[0]}, ${cameraConfig[1]}. Aborting...`);
53+
return;
54+
}
55+
imageDataTransfomer.setImageMode(imageMode);
56+
imageDataTransfomer.setResolution(imageResolution.width, imageResolution.height);
57+
renderStream();
58+
};
59+
60+
connectionHandler.onDisconnect = () => {
61+
connectButton.textContent = 'Connect';
62+
imageDataTransfomer.reset();
63+
};
64+
65+
66+
// Rendering logic
67+
68+
async function renderStream(){
69+
while(connectionHandler.isConnected()){
70+
if(imageDataTransfomer.isConfigured()) await renderFrame();
71+
}
72+
}
73+
74+
/**
75+
* Renders the image data for one frame from the board and renders it.
76+
* @returns {Promise<boolean>} True if a frame was rendered, false otherwise.
77+
*/
78+
async function renderFrame(){
79+
if(!connectionHandler.isConnected()) return;
80+
const imageData = await connectionHandler.getFrame(imageDataTransfomer);
81+
if(!imageData) return false; // Nothing to render
82+
if(!(imageData instanceof ImageData)) throw new Error('🚫 Image data is not of type ImageData');
83+
renderBitmap(ctx, imageData);
84+
return true;
85+
}
86+
87+
/**
88+
* Renders the image data on the canvas.
89+
* @param {CanvasRenderingContext2D} context The canvas context to render on.
90+
* @param {ImageData} imageData The image data to render.
91+
*/
92+
function renderBitmap(context, imageData) {
93+
context.canvas.width = imageData.width;
94+
context.canvas.height = imageData.height;
95+
context.clearRect(0, 0, canvas.width, canvas.height);
96+
context.putImageData(imageData, 0, 0);
97+
}
98+
99+
100+
// UI Event listeners
101+
102+
startButton.addEventListener('click', renderStream);
103+
104+
connectButton.addEventListener('click', async () => {
105+
if(connectionHandler.isConnected()){
106+
connectionHandler.disconnectSerial();
107+
} else {
108+
await connectionHandler.requestSerialPort();
109+
await connectionHandler.connectSerial();
110+
}
111+
});
112+
113+
refreshButton.addEventListener('click', () => {
114+
if(imageDataTransfomer.isConfigured()) renderFrame();
115+
});
116+
117+
saveImageButton.addEventListener('click', () => {
118+
const link = document.createElement('a');
119+
link.download = 'image.png';
120+
link.href = canvas.toDataURL();
121+
link.click();
122+
link.remove();
123+
});
124+
125+
// On page load event, try to connect to the serial port
126+
window.addEventListener('load', async () => {
127+
console.log('🚀 Page loaded. Trying to connect to serial port...');
128+
setTimeout(() => {
129+
connectionHandler.autoConnect();
130+
}, 1000);
131+
});
132+
133+
if (!("serial" in navigator)) {
134+
alert("The Web Serial API is not supported in your browser.");
135+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* @fileoverview This file contains the configuration for the camera.
3+
* @author Sebastian Romero
4+
*/
5+
6+
/**
7+
* The available camera (color) modes.
8+
* The Arduino sketch uses the same values to communicate which mode should be used.
9+
**/
10+
const CAMERA_MODES = {
11+
0: "GRAYSCALE",
12+
1: "BAYER",
13+
2: "RGB565"
14+
};
15+
16+
/**
17+
* The available camera resolutions.
18+
* The Arduino sketch uses the same values to communicate which resolution should be used.
19+
*/
20+
const CAMERA_RESOLUTIONS = {
21+
0: {
22+
"name": "QQVGA",
23+
"width": 160,
24+
"height": 120
25+
},
26+
1: {
27+
"name": "QVGA",
28+
"width": 320,
29+
"height": 240
30+
},
31+
2: {
32+
"name": "320x320",
33+
"width": 320,
34+
"height": 320
35+
},
36+
3: {
37+
"name": "VGA",
38+
"width": 640,
39+
"height": 480
40+
},
41+
5: {
42+
"name": "SVGA",
43+
"width": 800,
44+
"height": 600
45+
},
46+
6: {
47+
"name": "UXGA",
48+
"width": 1600,
49+
"height": 1200
50+
}
51+
};
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
/**
2+
* @fileoverview This file contains the filters that can be applied to an image.
3+
* @author Sebastian Romero
4+
*/
5+
6+
/**
7+
* Represents an image filter interface. This class is meant to be extended by subclasses.
8+
*/
9+
class ImageFilter {
10+
/**
11+
* Applies a filter to the given pixel data.
12+
* @param {Uint8Array} pixelData - The pixel data to apply the filter to. The pixel data gets modified in place.
13+
* @param {number} [width=null] - The width of the image. Defaults to null.
14+
* @param {number} [height=null] - The height of the image. Defaults to null.
15+
* @throws {Error} - Throws an error if the applyFilter method is not implemented.
16+
*/
17+
applyFilter(pixelData, width = null, height = null) {
18+
throw new Error('applyFilter not implemented');
19+
}
20+
}
21+
22+
/**
23+
* Represents a grayscale filter that converts an image to grayscale.
24+
* @extends ImageFilter
25+
*/
26+
class GrayScaleFilter extends ImageFilter {
27+
/**
28+
* Applies the grayscale filter to the given pixel data.
29+
* @param {Uint8ClampedArray} pixelData - The pixel data to apply the filter to.
30+
* @param {number} [width=null] - The width of the image.
31+
* @param {number} [height=null] - The height of the image.
32+
*/
33+
applyFilter(pixelData, width = null, height = null) {
34+
for (let i = 0; i < pixelData.length; i += 4) {
35+
const r = pixelData[i];
36+
const g = pixelData[i + 1];
37+
const b = pixelData[i + 2];
38+
const gray = (r + g + b) / 3;
39+
pixelData[i] = gray;
40+
pixelData[i + 1] = gray;
41+
pixelData[i + 2] = gray;
42+
}
43+
}
44+
}
45+
46+
/**
47+
* A class representing a black and white image filter.
48+
* @extends ImageFilter
49+
*/
50+
class BlackAndWhiteFilter extends ImageFilter {
51+
applyFilter(pixelData, width = null, height = null) {
52+
for (let i = 0; i < pixelData.length; i += 4) {
53+
const r = pixelData[i];
54+
const g = pixelData[i + 1];
55+
const b = pixelData[i + 2];
56+
const gray = (r + g + b) / 3;
57+
const bw = gray > 127 ? 255 : 0;
58+
pixelData[i] = bw;
59+
pixelData[i + 1] = bw;
60+
pixelData[i + 2] = bw;
61+
}
62+
}
63+
}
64+
65+
/**
66+
* Represents a color filter that applies a sepia tone effect to an image.
67+
* @extends ImageFilter
68+
*/
69+
class SepiaColorFilter extends ImageFilter {
70+
applyFilter(pixelData, width = null, height = null) {
71+
for (let i = 0; i < pixelData.length; i += 4) {
72+
const r = pixelData[i];
73+
const g = pixelData[i + 1];
74+
const b = pixelData[i + 2];
75+
const gray = (r + g + b) / 3;
76+
pixelData[i] = gray + 100;
77+
pixelData[i + 1] = gray + 50;
78+
pixelData[i + 2] = gray;
79+
}
80+
}
81+
}
82+
83+
/**
84+
* Represents a filter that applies a pixelation effect to an image.
85+
* @extends ImageFilter
86+
*/
87+
class PixelateFilter extends ImageFilter {
88+
89+
constructor(blockSize = 8){
90+
super();
91+
this.blockSize = blockSize;
92+
}
93+
94+
applyFilter(pixelData, width, height) {
95+
for (let y = 0; y < height; y += this.blockSize) {
96+
for (let x = 0; x < width; x += this.blockSize) {
97+
const blockAverage = this.getBlockAverage(x, y, width, height, pixelData, this.blockSize);
98+
99+
// Set all pixels in the block to the calculated average color
100+
for (let blockY = 0; blockY < this.blockSize && y + blockY < height; blockY++) {
101+
for (let blockX = 0; blockX < this.blockSize && x + blockX < width; blockX++) {
102+
const pixelIndex = ((y + blockY) * width + (x + blockX)) * 4;
103+
pixelData[pixelIndex] = blockAverage.red;
104+
pixelData[pixelIndex + 1] = blockAverage.green;
105+
pixelData[pixelIndex + 2] = blockAverage.blue;
106+
}
107+
}
108+
}
109+
}
110+
}
111+
112+
/**
113+
* Calculates the average RGB values of a block of pixels.
114+
*
115+
* @param {number} x - The x-coordinate of the top-left corner of the block.
116+
* @param {number} y - The y-coordinate of the top-left corner of the block.
117+
* @param {number} width - The width of the image.
118+
* @param {number} height - The height of the image.
119+
* @param {Uint8ClampedArray} pixels - The array of pixel data.
120+
* @returns {Object} - An object containing the average red, green, and blue values.
121+
*/
122+
getBlockAverage(x, y, width, height, pixels) {
123+
let totalRed = 0;
124+
let totalGreen = 0;
125+
let totalBlue = 0;
126+
const blockSizeSquared = this.blockSize * this.blockSize;
127+
128+
for (let blockY = 0; blockY < this.blockSize && y + blockY < height; blockY++) {
129+
for (let blockX = 0; blockX < this.blockSize && x + blockX < width; blockX++) {
130+
const pixelIndex = ((y + blockY) * width + (x + blockX)) * 4;
131+
totalRed += pixels[pixelIndex];
132+
totalGreen += pixels[pixelIndex + 1];
133+
totalBlue += pixels[pixelIndex + 2];
134+
}
135+
}
136+
137+
return {
138+
red: totalRed / blockSizeSquared,
139+
green: totalGreen / blockSizeSquared,
140+
blue: totalBlue / blockSizeSquared,
141+
};
142+
}
143+
144+
}
145+
146+
/**
147+
* Represents a filter that applies a blur effect to an image.
148+
* @extends ImageFilter
149+
*/
150+
class BlurFilter extends ImageFilter {
151+
constructor(radius = 8) {
152+
super();
153+
this.radius = radius;
154+
}
155+
156+
applyFilter(pixelData, width, height) {
157+
for (let y = 0; y < height; y++) {
158+
for (let x = 0; x < width; x++) {
159+
const pixelIndex = (y * width + x) * 4;
160+
161+
const averageColor = this.getAverageColor(x, y, width, height, pixelData, this.radius);
162+
pixelData[pixelIndex] = averageColor.red;
163+
pixelData[pixelIndex + 1] = averageColor.green;
164+
pixelData[pixelIndex + 2] = averageColor.blue;
165+
}
166+
}
167+
}
168+
169+
/**
170+
* Calculates the average color of a rectangular region in an image.
171+
*
172+
* @param {number} x - The x-coordinate of the top-left corner of the region.
173+
* @param {number} y - The y-coordinate of the top-left corner of the region.
174+
* @param {number} width - The width of the region.
175+
* @param {number} height - The height of the region.
176+
* @param {Uint8ClampedArray} pixels - The pixel data of the image.
177+
* @param {number} radius - The radius of the neighborhood to consider for each pixel.
178+
* @returns {object} - An object representing the average color of the region, with red, green, and blue components.
179+
*/
180+
getAverageColor(x, y, width, height, pixels, radius) {
181+
let totalRed = 0;
182+
let totalGreen = 0;
183+
let totalBlue = 0;
184+
let pixelCount = 0;
185+
186+
for (let offsetY = -radius; offsetY <= radius; offsetY++) {
187+
for (let offsetX = -radius; offsetX <= radius; offsetX++) {
188+
const neighborX = x + offsetX;
189+
const neighborY = y + offsetY;
190+
191+
if (neighborX >= 0 && neighborX < width && neighborY >= 0 && neighborY < height) {
192+
const pixelIndex = (neighborY * width + neighborX) * 4;
193+
totalRed += pixels[pixelIndex];
194+
totalGreen += pixels[pixelIndex + 1];
195+
totalBlue += pixels[pixelIndex + 2];
196+
pixelCount++;
197+
}
198+
}
199+
}
200+
201+
return {
202+
red: totalRed / pixelCount,
203+
green: totalGreen / pixelCount,
204+
blue: totalBlue / pixelCount,
205+
};
206+
}
207+
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/**
2+
* Represents an image data processor that converts raw image data to a specified pixel format.
3+
*
4+
* @author Sebastian Romero
5+
*/
6+
class ImageDataProcessor {
7+
pixelFormatInfo = {
8+
"RGB565": {
9+
"convert": this.convertRGB565ToRGB888,
10+
"bytesPerPixel": 2
11+
},
12+
"GRAYSCALE": {
13+
"convert": this.convertGrayScaleToRGB888,
14+
"bytesPerPixel": 1
15+
},
16+
"RGB888": {
17+
"convert": this.convertToRGB888,
18+
"bytesPerPixel": 3
19+
},
20+
"BAYER": {
21+
"convert": () => {throw new Error("BAYER conversion not implemented.")},
22+
"bytesPerPixel": 1
23+
}
24+
};
25+
26+
/**
27+
* Creates a new instance of the imageDataProcessor class.
28+
* @param {string|null} mode - The image mode of the image data processor. (Optional)
29+
* Possible values: RGB565, GRAYSCALE, RGB888, BAYER
30+
* @param {number|null} width - The width of the image data processor. (Optional)
31+
* @param {number|null} height - The height of the image data processor. (Optional)
32+
*/
33+
constructor(mode = null, width = null, height = null) {
34+
if(mode) this.setImageMode(mode);
35+
if(width && height) this.setResolution(width, height);
36+
}
37+
38+
/**
39+
* Sets the image mode of the image data processor.
40+
* Possible values: RGB565, GRAYSCALE, RGB888, BAYER
41+
*
42+
* @param {string} mode - The image mode of the image data processor.
43+
*/
44+
setImageMode(mode) {
45+
this.mode = mode;
46+
this.bytesPerPixel = this.pixelFormatInfo[mode].bytesPerPixel;
47+
}
48+
49+
/**
50+
* Sets the resolution of the target image.
51+
* @param {number} width - The width of the resolution.
52+
* @param {number} height - The height of the resolution.
53+
*/
54+
setResolution(width, height) {
55+
this.width = width;
56+
this.height = height;
57+
}
58+
59+
/**
60+
* Calculates the total number of bytes in the image data
61+
* based on the current image mode and resolution.
62+
*
63+
* @returns {number} The total number of bytes.
64+
*/
65+
getTotalBytes() {
66+
return this.width * this.height * this.bytesPerPixel;
67+
}
68+
69+
/**
70+
* Resets the state of the imageDataProcessor.
71+
* This resets the image mode, resolution, and bytes per pixel.
72+
*/
73+
reset() {
74+
this.mode = null;
75+
this.bytesPerPixel = null;
76+
this.width = null;
77+
this.height = null;
78+
}
79+
80+
/**
81+
* Converts a pixel value from RGB565 format to RGB888 format.
82+
* @param {number} pixelValue - The pixel value in RGB565 format.
83+
* @returns {number[]} - The RGB888 pixel value as an array of three values [R, G, B].
84+
*/
85+
convertRGB565ToRGB888(pixelValue) {
86+
// RGB565
87+
let r = (pixelValue >> (6 + 5)) & 0x1F;
88+
let g = (pixelValue >> 5) & 0x3F;
89+
let b = pixelValue & 0x1F;
90+
// RGB888 - amplify
91+
r <<= 3;
92+
g <<= 2;
93+
b <<= 3;
94+
return [r, g, b];
95+
}
96+
97+
/**
98+
* Converts a grayscale pixel value to RGB888 format.
99+
* @param {number} pixelValue - The grayscale pixel value.
100+
* @returns {number[]} - The RGB888 pixel value as an array of three values [R, G, B].
101+
*/
102+
convertGrayScaleToRGB888(pixelValue) {
103+
return [pixelValue, pixelValue, pixelValue];
104+
}
105+
106+
/**
107+
* Converts a pixel value to RGB888 format.
108+
* @param {number} pixelValue - The pixel value to convert.
109+
* @returns {number[]} - The RGB888 pixel value as an array of three values [R, G, B].
110+
*/
111+
convertToRGB888(pixelValue){
112+
return pixelValue;
113+
}
114+
115+
/**
116+
* Retrieves the pixel value from the source data at the specified index
117+
* using big endian: the most significant byte comes first.
118+
*
119+
* @param {Uint8Array} sourceData - The source data array.
120+
* @param {number} index - The index of the pixel value in the source data array.
121+
* @returns {number} The pixel value.
122+
*/
123+
getPixelValue(sourceData, index) {
124+
if (this.bytesPerPixel == 1) {
125+
return sourceData[index];
126+
} else if (this.bytesPerPixel == 2) {
127+
return (sourceData[index] << 8) | sourceData[index + 1];
128+
} else if (this.bytesPerPixel == 3) {
129+
return (sourceData[index] << 16) | (sourceData[index + 1] << 8) | sourceData[index + 2];
130+
} else if (this.bytesPerPixel == 4) {
131+
return (sourceData[index] << 24) | (sourceData[index + 1] << 16) | (sourceData[index + 2] << 8) | sourceData[index + 3];
132+
}
133+
134+
return 0;
135+
}
136+
137+
/**
138+
* Retrieves the image data from the given bytes by converting each pixel value.
139+
*
140+
* @param {Uint8Array} bytes - The raw byte array containing the image data.
141+
* @returns {Uint8ClampedArray} The image data as a Uint8ClampedArray containing RGBA values.
142+
*/
143+
convertToPixelData(bytes) {
144+
const BYTES_PER_ROW = this.width * this.bytesPerPixel;
145+
const dataContainer = new Uint8ClampedArray(this.width * this.height * 4); // 4 channels: R, G, B, A
146+
147+
for (let row = 0; row < this.height; row++) {
148+
for (let col = 0; col < this.width; col++) {
149+
const sourceDataIndex = (row * BYTES_PER_ROW) + (col * this.bytesPerPixel);
150+
const pixelValue = this.getPixelValue(bytes, sourceDataIndex, this.bytesPerPixel);
151+
const [r, g, b] = this.pixelFormatInfo[this.mode].convert(pixelValue);
152+
153+
const pixelIndex = ((row * this.width) + col) * 4; // 4 channels: R, G, B, A
154+
dataContainer[pixelIndex] = r; // Red channel
155+
dataContainer[pixelIndex + 1] = g; // Green channel
156+
dataContainer[pixelIndex + 2] = b; // Blue channel
157+
dataContainer[pixelIndex + 3] = 255; // Alpha channel (opacity)
158+
}
159+
}
160+
return dataContainer;
161+
}
162+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>Web Serial Bitmap Reader</title>
7+
<link rel="stylesheet" href="style.css">
8+
</head>
9+
<body>
10+
<div id="main-container">
11+
<canvas id="bitmapCanvas"></canvas>
12+
<div id="controls">
13+
<button id="connect">Connect</button>
14+
<button id="save-image">Save Image</button>
15+
<button id="refresh">Refresh</button>
16+
<button id="start">Start</button>
17+
</div>
18+
</div>
19+
<script src="filters.js"></script>
20+
<script src="transformers.js"></script>
21+
<script src="imageDataProcessor.js"></script>
22+
<script src="serialConnectionHandler.js"></script>
23+
<script src="cameraConfig.js"></script>
24+
<script src="app.js"></script>
25+
</body>
26+
</html>
Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
/**
2+
* @fileoverview This file contains the SerialConnectionHandler class.
3+
* It handles the connection between the browser and the Arduino board via Web Serial.
4+
* @author Sebastian Romero
5+
*/
6+
7+
const ArduinoUSBVendorId = 0x2341;
8+
const UserActionAbortError = 8;
9+
10+
/**
11+
* Handles the connection between the browser and the Arduino board via Web Serial.
12+
* Please note that for board with software serial over USB, the baud rate and other serial settings have no effect.
13+
*/
14+
class SerialConnectionHandler {
15+
/**
16+
* Represents a serial connection handler.
17+
* @constructor
18+
* @param {number} [baudRate=115200] - The baud rate of the serial connection.
19+
* @param {number} [dataBits=8] - The number of data bits.
20+
* @param {number} [stopBits=1] - The number of stop bits.
21+
* @param {string} [parity="none"] - The parity setting.
22+
* @param {string} [flowControl="none"] - The flow control setting.
23+
* @param {number} [bufferSize=2097152] - The size of the buffer in bytes. The default value is 2 MB. Max buffer size is 16MB.
24+
* @param {number} [timeout=2000] - The connection timeout value in milliseconds. The default value is 2000 ms.
25+
*/
26+
constructor(baudRate = 115200, dataBits = 8, stopBits = 1, parity = "none", flowControl = "none", bufferSize = 2 * 1024 * 1024, timeout = 2000) {
27+
this.baudRate = baudRate;
28+
this.dataBits = dataBits;
29+
this.stopBits = stopBits;
30+
this.flowControl = flowControl;
31+
this.bufferSize = bufferSize;
32+
this.parity = parity;
33+
this.timeout = timeout;
34+
this.currentPort = null;
35+
this.currentReader = null;
36+
this.currentTransformer = null;
37+
this.readableStreamClosed = null;
38+
this.registerEvents();
39+
}
40+
41+
/**
42+
* Sets the connection timeout for the serial connection.
43+
* @param {number} timeout - The timeout value in milliseconds.
44+
*/
45+
setConnectionTimeout(timeout) {
46+
this.timeout = timeout;
47+
}
48+
49+
/**
50+
* Prompts the user to select a serial port.
51+
* @returns {Promise<SerialPort>} The serial port that the user has selected.
52+
*/
53+
async requestSerialPort() {
54+
try {
55+
const port = await navigator.serial.requestPort({ filters: [{ usbVendorId: ArduinoUSBVendorId }] });
56+
this.currentPort = port;
57+
return port;
58+
} catch (error) {
59+
if (error.code != UserActionAbortError) {
60+
console.log(error);
61+
}
62+
return null;
63+
}
64+
}
65+
66+
/**
67+
* Checks if the browser is connected to a serial port.
68+
* @returns {boolean} True if the browser is connected, false otherwise.
69+
*/
70+
isConnected() {
71+
return this.currentPort?.readable != null;
72+
}
73+
74+
/**
75+
* Opens a connection to the given serial port by using the settings specified in the constructor.
76+
* If the port is already open, it will be closed first.
77+
* This method will call the `onConnect` callback before it returns.
78+
* @returns {boolean} True if the connection was successfully opened, false otherwise.
79+
*/
80+
async connectSerial() {
81+
try {
82+
// If the port is already open, close it
83+
if (this.isConnected()) await this.currentPort.close();
84+
await this.currentPort.open({
85+
baudRate: this.baudRate,
86+
parity: this.parity,
87+
dataBits: this.dataBits,
88+
stopBits: this.stopBits,
89+
bufferSize: this.bufferSize,
90+
flowControl: this.flowControl
91+
});
92+
console.log('✅ Connected to serial port.');
93+
if(this.onConnect) this.onConnect();
94+
return true;
95+
} catch (error) {
96+
return false;
97+
}
98+
}
99+
100+
/**
101+
* Disconnects from the current serial port.
102+
* If a reading operation is in progress, it will be canceled.
103+
* This function will call the `onDisconnect` callback before it returns.
104+
* @returns {Promise<void>} A promise that resolves when the port has been closed.
105+
*/
106+
async disconnectSerial() {
107+
if (!this.currentPort) return;
108+
try {
109+
const port = this.currentPort;
110+
this.currentPort = null;
111+
await this.currentReader?.cancel();
112+
await this.readableStreamClosed.catch(() => { }); // Ignores the error
113+
this.currentTransformer?.flush();
114+
await port.close();
115+
console.log('🔌 Disconnected from serial port.');
116+
if(this.onDisconnect) this.onDisconnect();
117+
} catch (error) {
118+
console.error('💣 Error occurred while disconnecting: ' + error.message);
119+
};
120+
}
121+
122+
/**
123+
* Tries to establish a connection to the first available serial port that has the Arduino USB vendor ID.
124+
* This only works if the user has previously granted the website access to that serial port.
125+
* @returns {Promise<boolean>} True if the connection was successfully opened, false otherwise.
126+
*/
127+
async autoConnect() {
128+
if (this.currentPort) {
129+
console.log('🔌 Already connected to a serial port.');
130+
return false;
131+
}
132+
133+
// Get all serial ports the user has previously granted the website access to.
134+
const ports = await navigator.serial.getPorts();
135+
136+
for (const port of ports) {
137+
console.log('👀 Serial port found with VID: 0x' + port.getInfo().usbVendorId.toString(16));
138+
if (port.getInfo().usbVendorId === ArduinoUSBVendorId) {
139+
this.currentPort = port;
140+
return await this.connectSerial(this.currentPort);
141+
}
142+
}
143+
return false;
144+
}
145+
146+
147+
/**
148+
* Reads a specified number of bytes from the serial connection.
149+
* @param {number} numBytes - The number of bytes to read.
150+
* @returns {Promise<Uint8Array>} - A promise that resolves to a Uint8Array containing the read bytes.
151+
*/
152+
async readBytes(numBytes) {
153+
return await this.readData(new BytesWaitTransformer(numBytes));
154+
}
155+
156+
/**
157+
* Reads the specified number of bytes from the serial port.
158+
* @param {Transformer} transformer The transformer that is used to process the bytes.
159+
* If the timeout is reached, the reader will be canceled and the read lock will be released.
160+
*/
161+
async readData(transformer) {
162+
if(!transformer) throw new Error('Transformer is null');
163+
if(!this.currentPort) return null;
164+
if(this.currentPort.readable.locked) {
165+
console.log('🔒 Stream is already locked. Ignoring request...');
166+
return null;
167+
}
168+
169+
const transformStream = new TransformStream(transformer);
170+
this.currentTransformer = transformer;
171+
// pipeThrough() cannot be used because we need a promise that resolves when the stream is closed
172+
// to be able to close the port. pipeTo() returns such a promise.
173+
// SEE: https://stackoverflow.com/questions/71262432/how-can-i-close-a-web-serial-port-that-ive-piped-through-a-transformstream
174+
this.readableStreamClosed = this.currentPort.readable.pipeTo(transformStream.writable);
175+
const reader = transformStream.readable.getReader();
176+
this.currentReader = reader;
177+
let timeoutID = null;
178+
179+
try {
180+
if (this.timeout) {
181+
timeoutID = setTimeout(() => {
182+
console.log('⌛️ Timeout occurred while reading.');
183+
if (this.currentPort?.readable) reader?.cancel();
184+
this.currentTransformer.flush();
185+
}, this.timeout);
186+
}
187+
const { value, done } = await reader.read();
188+
if (timeoutID) clearTimeout(timeoutID);
189+
190+
if (done) {
191+
console.log('🚫 Reader has been canceled');
192+
return null;
193+
}
194+
return value;
195+
} catch (error) {
196+
console.error('💣 Error occurred while reading: ' + error.message);
197+
} finally {
198+
// console.log('🔓 Releasing reader lock...');
199+
await reader?.cancel(); // Discards any enqueued data
200+
await this.readableStreamClosed.catch(() => { }); // Ignores the error
201+
reader?.releaseLock();
202+
this.currentReader = null;
203+
this.currentTransformer = null;
204+
}
205+
}
206+
207+
/**
208+
* Sends the provided byte array data through the current serial port.
209+
*
210+
* @param {ArrayBuffer} byteArray - The byte array data to send.
211+
* @returns {Promise<void>} - A promise that resolves when the data has been sent.
212+
*/
213+
async sendData(byteArray) {
214+
if (!this.currentPort?.writable) {
215+
console.log('🚫 Port is not writable. Ignoring request...');
216+
return;
217+
}
218+
const writer = this.currentPort.writable.getWriter();
219+
await writer.write(new Uint8Array(byteArray));
220+
await writer.close();
221+
}
222+
223+
/**
224+
* Reqests an image frame from the Arduino board by writing a 1 to the serial port.
225+
* @returns {Promise<void>} A promise that resolves when the frame has been requested and the write stream has been closed.
226+
*/
227+
async requestFrame() {
228+
// console.log('Writing 1 to the serial port...');
229+
// Write a 1 to the serial port
230+
return this.sendData([1]);
231+
}
232+
233+
/**
234+
* Requests the camera configuration from the board by writing a 2 to the serial port.
235+
* @returns {Promise} A promise that resolves with the configuration data.
236+
*/
237+
async requestConfig() {
238+
return this.sendData([2]);
239+
}
240+
241+
/**
242+
* Requests the camera resolution from the board and reads it back from the serial port.
243+
* The configuration simply consists of two bytes: the mode and the resolution.
244+
* @returns {Promise<ArrayBuffer>} The raw configuration data as an ArrayBuffer.
245+
*/
246+
async getConfig() {
247+
if (!this.currentPort) return;
248+
249+
await this.requestConfig();
250+
// console.log(`Trying to read 2 bytes...`);
251+
return await this.readBytes(2, this.timeout);
252+
}
253+
254+
/**
255+
* Requests a frame from the Arduino board and reads the specified number of bytes from the serial port afterwards.
256+
* Times out after the timeout in milliseconds specified in the constructor.
257+
* @param {Transformer} transformer The transformer that is used to process the bytes.
258+
*/
259+
async getFrame(transformer) {
260+
if (!this.currentPort) return;
261+
await this.requestFrame();
262+
return await this.readData(transformer, this.timeout);
263+
}
264+
265+
/**
266+
* Registers event listeners for the `connect` and `disconnect` events of the serial port.
267+
* The `connect` event is fired when a serial port becomes available not when it is opened.
268+
* When the `connect` event is fired, `autoConnect()` is called.
269+
* The `disconnect` event is fired when a serial port is lost.
270+
* When the `disconnect` event is fired, the `onDisconnect` callback is called.
271+
**/
272+
registerEvents() {
273+
navigator.serial.addEventListener("connect", (e) => {
274+
// Connect to `e.target` or add it to a list of available ports.
275+
console.log('🔌 Serial port became available. VID: 0x' + e.target.getInfo().usbVendorId.toString(16));
276+
this.autoConnect();
277+
});
278+
279+
navigator.serial.addEventListener("disconnect", (e) => {
280+
console.log('❌ Serial port lost. VID: 0x' + e.target.getInfo().usbVendorId.toString(16));
281+
this.currentPort = null;
282+
if(this.onDisconnect) this.onDisconnect();
283+
});
284+
}
285+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
:root {
2+
--main-control-color: #008184;
3+
--main-control-color-hover: #005c5f;
4+
--main-flexbox-gap: 16px;
5+
--secondary-text-color: #87898b;
6+
}
7+
8+
html {
9+
font-size: 14px;
10+
}
11+
12+
body {
13+
font-family: 'Open Sans', sans-serif;
14+
text-align: center;
15+
}
16+
17+
#main-container {
18+
display: flex;
19+
flex-direction: column;
20+
align-items: center;
21+
gap: 1rem;
22+
margin-top: 20px;
23+
}
24+
25+
#controls {
26+
display: flex;
27+
flex-direction: row;
28+
align-items: center;
29+
gap: 1rem;
30+
margin-top: 20px;
31+
}
32+
33+
canvas {
34+
border-radius: 5px;
35+
}
36+
37+
button {
38+
font-family: 'Open Sans', sans-serif;
39+
font-weight: 700;
40+
font-size: 1rem;
41+
justify-content: center;
42+
background-color: var(--main-control-color);
43+
color: #fff;
44+
cursor: pointer;
45+
letter-spacing: 1.28px;
46+
line-height: normal;
47+
outline: none;
48+
padding: 8px 18px;
49+
text-align: center;
50+
text-decoration: none;
51+
border: 2px solid transparent;
52+
border-radius: 32px;
53+
text-transform: uppercase;
54+
box-sizing: border-box;
55+
}
56+
57+
button:hover {
58+
background-color: var(--main-control-color-hover);
59+
}
60+
61+
#refresh {
62+
display: none;
63+
}
64+
65+
#start {
66+
display: none;
67+
}
Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
/**
2+
* @fileoverview This file contains classes that transform incoming data into higher-level data types.
3+
* @author Sebastian Romero
4+
*/
5+
6+
7+
/**
8+
* Represents a transformer that processes incoming data between start and stop sequences.
9+
*/
10+
class StartStopSequenceTransformer {
11+
constructor(startSequence = null, stopSequence = null, expectedBytes = null) {
12+
this.startSequence = new Uint8Array(startSequence);
13+
this.stopSequence = new Uint8Array(stopSequence);
14+
this.expectedBytes = expectedBytes;
15+
this.buffer = new Uint8Array(0);
16+
this.controller = undefined;
17+
this.waitingForStart = true;
18+
}
19+
20+
/**
21+
* Sets the start sequence for the received data.
22+
* This is used to disregard any data before the start sequence.
23+
* @param {Array<number>} startSequence - The start sequence as an array of numbers.
24+
*/
25+
setStartSequence(startSequence) {
26+
this.startSequence = new Uint8Array(startSequence);
27+
}
28+
29+
/**
30+
* Sets the stop sequence for the received data.
31+
* This is used to know when the data has finished being sent and should be processed.
32+
* @param {Array<number>} stopSequence - The stop sequence as an array of numbers.
33+
*/
34+
setStopSequence(stopSequence) {
35+
this.stopSequence = new Uint8Array(stopSequence);
36+
}
37+
38+
/**
39+
* Sets the expected number of bytes for the received data.
40+
* This is used to check if the number of bytes matches the expected amount
41+
* and discard the data if it doesn't.
42+
*
43+
* @param {number} expectedBytes - The expected number of bytes.
44+
*/
45+
setExpectedBytes(expectedBytes) {
46+
this.expectedBytes = expectedBytes;
47+
}
48+
49+
/**
50+
* Transforms the incoming chunk of data and enqueues the processed bytes to the controller
51+
* between start and stop sequences.
52+
*
53+
* @param {Uint8Array} chunk - The incoming chunk of data.
54+
* @param {TransformStreamDefaultController} controller - The controller for enqueuing processed bytes.
55+
* @returns {Promise<void>} - A promise that resolves when the transformation is complete.
56+
*/
57+
async transform(chunk, controller) {
58+
this.controller = controller;
59+
60+
// Concatenate incoming chunk with existing buffer
61+
this.buffer = new Uint8Array([...this.buffer, ...chunk]);
62+
let startIndex = 0;
63+
64+
// Only process data if at least one start and stop sequence is present in the buffer
65+
const minimumRequiredBytes = Math.min(this.startSequence.length, this.stopSequence.length);
66+
67+
while (this.buffer.length >= minimumRequiredBytes) {
68+
if (this.waitingForStart) {
69+
// Look for the start sequence
70+
startIndex = this.indexOfSequence(this.buffer, this.startSequence, startIndex);
71+
72+
if (startIndex === -1) {
73+
// No start sequence found, discard the buffer
74+
this.buffer = new Uint8Array(0);
75+
return;
76+
}
77+
78+
// Remove bytes before the start sequence including the start sequence
79+
this.buffer = this.buffer.slice(startIndex + this.startSequence.length);
80+
startIndex = 0; // Reset startIndex after removing bytes
81+
this.waitingForStart = false;
82+
}
83+
84+
// Look for the stop sequence
85+
const stopIndex = this.indexOfSequence(this.buffer, this.stopSequence, startIndex);
86+
87+
if (stopIndex === -1) {
88+
// No stop sequence found, wait for more data
89+
return;
90+
}
91+
92+
// Extract bytes between start and stop sequences
93+
const bytesToProcess = this.buffer.slice(startIndex, stopIndex);
94+
// Remove processed bytes from the buffer including the stop sequence.
95+
this.buffer = this.buffer.slice(stopIndex + this.stopSequence.length);
96+
97+
// Check if the number of bytes matches the expected amount
98+
if (this.expectedBytes !== null && bytesToProcess.length !== this.expectedBytes) {
99+
// Skip processing the bytes, but keep the remaining data in the buffer
100+
console.error(`🚫 Expected ${this.expectedBytes} bytes, but got ${bytesToProcess.length} bytes instead. Dropping data.`);
101+
this.waitingForStart = true;
102+
return;
103+
}
104+
105+
// Notify the controller that bytes have been processed
106+
controller.enqueue(this.convertBytes(bytesToProcess));
107+
this.waitingForStart = true;
108+
}
109+
}
110+
111+
/**
112+
* Flushes the buffer and discards any remaining bytes when the stream is closed.
113+
*
114+
* @param {WritableStreamDefaultController} controller - The controller for the writable stream.
115+
*/
116+
flush(controller) {
117+
// Discard the remaining data in the buffer
118+
this.buffer = new Uint8Array(0);
119+
}
120+
121+
122+
/**
123+
* Finds the index of the given sequence in the buffer.
124+
*
125+
* @param {Uint8Array} buffer - The buffer to search.
126+
* @param {Uint8Array} sequence - The sequence to find.
127+
* @param {number} startIndex - The index to start searching from.
128+
* @returns {number} - The index of the sequence in the buffer, or -1 if not found.
129+
*/
130+
indexOfSequence(buffer, sequence, startIndex) {
131+
for (let i = startIndex; i <= buffer.length - sequence.length; i++) {
132+
if (this.isSubarray(buffer, sequence, i)) {
133+
return i;
134+
}
135+
}
136+
return -1;
137+
}
138+
139+
/**
140+
* Checks if a subarray is present at a given index in the buffer.
141+
*
142+
* @param {Uint8Array} buffer - The buffer to check.
143+
* @param {Uint8Array} subarray - The subarray to check.
144+
* @param {number} index - The index to start checking from.
145+
* @returns {boolean} - True if the subarray is present at the given index, false otherwise.
146+
*/
147+
isSubarray(buffer, subarray, index) {
148+
for (let i = 0; i < subarray.length; i++) {
149+
if (buffer[index + i] !== subarray[i]) {
150+
return false;
151+
}
152+
}
153+
return true;
154+
}
155+
156+
/**
157+
* Converts bytes into higher-level data types.
158+
* This method is meant to be overridden by subclasses.
159+
* @param {Uint8Array} bytes
160+
* @returns
161+
*/
162+
convertBytes(bytes) {
163+
return bytes;
164+
}
165+
166+
}
167+
168+
169+
/**
170+
* A transformer class that waits for a specific number of bytes before processing them.
171+
*/
172+
class BytesWaitTransformer {
173+
constructor(waitBytes = 1) {
174+
this.waitBytes = waitBytes;
175+
this.buffer = new Uint8Array(0);
176+
this.controller = undefined;
177+
}
178+
179+
/**
180+
* Sets the number of bytes to wait before processing the data.
181+
* @param {number} waitBytes - The number of bytes to wait.
182+
*/
183+
setBytesToWait(waitBytes) {
184+
this.waitBytes = waitBytes;
185+
}
186+
187+
/**
188+
* Converts bytes into higher-level data types.
189+
* This method is meant to be overridden by subclasses.
190+
* @param {Uint8Array} bytes
191+
* @returns
192+
*/
193+
convertBytes(bytes) {
194+
return bytes;
195+
}
196+
197+
/**
198+
* Transforms the incoming chunk of data and enqueues the processed bytes to the controller.
199+
* It does so when the buffer contains at least the specified number of bytes.
200+
* @param {Uint8Array} chunk - The incoming chunk of data.
201+
* @param {TransformStreamDefaultController} controller - The controller for enqueuing processed bytes.
202+
* @returns {Promise<void>} - A promise that resolves when the transformation is complete.
203+
*/
204+
async transform(chunk, controller) {
205+
this.controller = controller;
206+
207+
// Concatenate incoming chunk with existing buffer
208+
this.buffer = new Uint8Array([...this.buffer, ...chunk]);
209+
210+
while (this.buffer.length >= this.waitBytes) {
211+
// Extract the required number of bytes
212+
const bytesToProcess = this.buffer.slice(0, this.waitBytes);
213+
214+
// Remove processed bytes from the buffer
215+
this.buffer = this.buffer.slice(this.waitBytes);
216+
217+
// Notify the controller that bytes have been processed
218+
controller.enqueue(this.convertBytes(bytesToProcess));
219+
}
220+
}
221+
222+
/**
223+
* Flushes the buffer and processes any remaining bytes when the stream is closed.
224+
*
225+
* @param {WritableStreamDefaultController} controller - The controller for the writable stream.
226+
*/
227+
flush(controller) {
228+
if (this.buffer.length > 0) {
229+
// Handle remaining bytes (if any) when the stream is closed
230+
const remainingBytes = this.buffer.slice();
231+
console.log("Remaining bytes:", remainingBytes);
232+
233+
// Notify the controller that remaining bytes have been processed
234+
controller?.enqueue(remainingBytes);
235+
}
236+
}
237+
}
238+
239+
/**
240+
* Represents an Image Data Transformer that converts bytes into image data.
241+
* See other example for PNGs here: https://github.com/mdn/dom-examples/blob/main/streams/png-transform-stream/png-transform-stream.js
242+
* @extends StartStopSequenceTransformer
243+
*/
244+
class ImageDataTransformer extends StartStopSequenceTransformer {
245+
/**
246+
* Creates a new instance of the Transformer class.
247+
* @param {CanvasRenderingContext2D} context - The canvas rendering context.
248+
* @param {number} [width=null] - The width of the image.
249+
* @param {number} [height=null] - The height of the image.
250+
* @param {string} [imageMode=null] - The image mode.
251+
*/
252+
constructor(context, width = null, height = null, imageMode = null) {
253+
super();
254+
this.context = context;
255+
this.imageDataProcessor = new ImageDataProcessor();
256+
if (width && height){
257+
this.setResolution(width, height);
258+
}
259+
if (imageMode){
260+
this.setImageMode(imageMode);
261+
}
262+
}
263+
264+
/**
265+
* Sets the resolution of the camera image that is being processed.
266+
*
267+
* @param {number} width - The width of the resolution.
268+
* @param {number} height - The height of the resolution.
269+
*/
270+
setResolution(width, height) {
271+
this.width = width;
272+
this.height = height;
273+
this.imageDataProcessor.setResolution(width, height);
274+
if(this.isConfigured()){
275+
this.setExpectedBytes(this.imageDataProcessor.getTotalBytes());
276+
}
277+
}
278+
279+
/**
280+
* Sets the image mode of the camera image that is being processed.
281+
* Possible values: RGB565, GRAYSCALE, RGB888, BAYER
282+
*
283+
* @param {string} imageMode - The image mode to set.
284+
*/
285+
setImageMode(imageMode) {
286+
this.imageMode = imageMode;
287+
this.imageDataProcessor.setImageMode(imageMode);
288+
if(this.isConfigured()){
289+
this.setBytesToWait(this.imageDataProcessor.getTotalBytes());
290+
}
291+
}
292+
293+
/**
294+
* Checks if the image data processor is configured.
295+
* This is true if the image mode and resolution are set.
296+
* @returns {boolean} True if the image data processor is configured, false otherwise.
297+
*/
298+
isConfigured() {
299+
return this.imageMode && this.width && this.height;
300+
}
301+
302+
/**
303+
* Resets the state of the transformer.
304+
*/
305+
reset() {
306+
this.imageMode = null;
307+
this.width = null;
308+
this.height = null;
309+
this.imageDataProcessor.reset();
310+
}
311+
312+
/**
313+
* Converts the given raw bytes into an ImageData object by using the ImageDataProcessor.
314+
*
315+
* @param {Uint8Array} bytes - The bytes to convert.
316+
* @returns {ImageData} The converted ImageData object.
317+
*/
318+
convertBytes(bytes) {
319+
let pixelData = this.imageDataProcessor.convertToPixelData(bytes);
320+
321+
if(this.filter){
322+
this.filter.applyFilter(pixelData, imageDataTransfomer.width, imageDataTransfomer.height);
323+
}
324+
325+
const imageData = this.context.createImageData(imageDataTransfomer.width, imageDataTransfomer.height);
326+
imageData.data.set(pixelData);
327+
return imageData;
328+
}
329+
}

0 commit comments

Comments
 (0)
Please sign in to comment.