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