Resources Numerical accuracy Calculation policy

Automatic count by symbol matching

Finds every copy of a boxed symbol on the sheets by comparing pictures, so the copies can be counted.

How the precision is kept

The search runs on rasters of the sheets, held as 32-bit ink values. The match score of each position is summed in 64-bit but stored in a Float32Array, and that stored score is what is compared with the threshold that decides whether a position is counted. The count itself is a whole number. What limits the result is the raster and the method (pixel positions, angle steps), far more than the 32-bit score.

Open item. Hold the correlation scores in a Float64Array so that the comparison with the threshold, which decides what is counted, is made on 64-bit values.

Calculations under this policy

15 calculations in 4 source files. The code is read from the released app when this page loads. It is the calculation people run, not a copy kept for this page.

match.js TakeoffCraft 0.8.1 32-bit in part 8 calculations Reviewed

Tests bin/test-takeoffcraft.mjs, bin/test-takeoffcraft-cad.mjs, tools/takeoffcraft-test/anyangle.mjs

Picture to ink darkness

32-bit in part

Turns the canvas pixels of a sheet into darkness from 0 (paper) to 1 (black).

ink = 1 − (lum·a + (1 − a)), lum = (0.299R + 0.587G + 0.114B) ÷ 255

Luminance is 0.299 R + 0.587 G + 0.114 B over 255, mixed with the alpha channel, computed in 64-bit and stored in a Float32Array. The source is 8 bits per channel.

  • match.js
  • lines 17 to 26
  • TakeoffCraft 0.8.1
/** RGBA (canvas ImageData) → ink darkness 0..1. */
export function toInk(rgba, w, h) {
  const out = new Float32Array(w * h);
  for (let i = 0, j = 0; i < out.length; i++, j += 4) {
    const a = rgba[j + 3] / 255;
    const lum = (0.299 * rgba[j] + 0.587 * rgba[j + 1] + 0.114 * rgba[j + 2]) / 255;
    out[i] = 1 - (lum * a + (1 - a));       // transparent = paper
  }
  return out;
}

Symbol turned to an angle

32-bit in part

Rotates the symbol's raster for a search at an angle.

Right angles move pixels without resampling. Any other angle resamples bilinearly into a Float32Array, which softens thin lines; paper is assumed outside the symbol.

  • match.js
  • lines 43 to 64
  • TakeoffCraft 0.8.1
/**
 * Rotate a raster by `deg` clockwise (as seen on screen, y down) about its middle, into the box that holds it all;
 * bilinear, paper (0) outside. Right angles go through rot90 (exact).
 */
export function rotate(t, deg) {
  const r = ((deg % 360) + 360) % 360;
  if (r === 0) return t;
  if (r % 90 === 0) { let o = t; for (let k = 0; k < r / 90; k++) o = rot90(o); return o; }
  const a = r * Math.PI / 180, c = Math.cos(a), s = Math.sin(a);
  const w = Math.ceil(Math.abs(t.w * c) + Math.abs(t.h * s)), h = Math.ceil(Math.abs(t.w * s) + Math.abs(t.h * c));
  const out = new Float32Array(w * h);
  const cx0 = t.w / 2, cy0 = t.h / 2, cx1 = w / 2, cy1 = h / 2;
  for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
    // the destination pixel's middle, turned back into the source
    const dx = x + 0.5 - cx1, dy = y + 0.5 - cy1;
    const u = c * dx + s * dy + cx0 - 0.5, v = -s * dx + c * dy + cy0 - 0.5;
    const x0 = Math.floor(u), y0 = Math.floor(v), fx = u - x0, fy = v - y0;
    const at = (xx, yy) => (xx >= 0 && yy >= 0 && xx < t.w && yy < t.h ? t.data[yy * t.w + xx] : 0);
    out[y * w + x] = (at(x0, y0) * (1 - fx) + at(x0 + 1, y0) * fx) * (1 - fy) + (at(x0, y0 + 1) * (1 - fx) + at(x0 + 1, y0 + 1) * fx) * fy;
  }
  return { data: out, w, h };
}

Integral images

64-bit

Running sums of ink and of ink squared, so any window's sum costs four lookups.

sum(window) = S[e] − S[b] − S[c] + S[a]

Float64Array running totals of 32-bit ink values. A window's sum is a difference of four totals that grow with the sheet (up to tens of millions of pixels), so a small window's sum carries the rounding of those totals.

  • match.js
  • lines 110 to 123
  • TakeoffCraft 0.8.1
