blob: 431fda3a26047440d55c1c3b59bafe70c1c6ef71 [file] [log] [blame]
Philipp Schradere2e27ff2024-02-25 22:08:55 -08001import {Component, NgZone, OnInit, ViewChild, ElementRef} from '@angular/core';
Philipp Schradere5d13942024-03-17 15:44:35 -07002import {ErrorResponse} from '@org_frc971/scouting/webserver/requests/messages/error_response_generated';
Philipp Schraderad2a6fb2024-03-20 20:51:36 -07003import {ActionsSubmitter} from '@org_frc971/scouting/www/rpc';
Philipp Schradere2e27ff2024-02-25 22:08:55 -08004import {Builder, ByteBuffer} from 'flatbuffers';
5import * as pako from 'pako';
6
7declare var cv: any;
8declare var Module: any;
9
10// The number of milliseconds between QR code scans.
11const SCAN_PERIOD = 500;
12
13@Component({
14 selector: 'app-scan',
15 templateUrl: './scan.ng.html',
16 styleUrls: ['../app/common.css', './scan.component.css'],
17})
18export class ScanComponent implements OnInit {
19 @ViewChild('video')
20 public video: ElementRef;
21
22 @ViewChild('canvas')
23 public canvas: ElementRef;
24
25 errorMessage: string = '';
26 progressMessage: string = 'Waiting for QR code(s)';
27 scanComplete: boolean = false;
28 videoStartedSuccessfully = false;
29
30 qrCodeValuePieces: string[] = [];
31 qrCodeValuePieceSize = 0;
32
33 scanStream: MediaStream | null = null;
34 scanTimer: ReturnType<typeof setTimeout> | null = null;
35
Philipp Schraderad2a6fb2024-03-20 20:51:36 -070036 constructor(
37 private ngZone: NgZone,
38 private actionsSubmitter: ActionsSubmitter
39 ) {}
Philipp Schradere2e27ff2024-02-25 22:08:55 -080040
41 ngOnInit() {
42 // If the user switched away from this tab, then the onRuntimeInitialized
43 // attribute will already be set. No need to load OpenCV again. If it's not
44 // loaded, however, we need to load it.
45 if (!Module['onRuntimeInitialized']) {
46 Module['onRuntimeInitialized'] = () => {
47 // Since the WASM code doesn't know about the Angular zone, we force
48 // it into the correct zone so that the UI gets updated properly.
49 this.ngZone.run(() => {
50 this.startScanning();
51 });
52 };
53 // Now that we set up the hook, we can load OpenCV.
54 this.loadOpenCv();
55 } else {
56 this.startScanning();
57 }
58 }
59
60 ngOnDestroy() {
61 clearInterval(this.scanTimer);
62
63 // Explicitly stop the streams so that the camera isn't being locked
64 // unnecessarily. I.e. other processes can use it too.
65 if (this.scanStream) {
66 this.scanStream.getTracks().forEach((track) => {
67 track.stop();
68 });
69 }
70 }
71
72 public ngAfterViewInit() {
73 // Start the video playback.
74 // It would be nice to let the user select which camera gets used. For now,
75 // we give the "environment" hint so that it faces away from the user.
76 if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
77 navigator.mediaDevices
78 .getUserMedia({video: {facingMode: 'environment'}})
79 .then(
80 (stream) => {
81 this.scanStream = stream;
82 this.video.nativeElement.srcObject = stream;
83 this.video.nativeElement.play();
84 this.videoStartedSuccessfully = true;
85 },
86 (reason) => {
87 this.progressMessage = '';
88 this.errorMessage = `Failed to start video: ${reason}`;
89 }
90 );
91 }
92 }
93
94 async scan() {
95 if (!this.videoStartedSuccessfully) {
96 return;
97 }
98
99 // Take a capture of the video stream. That capture can then be used by
100 // OpenCV to perform the QR code detection. Due to my inexperience, I could
101 // only make this code work if I size the (invisible) canvas to match the
102 // video element. Otherwise, I'd get cropped images.
103 // Can we stream the video directly into the canvas?
104 const width = this.video.nativeElement.clientWidth;
105 const height = this.video.nativeElement.clientHeight;
106 this.canvas.nativeElement.width = width;
107 this.canvas.nativeElement.height = height;
108 this.canvas.nativeElement
109 .getContext('2d')
110 .drawImage(this.video.nativeElement, 0, 0, width, height);
111
112 // Perform the QR code detection. We use the Aruco-variant of the detector
113 // here because it appears to detect QR codes much more reliably than the
114 // standard detector.
115 let mat = cv.imread('canvas');
116 let qrDecoder = new cv.QRCodeDetectorAruco();
117 const result = qrDecoder.detectAndDecode(mat);
118 mat.delete();
119
120 // Handle the result.
121 if (result) {
122 await this.scanSuccessHandler(result);
123 } else {
124 await this.scanFailureHandler();
125 }
126 }
127
128 async scanSuccessHandler(scanResult: string) {
129 // Reverse the conversion and obtain the original Uint8Array. In other
130 // words, undo the work in `scouting/www/entry/entry.component.ts`.
131 const [indexStr, numPiecesStr, pieceSizeStr, splitPiece] = scanResult.split(
132 '_',
133 4
134 );
135
136 // If we didn't get enough data, then maybe we scanned some non-scouting
137 // related QR code? Try to give a hint to the user.
138 if (!indexStr || !numPiecesStr || !pieceSizeStr || !splitPiece) {
139 this.progressMessage = '';
140 this.errorMessage = `Couldn't find scouting data in the QR code.`;
141 return;
142 }
143
144 const index = Number(indexStr);
145 const numPieces = Number(numPiecesStr);
146 const pieceSize = Number(pieceSizeStr);
147
148 if (
149 numPieces != this.qrCodeValuePieces.length ||
150 pieceSize != this.qrCodeValuePieceSize
151 ) {
152 // The number of pieces or the piece size changed. We need to reset our accounting.
153 this.qrCodeValuePieces = new Array<string>(numPieces);
154 this.qrCodeValuePieceSize = pieceSize;
155 }
156
157 this.qrCodeValuePieces[index] = splitPiece;
158 this.progressMessage = `Scanned QR code ${index + 1} out of ${
159 this.qrCodeValuePieces.length
160 }`;
161
162 // Count up the number of missing pieces so we can give a progress update.
163 let numMissingPieces = 0;
164 for (const piece of this.qrCodeValuePieces) {
165 if (!piece) {
166 numMissingPieces++;
167 }
168 }
169 if (numMissingPieces > 0) {
170 this.progressMessage = `Waiting for ${numMissingPieces} out of ${this.qrCodeValuePieces.length} QR codes.`;
171 this.errorMessage = '';
172 return;
173 }
174
175 // Stop scanning now that we have all the pieces.
176 this.progressMessage = 'Scanned all QR codes. Submitting.';
177 this.scanComplete = true;
178 clearInterval(this.scanTimer);
179
180 const encodedData = this.qrCodeValuePieces.join('');
181 const deflatedData = Uint8Array.from(atob(encodedData), (c) =>
182 c.charCodeAt(0)
183 );
184 const actionBuffer = pako.inflate(deflatedData);
185
Philipp Schraderad2a6fb2024-03-20 20:51:36 -0700186 const res = await this.actionsSubmitter.submit(actionBuffer);
Philipp Schradere2e27ff2024-02-25 22:08:55 -0800187
188 if (res.ok) {
189 // We successfully submitted the data. Report success.
190 this.progressMessage = 'Success!';
191 this.errorMessage = '';
192 } else {
193 const resBuffer = await res.arrayBuffer();
194 const fbBuffer = new ByteBuffer(new Uint8Array(resBuffer));
195 const parsedResponse = ErrorResponse.getRootAsErrorResponse(fbBuffer);
196
197 const errorMessage = parsedResponse.errorMessage();
198 this.progressMessage = '';
199 this.errorMessage = `Submission failed with ${res.status} ${res.statusText}: "${errorMessage}"`;
200 }
201 }
202
203 async scanFailureHandler() {
204 this.progressMessage = '';
205 this.errorMessage = 'Failed to scan!';
206 }
207
208 loadOpenCv() {
209 // Make the browser load OpenCV.
210 let body = <HTMLDivElement>document.body;
211 let script = document.createElement('script');
212 script.innerHTML = '';
213 script.src = 'assets/opencv_4.9.0/opencv.js';
214 script.async = false;
215 script.defer = true;
216 script.onerror = (error) => {
217 this.progressMessage = '';
218 if (typeof error === 'string') {
219 this.errorMessage = `OpenCV failed to load: ${error}`;
220 } else {
221 this.errorMessage = 'OpenCV failed to load.';
222 }
223 // Since we use the onRuntimeInitialized property as a flag to see if we
224 // need to perform loading, we need to delete the property. When the user
225 // switches away from this tab and then switches back, we want to attempt
226 // loading again.
227 delete Module['onRuntimeInitialized'];
228 };
229 body.appendChild(script);
230 }
231
232 startScanning() {
233 this.scanTimer = setInterval(() => {
234 this.scan();
235 }, SCAN_PERIOD);
236 }
237}