From 9801fad97b59554f208642bdc0d8bc8d91363408 Mon Sep 17 00:00:00 2001 From: Brian Donovan <1938+eventualbuddha@users.noreply.github.com> Date: Thu, 24 Oct 2024 13:35:08 -0700 Subject: [PATCH] Add vertical streak detection controls (#5549) * fix(ballot-interpreter): correct JS argument marshaling If `options` was given as `{ scoreWriteIns: undefined }`, the interpreter would fail to coerce the value as a `bool` but would ignore the error until some point in the future when interacting with the JavaScript context. This is because the `get::` call would put the context into a throwing state, but would not then bubble that `Throw` up to the caller. As a result, subsequent calls to the context would also yield `Throw` for operations that should have succeeded. In this case the error would be "failed to downcast any to boolean". To fix this we need to not trigger throws when we don't intend to bubble them up. In particular, instead of `get::` we now `get_value()?.downcast::` so that we can bubble possible errors from the getter but then handle a mismatched type in Rust appopriately. * feat(scan): allow disabling streak detection Closes #5538 Adds a system setting that disables streak detection for an election. We might want this in case of a ballot design that triggers false positives when looking for streaks. Because we support taking someone else's AccuVote-style ballot, we may not be able to ensure no streaks are naturally present in the original image. This is unlikely, but this gives us an escape hatch. * refactor(scan): access system settings directly * fix(ballot-interpreter): pass `disableVerticalStreakDetection` Ensures that the `disableVerticalStreakDetection` system setting is passed through from the CLI. Also adds a CLI option to disable streak detection. * test(scan): ensure disabling streak detection works --- .../backend/src/app.scanning.test.ts | 95 ++++++++++++- apps/central-scan/backend/src/importer.ts | 12 +- apps/central-scan/backend/src/store.ts | 5 - .../src/screens/ballot_eject_screen.test.tsx | 51 +++++++ .../src/screens/ballot_eject_screen.tsx | 24 +++- apps/scan/backend/src/app_scanning.test.ts | 132 ++++++++++++++++++ .../src/scanners/custom/state_machine.ts | 6 +- .../backend/src/scanners/pdi/state_machine.ts | 11 +- apps/scan/backend/src/store.ts | 5 - libs/backend/src/cast_vote_records/export.ts | 6 +- .../src/hmpb-rust/interpret.rs | 52 ++++--- .../src/hmpb-rust/js/args.rs | 7 +- .../src/hmpb-rust/js/image_data.rs | 17 ++- .../src/hmpb-rust/js/mod.rs | 23 ++- libs/ballot-interpreter/src/hmpb-ts/cli.ts | 51 +++++-- .../src/hmpb-ts/interpret.ts | 43 ++++-- .../src/hmpb-ts/rust_addon.d.ts | 5 +- libs/ballot-interpreter/src/interpret.ts | 1 + libs/ballot-interpreter/src/types.ts | 1 + libs/types/src/system_settings.ts | 7 + 20 files changed, 481 insertions(+), 73 deletions(-) create mode 100644 apps/scan/backend/src/app_scanning.test.ts diff --git a/apps/central-scan/backend/src/app.scanning.test.ts b/apps/central-scan/backend/src/app.scanning.test.ts index 30f747ae40..cf19b0db18 100644 --- a/apps/central-scan/backend/src/app.scanning.test.ts +++ b/apps/central-scan/backend/src/app.scanning.test.ts @@ -1,12 +1,17 @@ -import { electionFamousNames2021Fixtures } from '@votingworks/fixtures'; import { + electionFamousNames2021Fixtures, + electionGridLayoutNewHampshireTestBallotFixtures, +} from '@votingworks/fixtures'; +import { loadImageData, writeImageData } from '@votingworks/image-utils'; +import { + BallotPageInfo, BatchInfo, DEFAULT_SYSTEM_SETTINGS, TEST_JURISDICTION, } from '@votingworks/types'; import { mockElectionManagerAuth } from '../test/helpers/auth'; -import { withApp } from '../test/helpers/setup_app'; import { generateBmdBallotFixture } from '../test/helpers/ballots'; +import { withApp } from '../test/helpers/setup_app'; import { ScannedSheetInfo } from './fujitsu_scanner'; const jurisdiction = TEST_JURISDICTION; @@ -111,3 +116,89 @@ test('continueScanning after invalid ballot', async () => { } }); }); + +test('scanBatch with streaked page', async () => { + const { electionDefinition, scanMarkedFront, scanMarkedBack } = + electionGridLayoutNewHampshireTestBallotFixtures; + + const frontPath = scanMarkedFront.asFilePath(); + const backPath = scanMarkedBack.asFilePath(); + + const frontImageData = await loadImageData(frontPath); + + // add a vertical streak + for ( + let offset = 500; + offset < frontImageData.data.length; + offset += frontImageData.width * 4 + ) { + frontImageData.data[offset] = 0; + frontImageData.data[offset + 1] = 0; + frontImageData.data[offset + 2] = 0; + frontImageData.data[offset + 3] = 255; + } + + await writeImageData(frontPath, frontImageData); + + const scannedBallot: ScannedSheetInfo = { + frontPath, + backPath, + }; + + // try with vertical streak detection enabled + await withApp(async ({ auth, apiClient, scanner, importer, workspace }) => { + mockElectionManagerAuth(auth, electionDefinition); + importer.configure( + electionDefinition, + jurisdiction, + 'test-election-package-hash' + ); + workspace.store.setSystemSettings({ + ...DEFAULT_SYSTEM_SETTINGS, + // enable vertical streak detection + disableVerticalStreakDetection: false, + }); + await apiClient.setTestMode({ testMode: true }); + + scanner.withNextScannerSession().sheet(scannedBallot).end(); + + await apiClient.scanBatch(); + await importer.waitForEndOfBatchOrScanningPause(); + + const nextAdjudicationSheet = workspace.store.getNextAdjudicationSheet(); + + // adjudication should be needed because of the vertical streak + expect(nextAdjudicationSheet?.front).toMatchObject>( + { + interpretation: { + type: 'UnreadablePage', + reason: 'verticalStreaksDetected', + }, + } + ); + }); + + // try again with vertical streak detection disabled + await withApp(async ({ auth, apiClient, scanner, importer, workspace }) => { + mockElectionManagerAuth(auth, electionDefinition); + importer.configure( + electionDefinition, + jurisdiction, + 'test-election-package-hash' + ); + workspace.store.setSystemSettings({ + ...DEFAULT_SYSTEM_SETTINGS, + // disable vertical streak detection + disableVerticalStreakDetection: true, + }); + await apiClient.setTestMode({ testMode: true }); + + scanner.withNextScannerSession().sheet(scannedBallot).end(); + + await apiClient.scanBatch(); + await importer.waitForEndOfBatchOrScanningPause(); + + // no adjudication should be needed + expect(workspace.store.getNextAdjudicationSheet()).toBeUndefined(); + }); +}); diff --git a/apps/central-scan/backend/src/importer.ts b/apps/central-scan/backend/src/importer.ts index 69823404ea..29ed84f69e 100644 --- a/apps/central-scan/backend/src/importer.ts +++ b/apps/central-scan/backend/src/importer.ts @@ -188,6 +188,11 @@ export class Importer { ): Promise, Error>> { const electionDefinition = this.getElectionDefinition(); const { store } = this.workspace; + const { + allowOfficialBallotsInTestMode, + disableVerticalStreakDetection, + markThresholds, + } = assertDefined(store.getSystemSettings()); return ok( await interpretSheetAndSaveImages( @@ -195,11 +200,10 @@ export class Importer { electionDefinition, precinctSelection: ALL_PRECINCTS_SELECTION, testMode: store.getTestMode(), + disableVerticalStreakDetection, adjudicationReasons: store.getAdjudicationReasons(), - markThresholds: store.getMarkThresholds(), - allowOfficialBallotsInTestMode: assertDefined( - store.getSystemSettings() - ).allowOfficialBallotsInTestMode, + markThresholds, + allowOfficialBallotsInTestMode, }, [frontImageData, backImageData], sheetId, diff --git a/apps/central-scan/backend/src/store.ts b/apps/central-scan/backend/src/store.ts index f2612b6d21..4d05e056f8 100644 --- a/apps/central-scan/backend/src/store.ts +++ b/apps/central-scan/backend/src/store.ts @@ -10,7 +10,6 @@ import { BatchInfo, Iso8601Timestamp, mapSheet, - MarkThresholds, PageInterpretationSchema, PageInterpretationWithFiles, PollsState as PollsStateType, @@ -382,10 +381,6 @@ export class Store { ); } - getMarkThresholds(): MarkThresholds { - return assertDefined(this.getSystemSettings()).markThresholds; - } - getAdjudicationReasons(): readonly AdjudicationReason[] { return assertDefined(this.getSystemSettings()) .centralScanAdjudicationReasons; diff --git a/apps/central-scan/frontend/src/screens/ballot_eject_screen.test.tsx b/apps/central-scan/frontend/src/screens/ballot_eject_screen.test.tsx index 5f12a862d1..0e5aa98a00 100644 --- a/apps/central-scan/frontend/src/screens/ballot_eject_screen.test.tsx +++ b/apps/central-scan/frontend/src/screens/ballot_eject_screen.test.tsx @@ -707,3 +707,54 @@ test('does not allow tabulating the overvote if disallowCastingOvervotes is set' apiMock.expectContinueScanning({ forceAccept: false }); userEvent.click(screen.getByText('Confirm Ballot Removed')); }); + +test('says the scanner needs cleaning if a streak is detected', async () => { + fetchMock.getOnce( + '/central-scanner/scan/hmpb/review/next-sheet', + typedAs({ + interpreted: { + id: 'mock-sheet-id', + front: { + image: { url: '/front/url' }, + interpretation: { + type: 'UnreadablePage', + reason: 'verticalStreaksDetected', + }, + }, + back: { + image: { url: '/back/url' }, + interpretation: { + type: 'UnreadablePage', + reason: 'verticalStreaksDetected', + }, + }, + }, + layouts: {}, + definitions: {}, + }) + ); + + const logger = mockBaseLogger(); + + renderInAppContext(, { apiMock, logger }); + + await screen.findByText('Streak Detected'); + screen.getByText( + 'The last scanned ballot was not tabulated because the scanner needs to be cleaned.' + ); + screen.getByText('Clean the scanner before continuing to scan ballots.'); + expect(screen.getByRole('button').textContent).toEqual( + 'Confirm Ballot Removed' + ); + + expect(logger.log).toHaveBeenCalledTimes(1); + expect(logger.log).toHaveBeenCalledWith( + LogEventId.ScanAdjudicationInfo, + 'election_manager', + expect.objectContaining({ + adjudicationTypes: 'UnreadablePage', + }) + ); + apiMock.expectContinueScanning({ forceAccept: false }); + userEvent.click(screen.getByText('Confirm Ballot Removed')); +}); diff --git a/apps/central-scan/frontend/src/screens/ballot_eject_screen.tsx b/apps/central-scan/frontend/src/screens/ballot_eject_screen.tsx index c759dcfc64..7efc69118a 100644 --- a/apps/central-scan/frontend/src/screens/ballot_eject_screen.tsx +++ b/apps/central-scan/frontend/src/screens/ballot_eject_screen.tsx @@ -156,6 +156,7 @@ export function BallotEjectScreen({ isTestMode }: Props): JSX.Element | null { let isOvervotedSheet = false; let isUndervotedSheet = false; + let verticalStreaksDetected = false; let isFrontBlank = false; let isBackBlank = false; let isInvalidTestModeSheet = false; @@ -186,7 +187,12 @@ export function BallotEjectScreen({ isTestMode }: Props): JSX.Element | null { reviewInfo.interpreted.back.adjudicationFinishedAt, }, ]) { - if (reviewPageInfo.interpretation.type === 'InvalidTestModePage') { + if ( + reviewPageInfo.interpretation.type === 'UnreadablePage' && + reviewPageInfo.interpretation.reason === 'verticalStreaksDetected' + ) { + verticalStreaksDetected = true; + } else if (reviewPageInfo.interpretation.type === 'InvalidTestModePage') { isInvalidTestModeSheet = true; } else if (reviewPageInfo.interpretation.type === 'InvalidBallotHashPage') { isInvalidBallotHashSheet = true; @@ -233,6 +239,22 @@ export function BallotEjectScreen({ isTestMode }: Props): JSX.Element | null { } const ejectInfo: EjectInformation = (() => { + if (verticalStreaksDetected) { + return { + header: 'Streak Detected', + body: ( + +

+ The last scanned ballot was not tabulated because the scanner + needs to be cleaned. +

+

Clean the scanner before continuing to scan ballots.

+
+ ), + allowBallotDuplication: false, + }; + } + if (isInvalidTestModeSheet) { return isTestMode ? { diff --git a/apps/scan/backend/src/app_scanning.test.ts b/apps/scan/backend/src/app_scanning.test.ts new file mode 100644 index 0000000000..ab293cbeca --- /dev/null +++ b/apps/scan/backend/src/app_scanning.test.ts @@ -0,0 +1,132 @@ +import { electionGridLayoutNewHampshireTestBallotFixtures } from '@votingworks/fixtures'; +import { DEFAULT_SYSTEM_SETTINGS } from '@votingworks/types'; +import { + BooleanEnvironmentVariableName, + getFeatureFlagMock, +} from '@votingworks/utils'; +import { simulateScan, withApp } from '../test/helpers/pdi_helpers'; +import { configureApp, waitForStatus } from '../test/helpers/shared_helpers'; +import { delays } from './scanners/pdi/state_machine'; + +const mockFeatureFlagger = getFeatureFlagMock(); + +jest.mock('@votingworks/utils', (): typeof import('@votingworks/utils') => { + return { + ...jest.requireActual('@votingworks/utils'), + isFeatureFlagEnabled: (flag) => mockFeatureFlagger.isEnabled(flag), + }; +}); + +beforeEach(() => { + mockFeatureFlagger.resetFeatureFlags(); + mockFeatureFlagger.enableFeatureFlag( + BooleanEnvironmentVariableName.SKIP_ELECTION_PACKAGE_AUTHENTICATION + ); +}); + +test('scanBatch with streaked page', async () => { + const { scanMarkedFront, scanMarkedBack } = + electionGridLayoutNewHampshireTestBallotFixtures; + + const frontImageData = await scanMarkedFront.asImageData(); + const backImageData = await scanMarkedBack.asImageData(); + + // add a vertical streak + for ( + let offset = 500; + offset < frontImageData.data.length; + offset += frontImageData.width * 4 + ) { + frontImageData.data[offset] = 0; + frontImageData.data[offset + 1] = 0; + frontImageData.data[offset + 2] = 0; + frontImageData.data[offset + 3] = 255; + } + + // try with vertical streak detection enabled + await withApp( + async ({ + apiClient, + clock, + mockAuth, + mockScanner, + mockUsbDrive, + workspace, + }) => { + await configureApp(apiClient, mockAuth, mockUsbDrive, { + electionPackage: + electionGridLayoutNewHampshireTestBallotFixtures.electionJson.toElectionPackage(), + testMode: true, + }); + + workspace.store.setSystemSettings({ + ...DEFAULT_SYSTEM_SETTINGS, + // enable vertical streak detection + disableVerticalStreakDetection: false, + }); + + clock.increment(delays.DELAY_SCANNING_ENABLED_POLLING_INTERVAL); + await waitForStatus(apiClient, { state: 'no_paper' }); + expect(mockScanner.client.enableScanning).toHaveBeenCalledWith({ + doubleFeedDetectionEnabled: true, + paperLengthInches: 11, + }); + + await simulateScan(apiClient, mockScanner, [ + frontImageData, + backImageData, + ]); + + await waitForStatus(apiClient, { + state: 'rejecting', + interpretation: { + type: 'InvalidSheet', + reason: 'vertical_streaks_detected', + }, + }); + } + ); + + // try again with vertical streak detection disabled + await withApp( + async ({ + apiClient, + clock, + mockAuth, + mockScanner, + mockUsbDrive, + workspace, + }) => { + await configureApp(apiClient, mockAuth, mockUsbDrive, { + electionPackage: + electionGridLayoutNewHampshireTestBallotFixtures.electionJson.toElectionPackage(), + testMode: true, + }); + + workspace.store.setSystemSettings({ + ...DEFAULT_SYSTEM_SETTINGS, + // disable vertical streak detection + disableVerticalStreakDetection: true, + }); + + clock.increment(delays.DELAY_SCANNING_ENABLED_POLLING_INTERVAL); + await waitForStatus(apiClient, { state: 'no_paper' }); + expect(mockScanner.client.enableScanning).toHaveBeenCalledWith({ + doubleFeedDetectionEnabled: true, + paperLengthInches: 11, + }); + + await simulateScan(apiClient, mockScanner, [ + frontImageData, + backImageData, + ]); + + await waitForStatus(apiClient, { + state: 'accepting', + interpretation: { + type: 'ValidSheet', + }, + }); + } + ); +}); diff --git a/apps/scan/backend/src/scanners/custom/state_machine.ts b/apps/scan/backend/src/scanners/custom/state_machine.ts index d7c69f333e..cd52eb6e62 100644 --- a/apps/scan/backend/src/scanners/custom/state_machine.ts +++ b/apps/scan/backend/src/scanners/custom/state_machine.ts @@ -303,14 +303,18 @@ async function interpretSheet( assert(scannedSheet); const sheetId = uuid(); const { store } = workspace; + const { disableVerticalStreakDetection, markThresholds } = assertDefined( + store.getSystemSettings() + ); const interpretation = ( await interpret(sheetId, scannedSheet, { electionDefinition: assertDefined(store.getElectionRecord()) .electionDefinition, precinctSelection: assertDefined(store.getPrecinctSelection()), testMode: store.getTestMode(), + disableVerticalStreakDetection, ballotImagesPath: workspace.ballotImagesPath, - markThresholds: store.getMarkThresholds(), + markThresholds, adjudicationReasons: store.getAdjudicationReasons(), }) ).unsafeUnwrap(); diff --git a/apps/scan/backend/src/scanners/pdi/state_machine.ts b/apps/scan/backend/src/scanners/pdi/state_machine.ts index ab4bae011f..f9baf4a288 100644 --- a/apps/scan/backend/src/scanners/pdi/state_machine.ts +++ b/apps/scan/backend/src/scanners/pdi/state_machine.ts @@ -63,17 +63,22 @@ async function interpretSheet( const { store } = workspace; const interpretTimer = time(debug, 'interpret'); + const { + allowOfficialBallotsInTestMode, + disableVerticalStreakDetection, + markThresholds, + } = assertDefined(store.getSystemSettings()); const interpretation = ( await interpret(sheetId, scanImages, { electionDefinition: assertDefined(store.getElectionRecord()) .electionDefinition, precinctSelection: assertDefined(store.getPrecinctSelection()), testMode: store.getTestMode(), + disableVerticalStreakDetection, ballotImagesPath: workspace.ballotImagesPath, - markThresholds: store.getMarkThresholds(), + markThresholds, adjudicationReasons: store.getAdjudicationReasons(), - allowOfficialBallotsInTestMode: assertDefined(store.getSystemSettings()) - .allowOfficialBallotsInTestMode, + allowOfficialBallotsInTestMode, }) ).unsafeUnwrap(); interpretTimer.end(); diff --git a/apps/scan/backend/src/store.ts b/apps/scan/backend/src/store.ts index 99d0a12b35..636fd3e928 100644 --- a/apps/scan/backend/src/store.ts +++ b/apps/scan/backend/src/store.ts @@ -10,7 +10,6 @@ import { BatchInfo, Iso8601Timestamp, mapSheet, - MarkThresholds, PageInterpretationSchema, PageInterpretationWithFiles, PollsState as PollsStateType, @@ -422,10 +421,6 @@ export class Store { ); } - getMarkThresholds(): MarkThresholds { - return assertDefined(this.getSystemSettings()).markThresholds; - } - getAdjudicationReasons(): readonly AdjudicationReason[] { return assertDefined(this.getSystemSettings()) .precinctScanAdjudicationReasons; diff --git a/libs/backend/src/cast_vote_records/export.ts b/libs/backend/src/cast_vote_records/export.ts index ff85c13236..e06f9bb824 100644 --- a/libs/backend/src/cast_vote_records/export.ts +++ b/libs/backend/src/cast_vote_records/export.ts @@ -81,7 +81,6 @@ export interface ScannerStoreBase { getCastVoteRecordRootHash(): string; getElectionRecord(): ElectionRecord | undefined; getSystemSettings(): SystemSettings | undefined; - getMarkThresholds(): MarkThresholds; getTestMode(): boolean; updateCastVoteRecordHashes( castVoteRecordId: string, @@ -696,6 +695,7 @@ export async function exportCastVoteRecordsToUsbDrive( if (usbMountPoint === undefined) { return err({ type: 'missing-usb-drive' }); } + const systemSettings = assertDefined(scannerStore.getSystemSettings()); const exportContext: ExportContext = { exporter: new Exporter({ allowedExportPatterns: SCAN_ALLOWED_EXPORT_PATTERNS, @@ -706,9 +706,9 @@ export async function exportCastVoteRecordsToUsbDrive( batches: scannerStore.getBatches(), electionDefinition: assertDefined(scannerStore.getElectionRecord()) .electionDefinition, - systemSettings: assertDefined(scannerStore.getSystemSettings()), + systemSettings, inTestMode: scannerStore.getTestMode(), - markThresholds: scannerStore.getMarkThresholds(), + markThresholds: systemSettings.markThresholds, pollsState: scannerStore.scannerType === 'precinct' ? scannerStore.getPollsState() diff --git a/libs/ballot-interpreter/src/hmpb-rust/interpret.rs b/libs/ballot-interpreter/src/hmpb-rust/interpret.rs index bcc0d594af..8f02c94772 100644 --- a/libs/ballot-interpreter/src/hmpb-rust/interpret.rs +++ b/libs/ballot-interpreter/src/hmpb-rust/interpret.rs @@ -51,6 +51,7 @@ pub struct Options { pub debug_side_a_base: Option, pub debug_side_b_base: Option, pub score_write_ins: bool, + pub disable_vertical_streak_detection: bool, } #[derive(Debug, Serialize)] @@ -321,23 +322,25 @@ pub fn ballot_card( None => ImageDebugWriter::disabled(), }; - [ - (SIDE_A_LABEL, &side_a, &side_a_debug), - (SIDE_B_LABEL, &side_b, &side_b_debug), - ] - .par_iter() - .map(|(label, side, debug)| { - let streaks = detect_vertical_streaks(&side.image, side.threshold, debug); - if streaks.is_empty() { - Ok(()) - } else { - Err(Error::VerticalStreaksDetected { - label: (*label).to_string(), - x_coordinates: streaks, - }) - } - }) - .collect::>()?; + if !options.disable_vertical_streak_detection { + [ + (SIDE_A_LABEL, &side_a, &side_a_debug), + (SIDE_B_LABEL, &side_b, &side_b_debug), + ] + .par_iter() + .map(|(label, side, debug)| { + let streaks = detect_vertical_streaks(&side.image, side.threshold, debug); + if streaks.is_empty() { + Ok(()) + } else { + Err(Error::VerticalStreaksDetected { + label: (*label).to_string(), + x_coordinates: streaks, + }) + } + }) + .collect::>()?; + } let (side_a_grid_result, side_b_grid_result) = par_map_pair( (&side_a, &mut side_a_debug), @@ -697,6 +700,7 @@ mod test { bubble_template, election, score_write_ins: true, + disable_vertical_streak_detection: false, }; (side_a_image, side_b_image, options) } @@ -723,6 +727,7 @@ mod test { bubble_template, election, score_write_ins: true, + disable_vertical_streak_detection: false, }; (side_a_image, side_b_image, options) } @@ -884,7 +889,7 @@ mod test { let Error::VerticalStreaksDetected { label, x_coordinates, - } = ballot_card(side_a_image, side_b_image, &options).unwrap_err() + } = ballot_card(side_a_image.clone(), side_b_image.clone(), &options).unwrap_err() else { panic!("wrong error type"); }; @@ -897,6 +902,17 @@ mod test { fuzzy_streak_x as PixelPosition ] ); + + // ensure that we do NOT detect streaks when the option is disabled + ballot_card( + side_a_image, + side_b_image, + &Options { + disable_vertical_streak_detection: true, + ..options + }, + ) + .unwrap(); } #[test] diff --git a/libs/ballot-interpreter/src/hmpb-rust/js/args.rs b/libs/ballot-interpreter/src/hmpb-rust/js/args.rs index 833da09d70..9ab8948503 100644 --- a/libs/ballot-interpreter/src/hmpb-rust/js/args.rs +++ b/libs/ballot-interpreter/src/hmpb-rust/js/args.rs @@ -33,10 +33,9 @@ pub fn get_image_data_or_path_from_arg( let path = path.value(&mut *cx); Ok(ImageSource::Path(PathBuf::from(path))) } else if let Ok(image_data) = argument.downcast::(cx) { - ImageData::from_js_object(cx, image_data).map_or_else( - || cx.throw_type_error("unable to read argument as ImageData"), - |image| Ok(ImageSource::ImageData(image)), - ) + Ok(ImageSource::ImageData(ImageData::from_js_object( + cx, image_data, + )?)) } else { cx.throw_type_error("expected image data or path") } diff --git a/libs/ballot-interpreter/src/hmpb-rust/js/image_data.rs b/libs/ballot-interpreter/src/hmpb-rust/js/image_data.rs index 09d01196ad..9b8e84e4fe 100644 --- a/libs/ballot-interpreter/src/hmpb-rust/js/image_data.rs +++ b/libs/ballot-interpreter/src/hmpb-rust/js/image_data.rs @@ -35,19 +35,18 @@ impl ImageData { } /// Converts a JavaScript `ImageData` object to a Rust `ImageData`. - pub fn from_js_object(cx: &mut FunctionContext, js_object: Handle) -> Option { - let width = js_object.get::(cx, "width").ok()?.value(cx) as u32; - let height = js_object - .get::(cx, "height") - .ok()? - .value(cx) as u32; + pub fn from_js_object( + cx: &mut FunctionContext, + js_object: Handle, + ) -> NeonResult { + let width = js_object.get::(cx, "width")?.value(cx) as u32; + let height = js_object.get::(cx, "height")?.value(cx) as u32; let data = js_object - .get::(cx, "data") - .ok()? + .get::(cx, "data")? .borrow() .as_slice(cx) .to_vec(); - Some(Self::new(width, height, data)) + Ok(Self::new(width, height, data)) } pub fn to_js_object<'a>(&self, cx: &mut FunctionContext<'a>) -> JsResult<'a, JsObject> { diff --git a/libs/ballot-interpreter/src/hmpb-rust/js/mod.rs b/libs/ballot-interpreter/src/hmpb-rust/js/mod.rs index cde80bf03d..539214e1fb 100644 --- a/libs/ballot-interpreter/src/hmpb-rust/js/mod.rs +++ b/libs/ballot-interpreter/src/hmpb-rust/js/mod.rs @@ -54,13 +54,33 @@ pub fn interpret(mut cx: FunctionContext) -> JsResult { let side_b_image_or_path = get_image_data_or_path_from_arg(&mut cx, 2)?; let debug_side_a_base = get_path_from_arg_opt(&mut cx, 3); let debug_side_b_base = get_path_from_arg_opt(&mut cx, 4); + + // Equivalent to: + // let options = typeof arguments[5] === 'object' ? arguments[5] : {}; let options = match cx.argument_opt(5) { Some(arg) => arg.downcast::(&mut cx).or_throw(&mut cx)?, None => cx.empty_object(), }; + // Equivalent to: + // let score_write_ins = + // typeof options.scoreWriteIns === 'boolean' + // ? options.scoreWriteIns + // : false; let score_write_ins = options - .get::(&mut cx, "scoreWriteIns") + .get_value(&mut cx, "scoreWriteIns")? + .downcast::(&mut cx) + .ok() + .map_or(false, |b| b.value(&mut cx)); + + // Equivalent to: + // let disable_vertical_streak_detection = + // typeof options.disableVerticalStreakDetection === 'boolean' + // ? options.disableVerticalStreakDetection + // : false; + let disable_vertical_streak_detection = options + .get_value(&mut cx, "disableVerticalStreakDetection")? + .downcast::(&mut cx) .ok() .map_or(false, |b| b.value(&mut cx)); @@ -96,6 +116,7 @@ pub fn interpret(mut cx: FunctionContext) -> JsResult { debug_side_a_base, debug_side_b_base, score_write_ins, + disable_vertical_streak_detection, }, ); diff --git a/libs/ballot-interpreter/src/hmpb-ts/cli.ts b/libs/ballot-interpreter/src/hmpb-ts/cli.ts index 9ed30a5935..bd9dccb138 100644 --- a/libs/ballot-interpreter/src/hmpb-ts/cli.ts +++ b/libs/ballot-interpreter/src/hmpb-ts/cli.ts @@ -48,14 +48,19 @@ function usage(out: NodeJS.WritableStream): void { ); out.write(`\n`); out.write(chalk.bold(`Options:\n`)); - out.write(' -h, --help Show this help text.\n'); - out.write(' -w, --write-ins Score write-in areas.\n'); - out.write(` -j, --json Output JSON instead of human-readable text.\n`); + out.write(' -h, --help Show this help text.\n'); + out.write(' -w, --write-ins Score write-in areas.\n'); out.write( - ` -d, --debug Output debug information (images alongside inputs).\n` + ` -j, --json Output JSON instead of human-readable text.\n` ); out.write( - ' --default-mark-thresholds Use default mark thresholds if none provided.\n' + ` -d, --debug Output debug information (images alongside inputs).\n` + ); + out.write( + ' --default-mark-thresholds Use default mark thresholds if none provided.\n' + ); + out.write( + ' --disable-vertical-streak-detection Disable vertical streak detection.\n' ); out.write(`\n`); out.write(chalk.bold('Examples:\n')); @@ -197,7 +202,8 @@ async function interpretFiles( { stdout, stderr, - scoreWriteIns = false, + scoreWriteIns, + disableVerticalStreakDetection, json = false, debug = false, useDefaultMarkThresholds = false, @@ -205,6 +211,7 @@ async function interpretFiles( stdout: NodeJS.WritableStream; stderr: NodeJS.WritableStream; scoreWriteIns?: boolean; + disableVerticalStreakDetection?: boolean; json?: boolean; debug?: boolean; useDefaultMarkThresholds?: boolean; @@ -213,7 +220,13 @@ async function interpretFiles( const result = interpret( electionDefinition, [ballotPathSideA, ballotPathSideB], - { scoreWriteIns, debug } + { + scoreWriteIns, + disableVerticalStreakDetection: + disableVerticalStreakDetection ?? + systemSettings?.disableVerticalStreakDetection, + debug, + } ); if (result.isErr()) { @@ -346,6 +359,7 @@ async function interpretWorkspace( stderr, sheetIds, scoreWriteIns = false, + disableVerticalStreakDetection, json = false, debug = false, useDefaultMarkThresholds = false, @@ -354,6 +368,7 @@ async function interpretWorkspace( stderr: NodeJS.WritableStream; sheetIds: Iterable; scoreWriteIns?: boolean; + disableVerticalStreakDetection?: boolean; json?: boolean; debug?: boolean; useDefaultMarkThresholds?: boolean; @@ -451,7 +466,15 @@ async function interpretWorkspace( electionDefinition, systemSettings, correctionResult.ok(), - { stdout, stderr, scoreWriteIns, json, debug, useDefaultMarkThresholds } + { + stdout, + stderr, + scoreWriteIns, + disableVerticalStreakDetection, + json, + debug, + useDefaultMarkThresholds, + } ); count += 1; @@ -477,6 +500,7 @@ async function interpretCastVoteRecordFolder( stdout, stderr, scoreWriteIns = false, + disableVerticalStreakDetection, json = false, debug = false, useDefaultMarkThresholds = false, @@ -484,6 +508,7 @@ async function interpretCastVoteRecordFolder( stdout: NodeJS.WritableStream; stderr: NodeJS.WritableStream; scoreWriteIns?: boolean; + disableVerticalStreakDetection?: boolean; json?: boolean; debug?: boolean; useDefaultMarkThresholds?: boolean; @@ -512,6 +537,7 @@ async function interpretCastVoteRecordFolder( stdout, stderr, scoreWriteIns, + disableVerticalStreakDetection, json, debug, useDefaultMarkThresholds, @@ -535,6 +561,7 @@ export async function main(args: string[], io: IO = process): Promise { let ballotPathSideA: string | undefined; let ballotPathSideB: string | undefined; let scoreWriteIns: boolean | undefined; + let disableVerticalStreakDetection: boolean | undefined; let json = false; let debug = false; let useDefaultMarkThresholds = false; @@ -565,6 +592,11 @@ export async function main(args: string[], io: IO = process): Promise { continue; } + if (arg === '--disable-vertical-streak-detection') { + disableVerticalStreakDetection = true; + continue; + } + if (arg.startsWith('-')) { io.stderr.write(`Unknown option: ${arg}\n`); usage(io.stderr); @@ -627,6 +659,7 @@ export async function main(args: string[], io: IO = process): Promise { stderr: io.stderr, json, scoreWriteIns, + disableVerticalStreakDetection, debug, useDefaultMarkThresholds, }); @@ -674,6 +707,7 @@ export async function main(args: string[], io: IO = process): Promise { stdout: io.stdout, stderr: io.stderr, scoreWriteIns, + disableVerticalStreakDetection, json, debug, useDefaultMarkThresholds, @@ -689,6 +723,7 @@ export async function main(args: string[], io: IO = process): Promise { stdout: io.stdout, stderr: io.stderr, scoreWriteIns, + disableVerticalStreakDetection, json, debug, useDefaultMarkThresholds, diff --git a/libs/ballot-interpreter/src/hmpb-ts/interpret.ts b/libs/ballot-interpreter/src/hmpb-ts/interpret.ts index e24a48d8c6..6037362972 100644 --- a/libs/ballot-interpreter/src/hmpb-ts/interpret.ts +++ b/libs/ballot-interpreter/src/hmpb-ts/interpret.ts @@ -38,8 +38,16 @@ function normalizeArgumentsForBridge( electionDefinition: ElectionDefinition, ballotImageSources: SheetOf | SheetOf, options: - | { scoreWriteIns?: boolean; debug?: boolean } - | { scoreWriteIns?: boolean; debugBasePaths?: SheetOf } + | { + scoreWriteIns?: boolean; + disableVerticalStreakDetection?: boolean; + debug?: boolean; + } + | { + scoreWriteIns?: boolean; + disableVerticalStreakDetection?: boolean; + debugBasePaths?: SheetOf; + } = {} ): Parameters { assert(typeof electionDefinition.electionData === 'string'); assert(ballotImageSources.length === 2); @@ -66,7 +74,10 @@ function normalizeArgumentsForBridge( ...ballotImageSources, debugBasePathSideA, debugBasePathSideB, - { scoreWriteIns: options.scoreWriteIns ?? false }, + { + scoreWriteIns: options.scoreWriteIns, + disableVerticalStreakDetection: options.disableVerticalStreakDetection, + }, ]; } @@ -76,7 +87,11 @@ function normalizeArgumentsForBridge( export function interpret( electionDefinition: ElectionDefinition, ballotImagePaths: SheetOf, - options?: { scoreWriteIns?: boolean; debug?: boolean } + options?: { + scoreWriteIns?: boolean; + disableVerticalStreakDetection?: boolean; + debug?: boolean; + } ): HmpbInterpretResult; /** * Interprets a scanned ballot. @@ -84,7 +99,11 @@ export function interpret( export function interpret( electionDefinition: ElectionDefinition, ballotImages: SheetOf, - options?: { scoreWriteIns?: boolean; debugBasePaths?: SheetOf } + options?: { + scoreWriteIns?: boolean; + disableVerticalStreakDetection?: boolean; + debugBasePaths?: SheetOf; + } ): HmpbInterpretResult; /** * Interprets a scanned ballot. @@ -92,9 +111,17 @@ export function interpret( export function interpret( electionDefinition: ElectionDefinition, ballotImageSources: SheetOf | SheetOf, - options: - | { scoreWriteIns?: boolean; debug?: boolean } - | { scoreWriteIns?: boolean; debugBasePaths?: SheetOf } = {} + options?: + | { + scoreWriteIns?: boolean; + disableVerticalStreakDetection?: boolean; + debug?: boolean; + } + | { + scoreWriteIns?: boolean; + disableVerticalStreakDetection?: boolean; + debugBasePaths?: SheetOf; + } ): HmpbInterpretResult { const args = normalizeArgumentsForBridge( electionDefinition, diff --git a/libs/ballot-interpreter/src/hmpb-ts/rust_addon.d.ts b/libs/ballot-interpreter/src/hmpb-ts/rust_addon.d.ts index c021bcdd02..777ce7b164 100644 --- a/libs/ballot-interpreter/src/hmpb-ts/rust_addon.d.ts +++ b/libs/ballot-interpreter/src/hmpb-ts/rust_addon.d.ts @@ -28,7 +28,10 @@ export function interpret( ballotImageSourceSideB: string | ImageData, debugBasePathSideA?: string, debugBasePathSideB?: string, - options?: { scoreWriteIns?: boolean } + options?: { + scoreWriteIns?: boolean; + disableVerticalStreakDetection?: boolean; + } ): BridgeInterpretResult; /** diff --git a/libs/ballot-interpreter/src/interpret.ts b/libs/ballot-interpreter/src/interpret.ts index dc2bc0f554..ab1c55c08e 100644 --- a/libs/ballot-interpreter/src/interpret.ts +++ b/libs/ballot-interpreter/src/interpret.ts @@ -614,6 +614,7 @@ function interpretHmpb( const { electionDefinition } = options; const result = interpretHmpbBallotSheetRust(electionDefinition, sheet, { scoreWriteIns: shouldScoreWriteIns(options), + disableVerticalStreakDetection: options.disableVerticalStreakDetection, }); return validateInterpretResults( diff --git a/libs/ballot-interpreter/src/types.ts b/libs/ballot-interpreter/src/types.ts index 4bebbda5bb..2dbeae148c 100644 --- a/libs/ballot-interpreter/src/types.ts +++ b/libs/ballot-interpreter/src/types.ts @@ -12,6 +12,7 @@ export interface InterpreterOptions { adjudicationReasons: readonly AdjudicationReason[]; electionDefinition: ElectionDefinition; allowOfficialBallotsInTestMode?: boolean; + disableVerticalStreakDetection?: boolean; markThresholds: MarkThresholds; precinctSelection: PrecinctSelection; testMode: boolean; diff --git a/libs/types/src/system_settings.ts b/libs/types/src/system_settings.ts index 9efe33ec9e..06d9246e65 100644 --- a/libs/types/src/system_settings.ts +++ b/libs/types/src/system_settings.ts @@ -74,6 +74,12 @@ export interface SystemSettings { * import/export time (required for CDF). */ readonly castVoteRecordsIncludeRedundantMetadata?: boolean; + /** + * Disables vertical streak detection when scanning. This should only be used + * as a workaround in case the ballots have a design that triggers false + * positives. + */ + readonly disableVerticalStreakDetection?: boolean; } export const SystemSettingsSchema: z.ZodType = z.object({ @@ -90,6 +96,7 @@ export const SystemSettingsSchema: z.ZodType = z.object({ precinctScanEnableShoeshineMode: z.boolean().optional(), castVoteRecordsIncludeOriginalSnapshots: z.boolean().optional(), castVoteRecordsIncludeRedundantMetadata: z.boolean().optional(), + disableVerticalStreakDetection: z.boolean().optional(), }); /**