export function integrals(img) {
  const W = img.w + 1;
  const s = new Float64Array(W * (img.h + 1)), s2 = new Float64Array(W * (img.h + 1));
  for (let y = 0; y < img.h; y++) {
    let r = 0, r2 = 0;
    for (let x = 0; x < img.w; x++) {
      const v = img.data[y * img.w + x];
      r += v; r2 += v * v;
      s[(y + 1) * W + x + 1] = s[y * W + x + 1] + r;
      s2[(y + 1) * W + x + 1] = s2[y * W + x + 1] + r2;
    }
  }
  return { s, s2, W };
}

Match score at every position, and its peaks

32-bit in part

Scores how well the symbol matches at each position and keeps the local best ones.

score = Σ (t − t̄)·img ÷ (σₜ·√n × √(v·n)), v = Σimg² ÷ n − (Σimg ÷ n)²

The dot product is summed in 64-bit, but each score is stored in a Float32Array before the peak search and before any comparison with the floor. The window variance is sum2 ÷ n − (sum ÷ n)², a subtraction of nearly equal numbers for faint windows, guarded by the 0.02² skip. The reported score adds a parabola fit through the 32-bit neighbors and is capped at 1.

  • match.js
  • lines 125 to 182
  • TakeoffCraft 0.8.1
/**
 * NCC of one template over the image → local peaks ≥ floor: [{ x, y, score }] (top-left, px).
 * `tick(rowsDone)` is called now and then; returning true from `stop()` aborts (returns null).
 */
export function nccPeaks(img, t, { floor = 0.5, minStd = 0.02, I = integrals(img), tick = null, stop = null, gate = null } = {}) {
  const { mean: tm, std: ts } = stats(t);
  const n = t.w * t.h;
  // 0.8 EG.67: gate = [lo, hi] — skip a window whose ink is outside lo..hi times the symbol's (turning a symbol keeps its ink), so a big plan
  // costs a fraction of a full correlation; a symbol with a long line through it can fall outside (Auto count has no gate)
  const mass = gate ? tm * n : 0, gLo = gate ? gate[0] * mass : 0, gHi = gate ? gate[1] * mass : 0;
  const tz = new Float32Array(n);
  for (let i = 0; i < n; i++) tz[i] = t.data[i] - tm;
  const tnorm = ts * Math.sqrt(n);
  const W = img.w - t.w + 1, H = img.h - t.h + 1;
  if (W <= 0 || H <= 0) return [];
  const score = new Float32Array(W * H);
  const { s, s2, W: IW } = I;
  const d = img.data, iw = img.w;
  for (let y = 0; y < H; y++) {
    for (let x = 0; x < W; x++) {
      const a = y * IW + x, b = a + t.w, c = (y + t.h) * IW + x, e = c + t.w;
      const sum = s[e] - s[b] - s[c] + s[a], sum2 = s2[e] - s2[b] - s2[c] + s2[a];
      const v = sum2 / n - (sum / n) * (sum / n);
      if (v < minStd * minStd) continue;                  // blank (or solid) paper: no match
      if (gate && (sum < gLo || sum > gHi)) continue;
      let dot = 0;
      for (let ty = 0, k = 0; ty < t.h; ty++) {
        const row = (y + ty) * iw + x;
        for (let tx = 0; tx < t.w; tx++, k++) dot += tz[k] * d[row + tx];
      }
      score[y * W + x] = dot / (tnorm * Math.sqrt(v * n));
    }
    if (tick && (y & 15) === 15) { if (stop?.()) return null; tick(y + 1, H); }
  }
  const peaks = [];
  for (let y = 0; y < H; y++) {
    for (let x = 0; x < W; x++) {
      const v = score[y * W + x];
      if (v < floor) continue;
      let best = true;
      for (let dy = -1; dy <= 1 && best; dy++) for (let dx = -1; dx <= 1; dx++) {
        if (!dx && !dy) continue;
        const xx = x + dx, yy = y + dy;
        if (xx < 0 || yy < 0 || xx >= W || yy >= H) continue;
        const u = score[yy * W + xx];
        if (u > v || (u === v && (dy < 0 || (dy === 0 && dx < 0)))) { best = false; break; }
      }
      if (best) {
        // the true peak usually sits between pixels: fit a parabola through the neighbours on each axis
        const at = (xx, yy) => (xx < 0 || yy < 0 || xx >= W || yy >= H ? v : score[yy * W + xx]);
        const fit = (a, c) => { const den = a - 2 * v + c; if (den >= 0) return [0, 0]; const d = Math.max(-0.5, Math.min(0.5, (a - c) / (2 * den))); return [d, -(a - c) * d / 4]; };
        const [dx, gx] = fit(at(x - 1, y), at(x + 1, y)), [dy, gy] = fit(at(x, y - 1), at(x, y + 1));
        peaks.push({ x, y, dx, dy, score: Math.min(1, v + Math.max(0, gx) + Math.max(0, gy)), raw: v });
      }
    }
  }
  return peaks;
}

