Skip to content

Commit

Permalink
feat(scan): allow disabling streak detection
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
eventualbuddha committed Oct 24, 2024
1 parent 72bd015 commit 2ff3f2d
Show file tree
Hide file tree
Showing 16 changed files with 285 additions and 32 deletions.
95 changes: 93 additions & 2 deletions apps/central-scan/backend/src/app.scanning.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<Partial<BallotPageInfo>>(
{
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();
});
});
2 changes: 2 additions & 0 deletions apps/central-scan/backend/src/importer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ export class Importer {
electionDefinition,
precinctSelection: ALL_PRECINCTS_SELECTION,
testMode: store.getTestMode(),
disableVerticalStreakDetection:
store.isVerticalStreakDetectionDisabled(),
adjudicationReasons: store.getAdjudicationReasons(),
markThresholds: store.getMarkThresholds(),
allowOfficialBallotsInTestMode: assertDefined(
Expand Down
8 changes: 8 additions & 0 deletions apps/central-scan/backend/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,14 @@ export class Store {
.centralScanAdjudicationReasons;
}

/**
* Gets the current setting of {@link SystemSettings.disableVerticalStreakDetection},
* if present.
*/
isVerticalStreakDetectionDisabled(): boolean | undefined {
return this.getSystemSettings()?.disableVerticalStreakDetection;
}

/**
* Gets the current precinct `scan` is accepting ballots for. If set to
* `undefined`, ballots from all precincts will be accepted (this is the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Scan.GetNextReviewSheetResponse>({
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(<BallotEjectScreen isTestMode />, { 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'));
});
24 changes: 23 additions & 1 deletion apps/central-scan/frontend/src/screens/ballot_eject_screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -233,6 +239,22 @@ export function BallotEjectScreen({ isTestMode }: Props): JSX.Element | null {
}

const ejectInfo: EjectInformation = (() => {
if (verticalStreaksDetected) {
return {
header: 'Streak Detected',
body: (
<React.Fragment>
<P>
The last scanned ballot was not tabulated because the scanner
needs to be cleaned.
</P>
<P>Clean the scanner before continuing to scan ballots.</P>
</React.Fragment>
),
allowBallotDuplication: false,
};
}

if (isInvalidTestModeSheet) {
return isTestMode
? {
Expand Down
1 change: 1 addition & 0 deletions apps/scan/backend/src/scanners/custom/state_machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@ async function interpretSheet(
.electionDefinition,
precinctSelection: assertDefined(store.getPrecinctSelection()),
testMode: store.getTestMode(),
disableVerticalStreakDetection: store.isVerticalStreakDetectionDisabled(),
ballotImagesPath: workspace.ballotImagesPath,
markThresholds: store.getMarkThresholds(),
adjudicationReasons: store.getAdjudicationReasons(),
Expand Down
1 change: 1 addition & 0 deletions apps/scan/backend/src/scanners/pdi/state_machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ async function interpretSheet(
.electionDefinition,
precinctSelection: assertDefined(store.getPrecinctSelection()),
testMode: store.getTestMode(),
disableVerticalStreakDetection: store.isVerticalStreakDetectionDisabled(),
ballotImagesPath: workspace.ballotImagesPath,
markThresholds: store.getMarkThresholds(),
adjudicationReasons: store.getAdjudicationReasons(),
Expand Down
8 changes: 8 additions & 0 deletions apps/scan/backend/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,14 @@ export class Store {
.precinctScanAdjudicationReasons;
}

/**
* Gets the current setting of {@link SystemSettings.disableVerticalStreakDetection},
* if present.
*/
isVerticalStreakDetectionDisabled(): boolean | undefined {
return this.getSystemSettings()?.disableVerticalStreakDetection;
}

/**
* Gets the current precinct `scan` is accepting ballots for. If set to
* `undefined`, ballots from all precincts will be accepted (this is the
Expand Down
52 changes: 34 additions & 18 deletions libs/ballot-interpreter/src/hmpb-rust/interpret.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ pub struct Options {
pub debug_side_a_base: Option<PathBuf>,
pub debug_side_b_base: Option<PathBuf>,
pub score_write_ins: bool,
pub disable_vertical_streak_detection: bool,
}

#[derive(Debug, Serialize)]
Expand Down Expand Up @@ -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::<Result<(), _>>()?;
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::<Result<(), _>>()?;
}

let (side_a_grid_result, side_b_grid_result) = par_map_pair(
(&side_a, &mut side_a_debug),
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand Down Expand Up @@ -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");
};
Expand All @@ -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]
Expand Down
12 changes: 12 additions & 0 deletions libs/ballot-interpreter/src/hmpb-rust/js/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,17 @@ pub fn interpret(mut cx: FunctionContext) -> JsResult<JsObject> {
.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::<JsBoolean, _>(&mut cx)
.ok()
.map_or(false, |b| b.value(&mut cx));

let side_a_label = side_a_image_or_path.as_label_or(SIDE_A_LABEL);
let side_b_label = side_b_image_or_path.as_label_or(SIDE_B_LABEL);
let (side_a_image, side_b_image) = rayon::join(
Expand Down Expand Up @@ -105,6 +116,7 @@ pub fn interpret(mut cx: FunctionContext) -> JsResult<JsObject> {
debug_side_a_base,
debug_side_b_base,
score_write_ins,
disable_vertical_streak_detection,
},
);

Expand Down
Loading

0 comments on commit 2ff3f2d

Please sign in to comment.