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(), }); /**