Search at each angle

32-bit in part

Runs the match at every angle asked for and collects the finds.

Each peak is recorded with its whole-pixel x and y and its score; the sub-pixel offsets dx and dy that nccPeaks computes are not carried on. With angles other than right angles, a first pass at a lower floor (floor − 0.2, not below 0.3) is followed by refineAngle on at most 400 candidates, and a refined find is kept when its score reaches the floor.

  • match.js
  • lines 184 to 213
  • TakeoffCraft 0.8.1
/**
 * Search one image for a template at each of `rotations` (degrees clockwise; 0/90/180/270 by default, ANY_ANGLES for any
 * angle). → [{ x, y, w, h, rot, score }] in image px. progress(fraction) is called as it goes; stop() → true aborts (null).
 */
export function findSymbol(img0, template0, { rotations = [0, 90, 180, 270], floor = 0.5, smooth = BLUR, progress = null, stop = null, I: shared = null, gate = null } = {}) {
  const img = smooth ? blur(img0, smooth) : img0, template = smooth ? blur(template0, smooth) : template0;
  const I = shared && !smooth ? shared : integrals(img);      // 0.8 EG.67: a legend run searches many symbols on one sheet — one integral image
  const out = [];
  const list = [...new Set(rotations.map((r) => ((Math.round(r) % 360) + 360) % 360))];
  // any angle: a coarse pass at a lower floor, then each candidate's angle refined on a small window around it
  const fine = list.some((r) => r % 90 !== 0);
  const coarseFloor = fine ? Math.max(0.3, floor - 0.2) : floor;
  for (let i = 0; i < list.length; i++) {
    const r = list[i], tt = rotate(template, r);
    const peaks = nccPeaks(img, tt, { floor: coarseFloor, I, stop, gate, tick: progress ? (done, total) => progress((i + done / total) / list.length * (fine ? 0.8 : 1)) : null });
    if (!peaks) return null;
    for (const p of peaks) out.push({ x: p.x, y: p.y, w: tt.w, h: tt.h, rot: r, score: p.score });
  }
  if (!fine) { progress?.(1); return out; }
  const best = suppress(out, coarseFloor, 0.25).slice(0, 400);
  const refined = [];
  for (let k = 0; k < best.length; k++) {
    if (stop?.()) return null;
    const r = refineAngle(img, template, best[k]);
    if (r.score >= floor) refined.push(r);
    progress?.(0.8 + 0.2 * (k + 1) / best.length);
  }
  progress?.(1);
  return refined;
}

Angle of a find refined

32-bit in part

Tries angles within 7.5° of a find in 1.5° steps and keeps the best.

Each trial rotates the symbol (bilinear, 32-bit) and scores a small window. A trial replaces the best only when its score is higher by more than 1e-9. The loop variable steps by 1.5, which is exact in binary.

  • match.js
  • lines 215 to 230
  • TakeoffCraft 0.8.1
/** Try angles within ±7.5° of a candidate's (1.5° steps) on a window around it; the best fit. */
export function refineAngle(img, template, c) {
  const cx = c.x + c.w / 2, cy = c.y + c.h / 2;
  let best = c;
  for (let d = -7.5; d <= 7.5 + 1e-9; d += 1.5) {
    const r = ((c.rot + d) % 360 + 360) % 360, tt = rotate(template, r);
    const pad = 3;
    const x0 = Math.max(0, Math.floor(cx - tt.w / 2) - pad), y0 = Math.max(0, Math.floor(cy - tt.h / 2) - pad);
    const win = crop(img, x0, y0, tt.w + 2 * pad, tt.h + 2 * pad);
    if (win.w < tt.w || win.h < tt.h) continue;
    for (const p of nccPeaks(win, tt, { floor: 0 }) || []) {
      if (p.score > best.score + 1e-9) best = { x: x0 + p.x, y: y0 + p.y, w: tt.w, h: tt.h, rot: r, score: p.score };
    }
  }
  return best;
}

