Scouting App: Add ability to submit data with QR Codes
This patch adds the option for a scout to generate a QR Code
storing the collected data instead of submitting it. Next,
another user can submit the data by scanning the QR Code using the
new 'Scan' tab.
Since flatbuffers are pretty inefficient in terms of space usage, we
have trouble fitting all the data into a single QR code. The app
allows the user to split the data into multiple QR codes which have to
be scanned.
I tried a bunch of upstream QR code scanning libraries, but they're
all either unmaintained, not open source, or just don't work well. I
ended up settling on OpenCV because it was the most reliable library I
could find.
Co-Authored-By: Filip Kujawa <filip.j.kujawa@gmail.com>
Signed-off-by: Filip Kujawa <filip.j.kujawa@gmail.com>
Signed-off-by: Philipp Schrader <philipp.schrader+971@gmail.com>
Change-Id: I794b54bf7e8389200aa2abe8d05f622a987bca9c
diff --git a/scouting/BUILD b/scouting/BUILD
index 82f058c..ba6ab6b 100644
--- a/scouting/BUILD
+++ b/scouting/BUILD
@@ -38,6 +38,22 @@
],
)
+# The QR code test is separate from scouting_test because it's slow. Most of
+# the time folks will want to iterate on `scouting_test`.
+cypress_test(
+ name = "scouting_qrcode_test",
+ size = "large",
+ data = [
+ "scouting_qrcode_test.cy.js",
+ "//scouting/testing:scouting_test_servers",
+ "//scouting/testing/camera_simulator",
+ ],
+ runner = "scouting_test_runner.js",
+ tags = [
+ "no-remote-cache",
+ ],
+)
+
apache_wrapper(
name = "https",
binary = ":scouting",
diff --git a/scouting/scouting_qrcode_test.cy.js b/scouting/scouting_qrcode_test.cy.js
new file mode 100644
index 0000000..668cba8
--- /dev/null
+++ b/scouting/scouting_qrcode_test.cy.js
@@ -0,0 +1,157 @@
+/// <reference types="cypress" />
+
+// On the 3rd row of matches (index 2) click on the third team
+// (index 2) which resolves to team 333 in quals match 3.
+const QUALS_MATCH_3_TEAM_333 = 2 * 6 + 2;
+
+function disableAlerts() {
+ cy.get('#block_alerts').check({force: true}).should('be.checked');
+}
+
+function switchToTab(tabName) {
+ cy.contains('.nav-link', tabName).click();
+}
+
+function headerShouldBe(text) {
+ cy.get('.header').should('have.text', text);
+}
+
+function clickButton(buttonName) {
+ cy.contains('button', buttonName).click();
+}
+
+// Wrapper around cy.exec() because it truncates the output of the subprocess
+// if it fails. This is a work around to manually print the full error on the
+// console if a failure happends.
+function exec(command) {
+ cy.exec(command, {failOnNonZeroExit: false}).then((result) => {
+ if (result.code) {
+ throw new Error(`Execution of "${command}" failed
+ Exit code: ${result.code}
+ Stdout:\n${result.stdout}
+ Stderr:\n${result.stderr}`);
+ }
+ });
+}
+
+// Prepares data entry so that we _could_ hit Submit.
+//
+// Options:
+// matchButtonKey: The index into the big matchlist table that we want to
+// click on to start the data entry.
+// teamNumber: The team number that matches the button that we click on as
+// specified by `matchButtonKey`.
+//
+// TODO(phil): Deduplicate with scouting_test.cy.js.
+function prepareDataScouting(options) {
+ const {matchButtonKey = SEMI_FINAL_2_MATCH_3_TEAM_5254, teamNumber = 5254} =
+ options;
+
+ // Click on a random team in the Match list. The exact details here are not
+ // important, but we need to know what they are. This could as well be any
+ // other team from any other match.
+ cy.get('button.match-item').eq(matchButtonKey).click();
+
+ // Select Starting Position.
+ headerShouldBe(teamNumber + ' Init ');
+ cy.get('[type="radio"]').first().check();
+ clickButton('Start Match');
+
+ // Pick and Place Note in Auto.
+ clickButton('NOTE');
+ clickButton('AMP');
+
+ // Pick and Place Cube in Teleop.
+ clickButton('Start Teleop');
+ clickButton('NOTE');
+ clickButton('AMP AMPLIFIED');
+
+ // Generate some extra actions so that we are guaranteed to have at least 2
+ // QR codes.
+ for (let i = 0; i < 5; i++) {
+ clickButton('NOTE');
+ clickButton('AMP');
+ }
+
+ // Robot dead and revive.
+ clickButton('DEAD');
+ clickButton('Revive');
+
+ // Endgame.
+ clickButton('Endgame');
+ cy.contains(/Harmony/).click();
+
+ clickButton('End Match');
+ headerShouldBe(teamNumber + ' Review and Submit ');
+ cy.get('#review_data li')
+ .eq(0)
+ .should('have.text', ' Started match at position 1 ');
+ cy.get('#review_data li').eq(1).should('have.text', 'Picked up Note');
+ cy.get('#review_data li')
+ .last()
+ .should(
+ 'have.text',
+ ' Ended Match; stageType: kHARMONY, trapNote: false, spotlight: false '
+ );
+}
+
+before(() => {
+ cy.visit('/');
+ disableAlerts();
+ cy.title().should('eq', 'FRC971 Scouting Application');
+});
+
+beforeEach(() => {
+ cy.visit('/');
+ disableAlerts();
+});
+
+describe('Scouting app tests', () => {
+ // This test collects some scouting data and then generates the corresponding
+ // QR codes. The test takes screenshots of those QR codes. The QR codes get
+ // turned into a little video file for the browser to use as a fake camera
+ // input. The test then switches to the Scan tab to scan the QR codes from
+ // the "camera". We then make sure that the data gets submitted.
+ it('should: be able to generate and scan QR codes.', () => {
+ prepareDataScouting({
+ matchButtonKey: QUALS_MATCH_3_TEAM_333,
+ teamNumber: 333,
+ });
+ clickButton('Create QR Code');
+ headerShouldBe('333 QR Code ');
+
+ cy.get('#qr_code_piece_size').select('150');
+
+ // Go into a mobile-phone view so that we can guarantee that the QR code is
+ // visible.
+ cy.viewport(400, 660);
+
+ cy.get('.qrcode-buttons > li > a')
+ .should('have.length.at.least', 4)
+ .each(($button, index, $buttons) => {
+ if (index == 0 || index + 1 == $buttons.length) {
+ // Skip the "Previous" and "Next" buttons.
+ return;
+ }
+ // Click on the button to switch to that particular QR code.
+ // We use force:true here because without bootstrap (inside the
+ // sandbox) the buttons overlap one another a bit.
+ cy.wrap($button).click({force: true});
+ cy.get('div.qrcode').screenshot(`qrcode_${index}_screenshot`);
+ });
+
+ exec('./testing/camera_simulator/camera_simulator_/camera_simulator');
+
+ switchToTab('Scan');
+
+ // Since we cannot reliably predict how long it will take to scan all the
+ // QR codes, we use a really long timeout here.
+ cy.get('.progress_message', {timeout: 80000}).should('contain', 'Success!');
+
+ // Now that the data is submitted, the button should be disabled.
+ switchToTab('Match List');
+ cy.get('button.match-item')
+ .eq(QUALS_MATCH_3_TEAM_333)
+ .should('be.disabled');
+ });
+});
diff --git a/scouting/testing/camera_simulator/BUILD b/scouting/testing/camera_simulator/BUILD
new file mode 100644
index 0000000..b6e4bf3
--- /dev/null
+++ b/scouting/testing/camera_simulator/BUILD
@@ -0,0 +1,16 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+
+go_library(
+ name = "camera_simulator_lib",
+ srcs = ["camera_simulator.go"],
+ importpath = "github.com/frc971/971-Robot-Code/scouting/testing/camera_simulator",
+ target_compatible_with = ["@platforms//cpu:x86_64"],
+ visibility = ["//visibility:private"],
+)
+
+go_binary(
+ name = "camera_simulator",
+ embed = [":camera_simulator_lib"],
+ target_compatible_with = ["@platforms//cpu:x86_64"],
+ visibility = ["//visibility:public"],
+)
diff --git a/scouting/testing/camera_simulator/camera_simulator.go b/scouting/testing/camera_simulator/camera_simulator.go
new file mode 100644
index 0000000..a54e79f
--- /dev/null
+++ b/scouting/testing/camera_simulator/camera_simulator.go
@@ -0,0 +1,82 @@
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "image"
+ "image/jpeg"
+ _ "image/png"
+ "log"
+ "os"
+ "path/filepath"
+ "sort"
+)
+
+// Chrome plays back MJPEG files at a (hard-coded) 30 fps.
+const CHROME_FAKE_VIDEO_FPS = 30
+
+// For how many seconds to display a single image.
+const IMAGE_DURATION = 3
+
+// For how many frames (at CHROME_FAKE_VIDEO_FPS) to display a single image.
+const IMAGE_DURATION_FRAMES = int(CHROME_FAKE_VIDEO_FPS * IMAGE_DURATION)
+
+func checkErr(err error, message string) {
+ if err != nil {
+ log.Println(message)
+ log.Fatal(err)
+ }
+}
+
+func main() {
+ output_dir := os.Getenv("TEST_UNDECLARED_OUTPUTS_DIR")
+
+ // The output file is at a fixed path as expected by
+ // `tools/build_rules/js/cypress.config.js`.
+ outName := output_dir + "/fake_camera.mjpeg"
+
+ // The Cypress test is expected to dump all the screenshots in this
+ // directory.
+ screenshotDir := output_dir + "/screenshots/scouting_qrcode_test.cy.js"
+ log.Printf("Looking for screenshots in %s", screenshotDir)
+
+ // Create a movie from images.
+ matches, err := filepath.Glob(screenshotDir + "/qrcode_*_screenshot.png")
+ checkErr(err, "Failed to glob for the screenshots")
+ sort.Strings(matches)
+
+ log.Println("Found images:", matches)
+ if len(matches) < 2 {
+ // For the purposes of the test, we expect at least 2 QR codes.
+ // If something goes wrong, then this is an opportunity to bail
+ // early.
+ log.Fatalf("Only found %d images", len(matches))
+ }
+
+ mjpeg, err := os.Create(outName)
+ checkErr(err, "Failed to open output file")
+ defer mjpeg.Close()
+
+ // MJPEG is litterally a bunch of JPEGs concatenated together. Read in
+ // each frame and append it to the output file.
+ for _, name := range matches {
+ reader, err := os.Open(name)
+ checkErr(err, "Could not open "+name)
+ defer reader.Close()
+
+ img, _, err := image.Decode(reader)
+ checkErr(err, "Could not decode image")
+
+ buffer := &bytes.Buffer{}
+ checkErr(jpeg.Encode(buffer, img, nil), "Failed to encode as jpeg")
+
+ // In order to show a single picture for 1 second, we need to
+ // inject CHROME_FAKE_VIDEO_FPS copies of the same image.
+ for i := 0; i < IMAGE_DURATION_FRAMES; i++ {
+ _, err = mjpeg.Write(buffer.Bytes())
+ checkErr(err, "Failed to write to mjpeg")
+ }
+ }
+
+ fmt.Printf("%s was written successfully.\n", outName)
+}
diff --git a/scouting/www/BUILD b/scouting/www/BUILD
index a403bf3..b46f265 100644
--- a/scouting/www/BUILD
+++ b/scouting/www/BUILD
@@ -5,8 +5,19 @@
npm_link_all_packages(name = "node_modules")
+OPENCV_VERSION = "4.9.0"
+
+copy_file(
+ name = "opencv.js",
+ src = "@opencv_wasm//file",
+ out = "assets/opencv_{}/opencv.js".format(OPENCV_VERSION),
+)
+
ng_application(
name = "app",
+ assets = [
+ ":opencv.js",
+ ],
extra_srcs = [
"app/common.css",
],
@@ -20,6 +31,7 @@
"//scouting/www/match_list",
"//scouting/www/notes",
"//scouting/www/pit_scouting",
+ "//scouting/www/scan",
"//scouting/www/shift_schedule",
"//scouting/www/view",
],
diff --git a/scouting/www/app/app.module.ts b/scouting/www/app/app.module.ts
index bf393e4..ccc4840 100644
--- a/scouting/www/app/app.module.ts
+++ b/scouting/www/app/app.module.ts
@@ -10,6 +10,7 @@
import {ViewModule} from '../view';
import {DriverRankingModule} from '../driver_ranking';
import {PitScoutingModule} from '../pit_scouting';
+import {ScanModule} from '../scan';
@NgModule({
declarations: [App],
@@ -23,6 +24,7 @@
DriverRankingModule,
ViewModule,
PitScoutingModule,
+ ScanModule,
],
exports: [App],
bootstrap: [App],
diff --git a/scouting/www/app/app.ng.html b/scouting/www/app/app.ng.html
index 5f7ea9f..ef37c07 100644
--- a/scouting/www/app/app.ng.html
+++ b/scouting/www/app/app.ng.html
@@ -73,6 +73,15 @@
Pit
</a>
</li>
+ <li class="nav-item">
+ <a
+ class="nav-link"
+ [class.active]="tabIs('Scan')"
+ (click)="switchTabToGuarded('Scan')"
+ >
+ Scan
+ </a>
+ </li>
</ul>
<ng-container [ngSwitch]="tab">
@@ -93,4 +102,5 @@
<shift-schedule *ngSwitchCase="'ShiftSchedule'"></shift-schedule>
<app-view *ngSwitchCase="'View'"></app-view>
<app-pit-scouting *ngSwitchCase="'Pit'"></app-pit-scouting>
+ <app-scan *ngSwitchCase="'Scan'"></app-scan>
</ng-container>
diff --git a/scouting/www/app/app.ts b/scouting/www/app/app.ts
index 597e5c5..895336b 100644
--- a/scouting/www/app/app.ts
+++ b/scouting/www/app/app.ts
@@ -7,10 +7,11 @@
| 'DriverRanking'
| 'ShiftSchedule'
| 'View'
- | 'Pit';
+ | 'Pit'
+ | 'Scan';
// Ignore the guard for tabs that don't require the user to enter any data.
-const unguardedTabs: Tab[] = ['MatchList', 'View'];
+const unguardedTabs: Tab[] = ['MatchList', 'Scan', 'View'];
type TeamInMatch = {
teamNumber: string;
diff --git a/scouting/www/entry/BUILD b/scouting/www/entry/BUILD
index 98b457b..48732f9 100644
--- a/scouting/www/entry/BUILD
+++ b/scouting/www/entry/BUILD
@@ -10,6 +10,10 @@
],
deps = [
":node_modules/@angular/forms",
+ "//:node_modules/@angular/platform-browser",
+ "//:node_modules/@types/pako",
+ "//:node_modules/angularx-qrcode",
+ "//:node_modules/pako",
"//scouting/webserver/requests/messages:error_response_ts_fbs",
"//scouting/webserver/requests/messages:request_all_matches_response_ts_fbs",
"//scouting/webserver/requests/messages:submit_2024_actions_ts_fbs",
diff --git a/scouting/www/entry/entry.component.css b/scouting/www/entry/entry.component.css
index cd208b9..e646a25 100644
--- a/scouting/www/entry/entry.component.css
+++ b/scouting/www/entry/entry.component.css
@@ -28,3 +28,43 @@
width: 90vw;
justify-content: space-between;
}
+
+.qr-container {
+ width: 100%;
+ display: flex;
+ justify-content: center;
+ padding: 0px;
+}
+
+.qrcode-buttons > li.page-item {
+ padding: 0px;
+}
+
+/* Using deprecated ::ng-deep here, but couldn't find a better way to do it.
+ * The qrcode container generates a canvas without any style markers or
+ * classes. Angular's view encapsulation prevents this style from applying.
+ * Maybe https://developer.mozilla.org/en-US/docs/Web/CSS/::slotted ? */
+:host ::ng-deep .qr-container canvas {
+ /* Make the QR code take up as much space as possible. Can't take up more
+ * than the screen size, however, because you can't scan a QR code while
+ * scrolling. It needs to be fully visibile. */
+ width: 100% !important;
+ height: 100% !important;
+ aspect-ratio: 1 / 1;
+}
+
+/* Make the UI a little more compact. The QR code itself already has a good
+ * amount of margin. */
+
+.qrcode-nav {
+ padding: 0px;
+}
+
+.qrcode-buttons {
+ padding: 0px;
+ margin: 0px;
+}
+
+.qrcode {
+ padding: 0px;
+}
diff --git a/scouting/www/entry/entry.component.ts b/scouting/www/entry/entry.component.ts
index 6da8be8..09b1a8b 100644
--- a/scouting/www/entry/entry.component.ts
+++ b/scouting/www/entry/entry.component.ts
@@ -26,6 +26,7 @@
} from '../../webserver/requests/messages/submit_2024_actions_generated';
import {Match} from '../../webserver/requests/messages/request_all_matches_response_generated';
import {MatchListRequestor} from '../rpc';
+import * as pako from 'pako';
type Section =
| 'Team Selection'
@@ -35,6 +36,7 @@
| 'Endgame'
| 'Dead'
| 'Review and Submit'
+ | 'QR Code'
| 'Success';
// TODO(phil): Deduplicate with match_list.component.ts.
@@ -50,6 +52,13 @@
f: 'Finals',
};
+// The maximum number of bytes per QR code. The user can adjust this value to
+// make the QR code contain less information, but easier to scan.
+const QR_CODE_PIECE_SIZES = [150, 300, 450, 600, 750, 900];
+
+// The default index into QR_CODE_PIECE_SIZES.
+const DEFAULT_QR_CODE_PIECE_SIZE_INDEX = QR_CODE_PIECE_SIZES.indexOf(750);
+
type ActionT =
| {
type: 'startMatchAction';
@@ -112,6 +121,7 @@
// of radio buttons.
readonly COMP_LEVELS = COMP_LEVELS;
readonly COMP_LEVEL_LABELS = COMP_LEVEL_LABELS;
+ readonly QR_CODE_PIECE_SIZES = QR_CODE_PIECE_SIZES;
readonly ScoreType = ScoreType;
readonly StageType = StageType;
@@ -136,6 +146,16 @@
teamSelectionIsValid = false;
+ // When the user chooses to generate QR codes, we convert the flatbuffer into
+ // a long string. Since we frequently have more data than we can display in a
+ // single QR code, we break the data into multiple QR codes. The data for
+ // each QR code ("pieces") is stored in the `qrCodeValuePieces` list below.
+ // The `qrCodeValueIndex` keeps track of which QR code we're currently
+ // displaying.
+ qrCodeValuePieceSize = QR_CODE_PIECE_SIZES[DEFAULT_QR_CODE_PIECE_SIZE_INDEX];
+ qrCodeValuePieces: string[] = [];
+ qrCodeValueIndex: number = 0;
+
constructor(private readonly matchListRequestor: MatchListRequestor) {}
ngOnInit() {
@@ -219,7 +239,8 @@
}
if (action.type == 'endMatchAction') {
- // endMatchAction occurs at the same time as penaltyAction so add to its timestamp to make it unique.
+ // endMatchAction occurs at the same time as penaltyAction so add to its
+ // timestamp to make it unique.
action.timestamp += 1;
}
@@ -282,6 +303,11 @@
this.errorMessage = '';
this.progressMessage = '';
+ // For the QR code screen, we need to make the value to encode available.
+ if (target == 'QR Code') {
+ this.updateQrCodeValuePieceSize();
+ }
+
this.section = target;
}
@@ -291,7 +317,7 @@
this.header.nativeElement.scrollIntoView();
}
- async submit2024Actions() {
+ createActionsBuffer() {
const builder = new Builder();
const actionOffsets: number[] = [];
@@ -419,10 +445,44 @@
Submit2024Actions.addPreScouting(builder, this.preScouting);
builder.finish(Submit2024Actions.endSubmit2024Actions(builder));
- const buffer = builder.asUint8Array();
+ return builder.asUint8Array();
+ }
+
+ // Same as createActionsBuffer, but encoded as Base64. It's also split into
+ // a number of pieces so that each piece is roughly limited to
+ // `qrCodeValuePieceSize` bytes.
+ createBase64ActionsBuffers(): string[] {
+ const originalBuffer = this.createActionsBuffer();
+ const deflatedData = pako.deflate(originalBuffer, {level: 9});
+
+ const pieceSize = this.qrCodeValuePieceSize;
+ const fullValue = btoa(String.fromCharCode(...deflatedData));
+ const numPieces = Math.ceil(fullValue.length / pieceSize);
+
+ let splitData: string[] = [];
+ for (let i = 0; i < numPieces; i++) {
+ const splitPiece = fullValue.slice(i * pieceSize, (i + 1) * pieceSize);
+ splitData.push(`${i}_${numPieces}_${pieceSize}_${splitPiece}`);
+ }
+ return splitData;
+ }
+
+ setQrCodeValueIndex(index: number) {
+ this.qrCodeValueIndex = Math.max(
+ 0,
+ Math.min(index, this.qrCodeValuePieces.length - 1)
+ );
+ }
+
+ updateQrCodeValuePieceSize() {
+ this.qrCodeValuePieces = this.createBase64ActionsBuffers();
+ this.qrCodeValueIndex = 0;
+ }
+
+ async submit2024Actions() {
const res = await fetch('/requests/submit/submit_2024_actions', {
method: 'POST',
- body: buffer,
+ body: this.createActionsBuffer(),
});
if (res.ok) {
diff --git a/scouting/www/entry/entry.module.ts b/scouting/www/entry/entry.module.ts
index c74d4d0..bcba4ee 100644
--- a/scouting/www/entry/entry.module.ts
+++ b/scouting/www/entry/entry.module.ts
@@ -2,10 +2,11 @@
import {CommonModule} from '@angular/common';
import {FormsModule} from '@angular/forms';
import {EntryComponent} from './entry.component';
+import {QRCodeModule} from 'angularx-qrcode';
@NgModule({
declarations: [EntryComponent],
exports: [EntryComponent],
- imports: [CommonModule, FormsModule],
+ imports: [CommonModule, FormsModule, QRCodeModule],
})
export class EntryModule {}
diff --git a/scouting/www/entry/entry.ng.html b/scouting/www/entry/entry.ng.html
index 22d2d27..139268f 100644
--- a/scouting/www/entry/entry.ng.html
+++ b/scouting/www/entry/entry.ng.html
@@ -476,6 +476,9 @@
</div>
<div class="d-grid gap-5">
<button class="btn btn-secondary" (click)="undoLastAction()">UNDO</button>
+ <button class="btn btn-info" (click)="changeSectionTo('QR Code');">
+ Create QR Code
+ </button>
<button class="btn btn-warning" (click)="submit2024Actions();">
Submit
</button>
@@ -484,6 +487,75 @@
<div *ngSwitchCase="'Success'" id="Success" class="container-fluid">
<h2>Successfully submitted data.</h2>
</div>
+ <div *ngSwitchCase="'QR Code'" id="QR Code" class="container-fluid">
+ <span>Density:</span>
+ <select
+ [(ngModel)]="qrCodeValuePieceSize"
+ (ngModelChange)="updateQrCodeValuePieceSize()"
+ type="number"
+ id="qr_code_piece_size"
+ >
+ <option
+ *ngFor="let pieceSize of QR_CODE_PIECE_SIZES"
+ [ngValue]="pieceSize"
+ >
+ {{pieceSize}}
+ </option>
+ </select>
+ <div class="qr-container">
+ <qrcode
+ [qrdata]="qrCodeValuePieces[qrCodeValueIndex]"
+ [width]="1000"
+ [errorCorrectionLevel]="'M'"
+ [margin]="6"
+ class="qrcode"
+ ></qrcode>
+ </div>
+ <nav class="qrcode-nav">
+ <ul
+ class="qrcode-buttons pagination pagination-lg justify-content-center"
+ >
+ <li class="page-item">
+ <a
+ class="page-link"
+ href="#"
+ aria-label="Previous"
+ (click)="setQrCodeValueIndex(qrCodeValueIndex - 1)"
+ >
+ <span aria-hidden="true">«</span>
+ <span class="visually-hidden">Previous</span>
+ </a>
+ </li>
+ <li *ngFor="let _ of qrCodeValuePieces; index as i" class="page-item">
+ <a
+ class="page-link"
+ href="#"
+ (click)="setQrCodeValueIndex(i)"
+ [class.active]="qrCodeValueIndex == i"
+ >
+ {{i + 1}}
+ </a>
+ </li>
+ <li class="page-item">
+ <a
+ class="page-link"
+ href="#"
+ aria-label="Next"
+ (click)="setQrCodeValueIndex(qrCodeValueIndex + 1)"
+ >
+ <span aria-hidden="true">»</span>
+ <span class="visually-hidden">Next</span>
+ </a>
+ </li>
+ </ul>
+ </nav>
+ <button
+ class="btn btn-secondary"
+ (click)="changeSectionTo('Review and Submit')"
+ >
+ BACK
+ </button>
+ </div>
<span class="progress_message" role="alert">{{ progressMessage }}</span>
<span class="error_message" role="alert">{{ errorMessage }}</span>
diff --git a/scouting/www/entry/package.json b/scouting/www/entry/package.json
index d37ce10..a02c0a6 100644
--- a/scouting/www/entry/package.json
+++ b/scouting/www/entry/package.json
@@ -2,6 +2,8 @@
"name": "@org_frc971/scouting/www/entry",
"private": true,
"dependencies": {
+ "pako": "2.1.0",
+ "@types/pako": "2.0.3",
"@org_frc971/scouting/www/counter_button": "workspace:*",
"@angular/forms": "v16-lts"
}
diff --git a/scouting/www/index.html b/scouting/www/index.html
index 46779cc..e4e996a 100644
--- a/scouting/www/index.html
+++ b/scouting/www/index.html
@@ -16,6 +16,12 @@
integrity="d8824f7067cdfea38afec7e9ffaf072125266824206d69ef1f112d72153a505e"
/>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
+ <script>
+ // In order to hook into WASM's "finished loading" event, we interact
+ // with the global Module variable. Maybe some day there'll be a better
+ // way to interact with it.
+ var Module = {};
+ </script>
</head>
<body>
<my-app></my-app>
diff --git a/scouting/www/scan/BUILD b/scouting/www/scan/BUILD
new file mode 100644
index 0000000..88b2822
--- /dev/null
+++ b/scouting/www/scan/BUILD
@@ -0,0 +1,18 @@
+load("@npm//:defs.bzl", "npm_link_all_packages")
+load("//tools/build_rules:js.bzl", "ng_pkg")
+
+npm_link_all_packages(name = "node_modules")
+
+ng_pkg(
+ name = "scan",
+ extra_srcs = [
+ "//scouting/www:app_common_css",
+ ],
+ deps = [
+ ":node_modules/@angular/forms",
+ ":node_modules/@types/pako",
+ ":node_modules/pako",
+ "//scouting/webserver/requests/messages:error_response_ts_fbs",
+ "@com_github_google_flatbuffers//ts:flatbuffers_ts",
+ ],
+)
diff --git a/scouting/www/scan/package.json b/scouting/www/scan/package.json
new file mode 100644
index 0000000..a5950c3
--- /dev/null
+++ b/scouting/www/scan/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "@org_frc971/scouting/www/scan",
+ "private": true,
+ "dependencies": {
+ "pako": "2.1.0",
+ "@types/pako": "2.0.3",
+ "@angular/forms": "v16-lts"
+ }
+}
diff --git a/scouting/www/scan/scan.component.css b/scouting/www/scan/scan.component.css
new file mode 100644
index 0000000..11cfe09
--- /dev/null
+++ b/scouting/www/scan/scan.component.css
@@ -0,0 +1,20 @@
+video {
+ width: 100%;
+ aspect-ratio: 1 / 1;
+}
+
+canvas {
+ /* We don't want to show the frames that we are scanning for QR codes. It's
+ * nicer to just see the video stream. */
+ display: none;
+}
+
+ul {
+ margin: 0px;
+}
+
+li > a.active {
+ /* Set the scanned QR codes to a green color. */
+ background-color: #198754;
+ border-color: #005700;
+}
diff --git a/scouting/www/scan/scan.component.ts b/scouting/www/scan/scan.component.ts
new file mode 100644
index 0000000..98a7b61
--- /dev/null
+++ b/scouting/www/scan/scan.component.ts
@@ -0,0 +1,236 @@
+import {Component, NgZone, OnInit, ViewChild, ElementRef} from '@angular/core';
+import {ErrorResponse} from '../../webserver/requests/messages/error_response_generated';
+import {Builder, ByteBuffer} from 'flatbuffers';
+import * as pako from 'pako';
+
+declare var cv: any;
+declare var Module: any;
+
+// The number of milliseconds between QR code scans.
+const SCAN_PERIOD = 500;
+
+@Component({
+ selector: 'app-scan',
+ templateUrl: './scan.ng.html',
+ styleUrls: ['../app/common.css', './scan.component.css'],
+})
+export class ScanComponent implements OnInit {
+ @ViewChild('video')
+ public video: ElementRef;
+
+ @ViewChild('canvas')
+ public canvas: ElementRef;
+
+ errorMessage: string = '';
+ progressMessage: string = 'Waiting for QR code(s)';
+ scanComplete: boolean = false;
+ videoStartedSuccessfully = false;
+
+ qrCodeValuePieces: string[] = [];
+ qrCodeValuePieceSize = 0;
+
+ scanStream: MediaStream | null = null;
+ scanTimer: ReturnType<typeof setTimeout> | null = null;
+
+ constructor(private ngZone: NgZone) {}
+
+ ngOnInit() {
+ // If the user switched away from this tab, then the onRuntimeInitialized
+ // attribute will already be set. No need to load OpenCV again. If it's not
+ // loaded, however, we need to load it.
+ if (!Module['onRuntimeInitialized']) {
+ Module['onRuntimeInitialized'] = () => {
+ // Since the WASM code doesn't know about the Angular zone, we force
+ // it into the correct zone so that the UI gets updated properly.
+ this.ngZone.run(() => {
+ this.startScanning();
+ });
+ };
+ // Now that we set up the hook, we can load OpenCV.
+ this.loadOpenCv();
+ } else {
+ this.startScanning();
+ }
+ }
+
+ ngOnDestroy() {
+ clearInterval(this.scanTimer);
+
+ // Explicitly stop the streams so that the camera isn't being locked
+ // unnecessarily. I.e. other processes can use it too.
+ if (this.scanStream) {
+ this.scanStream.getTracks().forEach((track) => {
+ track.stop();
+ });
+ }
+ }
+
+ public ngAfterViewInit() {
+ // Start the video playback.
+ // It would be nice to let the user select which camera gets used. For now,
+ // we give the "environment" hint so that it faces away from the user.
+ if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
+ navigator.mediaDevices
+ .getUserMedia({video: {facingMode: 'environment'}})
+ .then(
+ (stream) => {
+ this.scanStream = stream;
+ this.video.nativeElement.srcObject = stream;
+ this.video.nativeElement.play();
+ this.videoStartedSuccessfully = true;
+ },
+ (reason) => {
+ this.progressMessage = '';
+ this.errorMessage = `Failed to start video: ${reason}`;
+ }
+ );
+ }
+ }
+
+ async scan() {
+ if (!this.videoStartedSuccessfully) {
+ return;
+ }
+
+ // Take a capture of the video stream. That capture can then be used by
+ // OpenCV to perform the QR code detection. Due to my inexperience, I could
+ // only make this code work if I size the (invisible) canvas to match the
+ // video element. Otherwise, I'd get cropped images.
+ // Can we stream the video directly into the canvas?
+ const width = this.video.nativeElement.clientWidth;
+ const height = this.video.nativeElement.clientHeight;
+ this.canvas.nativeElement.width = width;
+ this.canvas.nativeElement.height = height;
+ this.canvas.nativeElement
+ .getContext('2d')
+ .drawImage(this.video.nativeElement, 0, 0, width, height);
+
+ // Perform the QR code detection. We use the Aruco-variant of the detector
+ // here because it appears to detect QR codes much more reliably than the
+ // standard detector.
+ let mat = cv.imread('canvas');
+ let qrDecoder = new cv.QRCodeDetectorAruco();
+ const result = qrDecoder.detectAndDecode(mat);
+ mat.delete();
+
+ // Handle the result.
+ if (result) {
+ await this.scanSuccessHandler(result);
+ } else {
+ await this.scanFailureHandler();
+ }
+ }
+
+ async scanSuccessHandler(scanResult: string) {
+ // Reverse the conversion and obtain the original Uint8Array. In other
+ // words, undo the work in `scouting/www/entry/entry.component.ts`.
+ const [indexStr, numPiecesStr, pieceSizeStr, splitPiece] = scanResult.split(
+ '_',
+ 4
+ );
+
+ // If we didn't get enough data, then maybe we scanned some non-scouting
+ // related QR code? Try to give a hint to the user.
+ if (!indexStr || !numPiecesStr || !pieceSizeStr || !splitPiece) {
+ this.progressMessage = '';
+ this.errorMessage = `Couldn't find scouting data in the QR code.`;
+ return;
+ }
+
+ const index = Number(indexStr);
+ const numPieces = Number(numPiecesStr);
+ const pieceSize = Number(pieceSizeStr);
+
+ if (
+ numPieces != this.qrCodeValuePieces.length ||
+ pieceSize != this.qrCodeValuePieceSize
+ ) {
+ // The number of pieces or the piece size changed. We need to reset our accounting.
+ this.qrCodeValuePieces = new Array<string>(numPieces);
+ this.qrCodeValuePieceSize = pieceSize;
+ }
+
+ this.qrCodeValuePieces[index] = splitPiece;
+ this.progressMessage = `Scanned QR code ${index + 1} out of ${
+ this.qrCodeValuePieces.length
+ }`;
+
+ // Count up the number of missing pieces so we can give a progress update.
+ let numMissingPieces = 0;
+ for (const piece of this.qrCodeValuePieces) {
+ if (!piece) {
+ numMissingPieces++;
+ }
+ }
+ if (numMissingPieces > 0) {
+ this.progressMessage = `Waiting for ${numMissingPieces} out of ${this.qrCodeValuePieces.length} QR codes.`;
+ this.errorMessage = '';
+ return;
+ }
+
+ // Stop scanning now that we have all the pieces.
+ this.progressMessage = 'Scanned all QR codes. Submitting.';
+ this.scanComplete = true;
+ clearInterval(this.scanTimer);
+
+ const encodedData = this.qrCodeValuePieces.join('');
+ const deflatedData = Uint8Array.from(atob(encodedData), (c) =>
+ c.charCodeAt(0)
+ );
+ const actionBuffer = pako.inflate(deflatedData);
+
+ const res = await fetch('/requests/submit/submit_2024_actions', {
+ method: 'POST',
+ body: actionBuffer,
+ });
+
+ if (res.ok) {
+ // We successfully submitted the data. Report success.
+ this.progressMessage = 'Success!';
+ this.errorMessage = '';
+ } else {
+ const resBuffer = await res.arrayBuffer();
+ const fbBuffer = new ByteBuffer(new Uint8Array(resBuffer));
+ const parsedResponse = ErrorResponse.getRootAsErrorResponse(fbBuffer);
+
+ const errorMessage = parsedResponse.errorMessage();
+ this.progressMessage = '';
+ this.errorMessage = `Submission failed with ${res.status} ${res.statusText}: "${errorMessage}"`;
+ }
+ }
+
+ async scanFailureHandler() {
+ this.progressMessage = '';
+ this.errorMessage = 'Failed to scan!';
+ }
+
+ loadOpenCv() {
+ // Make the browser load OpenCV.
+ let body = <HTMLDivElement>document.body;
+ let script = document.createElement('script');
+ script.innerHTML = '';
+ script.src = 'assets/opencv_4.9.0/opencv.js';
+ script.async = false;
+ script.defer = true;
+ script.onerror = (error) => {
+ this.progressMessage = '';
+ if (typeof error === 'string') {
+ this.errorMessage = `OpenCV failed to load: ${error}`;
+ } else {
+ this.errorMessage = 'OpenCV failed to load.';
+ }
+ // Since we use the onRuntimeInitialized property as a flag to see if we
+ // need to perform loading, we need to delete the property. When the user
+ // switches away from this tab and then switches back, we want to attempt
+ // loading again.
+ delete Module['onRuntimeInitialized'];
+ };
+ body.appendChild(script);
+ }
+
+ startScanning() {
+ this.scanTimer = setInterval(() => {
+ this.scan();
+ }, SCAN_PERIOD);
+ }
+}
diff --git a/scouting/www/scan/scan.module.ts b/scouting/www/scan/scan.module.ts
new file mode 100644
index 0000000..00a9c58
--- /dev/null
+++ b/scouting/www/scan/scan.module.ts
@@ -0,0 +1,10 @@
+import {NgModule} from '@angular/core';
+import {CommonModule} from '@angular/common';
+import {ScanComponent} from './scan.component';
+
+@NgModule({
+ declarations: [ScanComponent],
+ exports: [ScanComponent],
+ imports: [CommonModule],
+})
+export class ScanModule {}
diff --git a/scouting/www/scan/scan.ng.html b/scouting/www/scan/scan.ng.html
new file mode 100644
index 0000000..f9b82b3
--- /dev/null
+++ b/scouting/www/scan/scan.ng.html
@@ -0,0 +1,21 @@
+<h1>Scan</h1>
+<span class="progress_message" role="alert">{{ progressMessage }}</span>
+<span class="error_message" role="alert">{{ errorMessage }}</span>
+<nav class="qrcode-progress" *ngIf="!scanComplete">
+ <ul class="pagination pagination-lg justify-content-center">
+ <li *ngFor="let piece of qrCodeValuePieces" class="page-item">
+ <a class="page-link" href="#" [class.active]="piece">
+ <i *ngIf="piece" class="bi bi-check">
+ <span class="visually-hidden">✓</span>
+ </i>
+ <i *ngIf="!piece" class="bi bi-camera">
+ <span class="visually-hidden">☒</span>
+ </i>
+ </a>
+ </li>
+ </ul>
+</nav>
+<div *ngIf="!scanComplete">
+ <video #video id="video"></video>
+</div>
+<canvas #canvas id="canvas"></canvas>