@@ -122,52 +122,70 @@ and write-in areas, with the origin (0, 0) at the top left corner of the ballot.
122122Because no bubbles may appear in the timing mark area, bubble coordinates
123123effectively begin at (1, 1).
124124
125- #### Timing Mark Detection and Error Handling
126-
127- Candidate timing marks are detected using a contours algorithm (similar to
128- openCV's
129- [ ` findContours ` ] ( https://docs.opencv.org/3.4/d4/d73/tutorial_py_contours_begin.html )
130- function). This returns all the contiguous shapes as rectangle bounding boxes.
131- We then filter these by plausibility based on size and position. We then find
132- the top/bottom/left/right timing mark edges independently. This is done by
133- splitting the candidate timing mark rectangles into top/bottom halves and
134- left/right halves, then searching within each of those four halves for the line
135- that intersects the most rectangles and is in a plausible position and angle.
136-
137- If each edge is found, we now have what the code refers to as "partial" timing
138- marks. We then begin inferring any timing marks that we may have missed. We do
139- this by examining the spacing between each ordered pair of timing marks and, if
140- another would fit between them, we add it. When using bottom timing marks to
141- encode the ballot metadata, we infer a lot of bottom timing marks because we
142- expect many or even most of them to be missing. Once the inference is complete,
143- we now have what the code refers to as "complete" timing marks. This is the data
144- structure we use to determine the ballot layout and locate the bubbles.
145-
146- Handling errors in timing mark detection requires a balance between being too
147- restrictive and rejecting too many ballots that could have been successfully
148- interpreted and being too permissive and interpreting ballots incorrectly.
149- Because the outcome of the former is not so bad–the voter may simply scan the
150- ballot again–we err on the side of caution. We have a few strategies for
151- handling errors:
152-
153- 1 . ** Allow for some missing timing marks.** We infer missing timing marks based
154- on the spacing between known timing marks. This is especially important for
155- bottom timing marks, which may be used to encode the ballot metadata. We
156- can't allow too many missing timing marks, however, because the inference
157- process is not perfect and may introduce errors.
158- 2 . ** Require small rotation and/or skew if we've inferred any timing marks.** If
159- we didn't infer any timing marks we can be more lenient, but if we did we
160- need to be more strict about rotation and skew because they can cause the
161- inferred timing marks to be incorrect. Note that this project uses "rotation"
162- to refer to an angle by which the entire ballot is rotated, preserving the
163- equal distance of points in the grid from each other. "Skew" refers to a
164- distortion of the grid, where the distance between points in the grid is not
165- preserved, and is caused by different parts of the ballot being scanned at
166- different speeds.
167- 3 . ** Require that the number of inferred timing marks on a side matches the
168- expected number.** And that the left/right and top/bottom marks have the same
169- count. The exception to this for the top/bottom marks is when encoding
170- metadata using presence/absence of timing marks.
125+ The implementation uses a "corners" algorithm
126+ ([ timing_marks/corners/] ( src/bubble-ballot-rust/timing_marks/corners/ ) ) that
127+ starts by identifying the four corners of the ballot grid and then walks along
128+ each border to find all timing marks.
129+
130+ #### Timing Mark Detection Algorithm
131+
132+ The timing mark detection process consists of three main steps:
133+
134+ 1 . ** Shape Finding** ([ timing_marks/corners/shape_finding/mod.rs] ( src/bubble-ballot-rust/timing_marks/corners/shape_finding/mod.rs ) ):
135+ The algorithm scans the ballot image column by column within an inset region
136+ from each edge. For each column, it groups contiguous black pixels vertically
137+ and filters groups by height to match expected timing mark dimensions.
138+ Adjacent columns with similar vertical ranges are merged into shapes. These
139+ shapes are then smoothed using a median filter to eliminate bumps from stray
140+ marks or debris. Finally, shapes are filtered to ensure they match expected
141+ timing mark size and aspect ratio.
142+
143+ 2 . ** Corner Finding** ([ timing_marks/corners/corner_finding.rs] ( src/bubble-ballot-rust/timing_marks/corners/corner_finding.rs ) ):
144+ For each of the four corners (top-left, top-right, bottom-left, bottom-right),
145+ the algorithm identifies candidate corner groupings. Each grouping consists of
146+ three timing marks: the corner mark itself, plus one mark along the adjacent
147+ row and one along the adjacent column. Candidates are sorted by distance from
148+ the expected corner location. The algorithm selects the first grouping where
149+ all three marks meet minimum quality thresholds. If no such grouping is found,
150+ an error is returned.
151+
152+ 3 . ** Border Finding** ([ timing_marks/corners/border_finding.rs] ( src/bubble-ballot-rust/timing_marks/corners/border_finding.rs ) ):
153+ After corners are identified, the algorithm finds timing marks along each
154+ border by "walking" from one corner to the other. Starting at a corner mark,
155+ it computes a unit vector pointing toward the opposite corner with length
156+ equal to the expected timing mark spacing. At each step, it searches for the
157+ closest candidate mark within a tolerance of the expected spacing. If no mark
158+ is found within this tolerance, an error is returned. This continues until the
159+ ending corner is reached. Finally, the algorithm validates that each border
160+ contains exactly the expected number of timing marks (matching the grid
161+ dimensions from the election definition). If any border has an incorrect
162+ count, an error is returned. This strict validation ensures the grid is
163+ complete and accurate before proceeding to bubble scoring.
164+
165+ #### Error Handling
166+
167+ The timing mark detection algorithm errs on the side of caution, preferring to
168+ reject ballots that might be interpreted incorrectly rather than risk
169+ misinterpreting votes. Key aspects of error handling include:
170+
171+ 1 . ** No Inference of Missing Marks** : A previous version of this algorithm
172+ inferred missing timing marks based on spacing between detected marks, but the
173+ current implementation requires all marks to be physically present and
174+ detected. This change reduces the risk of misinterpretation. Missing marks
175+ cause interpretation to fail.
176+
177+ 2 . ** Strict Count Validation** : Each border must contain exactly the number of
178+ marks specified in the ballot's grid dimensions. There is no tolerance for
179+ missing or extra marks.
180+
181+ 3 . ** Minimum Mark Quality** : Corner timing marks must meet minimum quality
182+ thresholds to be accepted. This ensures that only high-quality, unambiguous
183+ marks are used as reference points for the grid.
184+
185+ 4 . ** Controlled Search Areas** : Shape finding is limited to an inset region from
186+ each edge, and border finding restricts the search for each mark to within a
187+ tolerance of the expected spacing. This prevents the algorithm from
188+ incorrectly identifying marks that are too far from their expected positions.
171189
172190### Decode Metadata
173191
@@ -183,23 +201,81 @@ etc.
183201
184202### Score Bubble Marks
185203
186- Bubble marks are scored to determine which bubbles are filled in. Locating the
187- bubble at ` (x, y) ` first locates the ` y ` th timing mark on the left and right
188- sides of the ballot. Given a line segment between the centers of these two
189- timing marks, the center of the bubble is presumed to be on this line ` x / N `
190- percent of the way from the left timing mark to the right timing mark, where ` N `
191- is the number of columns of timing marks on the ballot. The bubble is then
192- scored by comparing a template bubble image to the actual contents of the scan
193- at the bubble location. A score is computed by computing the number of new dark
194- pixels compared to the template bubble image. The score is later compared to a
195- threshold to determine if the bubble is filled in, but the core function simply
204+ Bubble marks are scored to determine which bubbles are filled in. The scoring
205+ process ([ scoring.rs] ( src/bubble-ballot-rust/scoring.rs ) ) consists of searching
206+ for the best template match starting at the expected location, then computing a
207+ score for how filled in the bubble is.
208+
209+ #### Bubble Locating
210+
211+ To compute expected bubble location within the image at grid coordinates
212+ ` (column, row) ` :
213+
214+ 1 . Find the ` row ` th timing mark on the left and right sides of the ballot. If
215+ ` row ` is fractional, interpolate vertically between the closest two rows.
216+ 2 . Account for timing marks being cropped during scanning or border removal by
217+ adjusting the timing mark positions to use the expected width.
218+ 3 . Compute a line segment between the centers of the left and right timing marks.
219+ 4 . The expected bubble center is at position ` column / (N - 1) ` along this
220+ segment, where ` N ` is the total number of columns in the grid.
221+
222+ #### Template Matching
223+
224+ To account for stretching and other distortions in the scanned image, the
225+ algorithm performs template matching against
226+ [ a typical scanned bubble] ( data/bubble_scan.png ) within a search area:
227+
228+ 1 . ** Search Area** : Starting from the expected bubble center, the algorithm
229+ searches within a small radius in all directions.
230+
231+ 2 . ** Match Score Computation** : For each position in the search area:
232+ - Crop the scanned image to the bubble template size
233+ - Apply binary thresholding using the ballot's global threshold
234+ - Compute a difference image between the thresholded crop and the template
235+ - The match score is the percentage of pixels that are white (matching) in
236+ the difference image
237+
238+ 3 . ** Best Match Selection** : The algorithm selects the position with the highest
239+ match score as the actual bubble location.
240+
241+ #### Fill Scoring
242+
243+ Once the best matching position is found, the algorithm computes how filled the
244+ bubble is:
245+
246+ 1 . Crop the scanned image at the best matching bounds
247+ 2 . Apply binary thresholding using the ballot's global threshold
248+ 3 . Compute a difference image between the template (unfilled bubble) and the
249+ thresholded source image
250+ 4 . The fill score is the percentage of pixels that are black (filled) in the
251+ difference image, representing new dark pixels compared to the template
252+
253+ The fill score represents what percentage of the bubble has been filled in
254+ beyond what the template shows. A higher fill score indicates a more completely
255+ filled bubble. The score is later compared to a threshold to determine if the
256+ bubble should be counted as marked, but the scoring function itself simply
196257computes the score and lets the caller decide how to interpret it.
197258
198259### Score Write-Ins
199260
200261Write-ins are scored to determine whether any have handwriting. The write-in
201262area is encoded in the election definition per contest option and is a rectangle
202- specified using the same grid as bubble marks. The write-in area is scored by
203- computing the ratio of dark pixels in the area. It is later compared to a
204- threshold, but similarly to bubble scoring, the core function simply computes
205- the score and lets the caller decide how to interpret it.
263+ specified using the same grid as bubble marks.
264+
265+ The scoring process ([ scoring.rs] ( src/bubble-ballot-rust/scoring.rs ) ) works as
266+ follows:
267+
268+ 1 . ** Locate Write-In Area** : The write-in area is defined as a rectangle with
269+ coordinates ` (x, y, width, height) ` in the timing mark grid. The algorithm
270+ uses the timing mark grid to convert these grid coordinates into pixel
271+ coordinates, computing the four corners of the write-in area as a
272+ quadrilateral (to account for skew and distortion).
273+
274+ 2 . ** Score Computation** : The score is the ratio of dark (foreground) pixels to
275+ total pixels within the quadrilateral area. This ratio represents how much of
276+ the write-in area contains ink or markings.
277+
278+ The score is later compared to a threshold to determine whether handwriting is
279+ present, but the core function simply computes the score and lets the caller
280+ decide how to interpret it. This enables detection of write-in votes even when
281+ the corresponding bubble is not filled in.
0 commit comments