Finds kept at the threshold

32-bit in part

Keeps the finds at or above the threshold, best first, and drops any that overlaps a kept one.

keep when score ≥ threshold and IoU with every kept find ≤ 0.25

score ≥ threshold is tested on scores that came out of the 32-bit score map. Overlap is intersection over union of the two boxes in 64-bit, dropped above 0.25. The length of the kept list is the count offered to the person.

  • match.js
  • lines 241 to 248
  • TakeoffCraft 0.8.1
/** Keep candidates ≥ threshold, best first, dropping any that overlaps a kept one by IoU > maxOverlap. */
export function suppress(cands, threshold, maxOverlap = 0.25) {
  const keep = [];
  for (const c of cands.filter((c) => c.score >= threshold).sort((a, b) => b.score - a.score)) {
    if (!keep.some((k) => k.sheet === c.sheet && iou(k, c) > maxOverlap)) keep.push(c);
  }
  return keep;
}

Raster scale for a search

64-bit

The pixels per sheet unit that make the symbol's longer side about 40 pixels.

w = clamp(40 ÷ max(boxW, boxH), 0.2, 4)

target ÷ longer side, clamped to 0.2 … 4. This sets the size of a pixel and with it how finely a find is placed.

  • match.js
  • lines 250 to 253
  • TakeoffCraft 0.8.1
/** Working scale (px per PDF unit) that makes the template's longer side about `target` px. */
export function workScale(boxW, boxH, target = 40) {
  return Math.min(4, Math.max(0.2, target / Math.max(boxW, boxH)));
}
legend.js TakeoffCraft 0.8.1 32-bit in part 4 calculations Reviewed

Tests tools/takeoffcraft-test/legend.mjs, tools/takeoffcraft-test/countall.mjs, tools/takeoffcraft-test/anyangle.mjs

How alike two symbols are

64-bit

The best normalized correlation of two symbols over shifts of up to one pixel.

likeness = max over shifts of Σ(p − p̄)(q − q̄) ÷ √(Σ(p − p̄)² · Σ(q − q̄)²)

Means, variances and the dot product are summed in 64-bit over 32-bit rasters, for the nine shifts of up to one pixel each way. The largest value is returned; a shift where either picture has no variance is skipped.

  • legend.js
  • lines 165 to 186
  • TakeoffCraft 0.8.1
/** How alike two symbols are (0..1): the best normalized correlation over shifts of up to a pixel, at the larger of their sizes. */
export function likeness(a, b) {
  const w = Math.max(a.w, b.w) + 2, h = Math.max(a.h, b.h) + 2;
  const put = (t, dx, dy) => { const o = new Float32Array(w * h); const ox = Math.floor((w - t.w) / 2) + dx, oy = Math.floor((h - t.h) / 2) + dy; for (let y = 0; y < t.h; y++) for (let x = 0; x < t.w; x++) { const X = x + ox, Y = y + oy; if (X >= 0 && Y >= 0 && X < w && Y < h) o[Y * w + X] = t.data[y * t.w + x]; } return o; };
  const q = put(b, 0, 0);
  let mq = 0;
  for (let i = 0; i < q.length; i++) mq += q[i];
  mq /= q.length;
  let vq = 0;
  for (let i = 0; i < q.length; i++) vq += (q[i] - mq) ** 2;
  let best = 0;
  for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
    const p = put(a, dx, dy);
    let mp = 0;
    for (let i = 0; i < p.length; i++) mp += p[i];
    mp /= p.length;
    let dot = 0, vp = 0;
    for (let i = 0; i < p.length; i++) { const x = p[i] - mp; dot += x * (q[i] - mq); vp += x * x; }
    if (vp > 0 && vq > 0) best = Math.max(best, dot / Math.sqrt(vp * vq));
  }
  return best;
}

One find per spot across symbols

32-bit in part

Merges overlapping finds of one symbol, and gives a spot two symbols match to the higher score.

Finds are ordered by scores that came from the 32-bit score map; two finds of different symbols clash when their boxes overlap by more than 0.5 (intersection over union).

  • legend.js
  • lines 230 to 249
  • TakeoffCraft 0.8.1
/**
 * Hits (one per find, in sheet units): { entry, sheet, x, y, w, h, score, … }. Within one symbol, finds that overlap are one find (match.js
 * suppress, at that symbol's threshold); between symbols, a spot two of them match goes to the higher score.
 * `th(entry)` = that symbol's threshold. → { kept, mixed: Map("a|b" → count) }
 */
export function keepBest(hits, th) {
  const bySymbol = new Map();
  for (const h of hits) { const k = h.entry + '|' + h.sheet; (bySymbol.get(k) || bySymbol.set(k, []).get(k)).push(h); }
  let first = [];
  for (const list of bySymbol.values()) first = first.concat(suppress(list.map((h) => ({ ...h, sheet: h.sheet })), th(list[0].entry), 0.25));
  first.sort((a, b) => b.score - a.score);
  const kept = [], mixed = new Map(), bySheet = new Map();
  for (const h of first) {
    const here = bySheet.get(h.sheet) || bySheet.set(h.sheet, []).get(h.sheet);
    const clash = here.find((k) => k.entry !== h.entry && iou(k, h) > 0.5);
    if (clash) { const key = [clash.entry, h.entry].sort().join('|'); mixed.set(key, (mixed.get(key) || 0) + 1); continue; }
    here.push(h); kept.push(h);
  }
  return { kept, mixed };
}

Counts per symbol and per sheet

Exact

Counts the kept finds for each symbol, in total and on each sheet.

Whole-number increments in plain objects, one per kept find that was not rejected; no floating-point value takes part.

  • legend.js
  • lines 251 to 262
  • TakeoffCraft 0.8.1
/** Counts: { total: {entry → n}, bySheet: {entry → {sheet → n}}, sheets: [sheet keys that have any], all } from the kept hits. */
export function tally(kept) {
  const total = {}, bySheet = {}, sheets = new Set();
  for (const h of kept) {
    if (h.rejected) continue;
    total[h.entry] = (total[h.entry] || 0) + 1;
    const s = bySheet[h.entry] || (bySheet[h.entry] = {});
    s[h.sheet] = (s[h.sheet] || 0) + 1;
    sheets.add(h.sheet);
  }
  return { total, bySheet, sheets: [...sheets], all: kept.filter((h) => !h.rejected).length };
}

Raster sizes for a sheet at another scale

64-bit

The pixel sizes to search a sheet at so a symbol drawn to scale keeps its size in pixels.

w′ = w × tgtFt ÷ srcFt when |tgtFt ÷ srcFt − 1| > 0.01

When the two sheets' scales differ by more than 1 % (relative), the sheet is searched at w × target ÷ source and at w; otherwise only at w.

  • legend.js
  • lines 264 to 267
  • TakeoffCraft 0.8.1
/** Render sizes for a sheet: the symbol's pixel size at the legend's scale, and (when the scales differ) at this sheet's. */
export function renderScales(w, srcFt, tgtFt) {
  return srcFt && tgtFt && Math.abs(tgtFt / srcFt - 1) > 0.01 ? [w * tgtFt / srcFt, w] : [w];
}
learn.js TakeoffCraft 0.8.1 32-bit in part 2 calculations Reviewed

Tests tools/takeoffcraft-test/learn.mjs

Learned symbol saved and read back

32-bit in part

Stores an accepted symbol in the file and reads it back for a later search.

byte = round(ink × 255); ink′ = byte ÷ 255

Saving rounds each ink value to one of 256 levels (round(v × 255), one byte). Reading divides by 255 into a Float32Array. A learned symbol is therefore not bit-for-bit the one that was accepted.

  • learn.js
  • lines 79 to 92
  • TakeoffCraft 0.8.1
export function packTemplate(tpl) {
  const n = tpl.w * tpl.h, b = new Uint8Array(n);
  for (let i = 0; i < n; i++) b[i] = Math.max(0, Math.min(255, Math.round((tpl.data[i] || 0) * 255)));
  return { w: tpl.w, h: tpl.h, ink: toBase64(b) };
}
/** …and back, for match.js (Float32Array 0…1). Null when damaged. */
export function unpackTemplate(p) {
  if (!p || !intIn(p.w, LEARN_LIMITS.tplMin, LEARN_LIMITS.tplMax) || !intIn(p.h, LEARN_LIMITS.tplMin, LEARN_LIMITS.tplMax)) return null;
  const b = fromBase64(p.ink);
  if (!b || b.length !== p.w * p.h) return null;
  const data = new Float32Array(b.length);
  for (let i = 0; i < b.length; i++) data[i] = b[i] / 255;
  return { data, w: p.w, h: p.h };
}

Same symbol as one already learned

64-bit

The normalized correlation of two symbols over their common area.

ncc = Σ(a − ā)(b − b̄) ÷ √(Σ(a − ā)² · Σ(b − b̄)²)

Sums in 64-bit over 32-bit rasters. Returns 0 when either symbol's variance sum is at or below 1e-9 (absolute). The two symbols are compared from their top-left corners over the size they share, with no shift.

  • learn.js
  • lines 94 to 106
  • TakeoffCraft 0.8.1
/** Normalized cross-correlation of two templates over their common top-left overlap (−1 … 1; 0 when one is blank). */
export function templateNcc(a, b) {
  const w = Math.min(a.w, b.w), h = Math.min(a.h, b.h), n = w * h;
  let sa = 0, sb = 0;
  for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) { sa += a.data[y * a.w + x]; sb += b.data[y * b.w + x]; }
  const ma = sa / n, mb = sb / n;
  let num = 0, da = 0, db = 0;
  for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
    const p = a.data[y * a.w + x] - ma, q = b.data[y * b.w + x] - mb;
    num += p * q; da += p * p; db += q * q;
  }
  return da > 1e-9 && db > 1e-9 ? num / Math.sqrt(da * db) : 0;
}
takeoffcraft.js TakeoffCraft 0.8.1 64-bit 1 calculation Reviewed

Tests bin/test-takeoffcraft.mjs

Find placed on the sheet

64-bit

Maps a find's pixel box back to sheet coordinates and takes its middle as the count mark's point.

p = T⁻¹·((x + w ÷ 2) ÷ wt, (y + h ÷ 2) ÷ wt)

Pixel coordinates are ÷ the working scale and passed through the inverse page transform in 64-bit. The mark's point is the middle of a whole-pixel box, so it is placed to about half a pixel of the working raster.

  • takeoffcraft.js
  • lines 3389 to 3397
  • TakeoffCraft 0.8.1
const inv = G.invert(sh.info.transform);
const toUser = (x, y) => G.apply(inv, { x: x / wt, y: y / wt });
const own = model.state.marks.filter((m) => m.item === it.id && m.sheet === sh.key);
for (const c of res) {
  const corners = [toUser(c.x, c.y), toUser(c.x + c.w, c.y), toUser(c.x + c.w, c.y + c.h), toUser(c.x, c.y + c.h)];
  const dup = own.some((m) => G.pointInPolygon(m.pts[0], corners));
  // boxes in sheet units, so suppress() compares candidates found at two pixel sizes
  const bx = Math.min(...corners.map((q) => q.x)), by = Math.min(...corners.map((q) => q.y));
  ac.cands.push({ ...c, x: bx, y: by, w: Math.max(...corners.map((q) => q.x)) - bx, h: Math.max(...corners.map((q) => q.y)) - by, sheet: sh.key, center: toUser(c.x + c.w / 2, c.y + c.h / 2), corners, dup, rejected: dup });

In detail

Method
Normalized cross-correlation of the symbol over the sheet, with window sums from integral images; peaks are 3 × 3 local maxima; overlapping finds are merged by intersection over union. Angles: 0°, 90°, 180°, 270° exactly, or every 15° with a refinement in 1.5° steps.
Number types
Float32Array for the ink rasters, the symbol and the score map; Float64Array for the integral images; 64-bit sums inside each correlation. A learned symbol is saved as 8-bit ink.
Tolerances
A window with ink variance below 0.02² is skipped. Peaks at or above the floor (0.5 by default) are kept; the person's threshold is applied afterward. Overlap above 0.25 (intersection over union) merges two finds. Sheets whose scales differ by more than 1 % are searched at both sizes.
Limits
A find's position is a whole pixel at the working scale (in Auto count the symbol's longer side is about 40 pixels). A symbol at an angle is at most 0.75° from a tried angle after refinement. A score within float32 rounding of the threshold can fall on either side.

Approach: 32-bit step, flagged

One step stores or exchanges 32-bit values that reach a number a person reads. It is listed here with its open item, and the step is either moved to 64-bit or the app will warn where the number is read.

Every policy that takes this approach