@scanmate/ocr 0.0.3 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -3
- package/dist/index.esm.js +743 -25
- package/dist/src/index.d.ts +4 -2
- package/dist/src/page-reading/index.d.ts +2 -2
- package/dist/src/page-reading/match-words.use-case.d.ts +6 -1
- package/dist/src/page-reading/ocr-report.contract.d.ts +23 -0
- package/dist/src/page-reading/recheck-run.use-case.d.ts +24 -8
- package/dist/src/print-verification/glyph-cells.use-case.d.ts +86 -0
- package/dist/src/print-verification/glyph-templates.use-case.d.ts +40 -0
- package/dist/src/print-verification/index.d.ts +10 -0
- package/dist/src/print-verification/merge-reading.mapper.d.ts +19 -0
- package/dist/src/print-verification/print-polarity.policy.d.ts +11 -0
- package/dist/src/print-verification/verify-print.use-case.d.ts +122 -0
- package/package.json +4 -4
package/dist/index.esm.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { distance } from 'fastest-levenshtein';
|
|
2
|
-
import { isRaster, encodeImage, resampleRaster, createRaster } from '@scanmate/ink';
|
|
2
|
+
import { resizeGray, isRaster, encodeImage, resampleRaster, createRaster, toGrayscale } from '@scanmate/ink';
|
|
3
3
|
import { mkdir, copyFile, stat } from 'node:fs/promises';
|
|
4
4
|
import { createRequire } from 'node:module';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
@@ -326,7 +326,12 @@ function judgeRun(expected, found, options) {
|
|
|
326
326
|
agrees: true
|
|
327
327
|
};
|
|
328
328
|
}
|
|
329
|
-
|
|
329
|
+
/**
|
|
330
|
+
* @param verified - Runs the print check matched glyph by glyph and found
|
|
331
|
+
* changed. Their differences are evidence in themselves; every other one is
|
|
332
|
+
* a reading, which the pixels still have to agree with.
|
|
333
|
+
*/
|
|
334
|
+
function judgeRuns(references, claims, options, verified = new Set()) {
|
|
330
335
|
const differences = [];
|
|
331
336
|
const expectedParts = [];
|
|
332
337
|
const alignedParts = [];
|
|
@@ -343,6 +348,7 @@ function judgeRuns(references, claims, options) {
|
|
|
343
348
|
found: verdict.kind === 'missing' ? null : found,
|
|
344
349
|
similarity: verdict.similarity,
|
|
345
350
|
reason: verdict.reason,
|
|
351
|
+
verified: verified.has(r),
|
|
346
352
|
...box(ref)
|
|
347
353
|
});
|
|
348
354
|
}
|
|
@@ -356,6 +362,7 @@ function judgeRuns(references, claims, options) {
|
|
|
356
362
|
found,
|
|
357
363
|
similarity: 0,
|
|
358
364
|
reason: 'text',
|
|
365
|
+
verified: false,
|
|
359
366
|
...union(group)
|
|
360
367
|
});
|
|
361
368
|
}
|
|
@@ -433,6 +440,670 @@ function union(texts) {
|
|
|
433
440
|
};
|
|
434
441
|
}
|
|
435
442
|
|
|
443
|
+
/**
|
|
444
|
+
* The least a pixel may stand out from the paper around it and still count as
|
|
445
|
+
* print, `0` to `1`. The run's own contrast decides above this.
|
|
446
|
+
*
|
|
447
|
+
* Measured against the run's own background rather than against white, because
|
|
448
|
+
* the paper under a run is whatever the page puts there: white, the grey of a
|
|
449
|
+
* shaded row, or the colour of a total bar.
|
|
450
|
+
*/
|
|
451
|
+
const CONTRAST = 0.19;
|
|
452
|
+
/** Columns are grown by this, in pixels, before grouping, so a dotted stem stays one glyph. */
|
|
453
|
+
const JOIN = 0;
|
|
454
|
+
/**
|
|
455
|
+
* The box of each printed character of a run, left to right.
|
|
456
|
+
*
|
|
457
|
+
* @param page - The original page, greyscale, `0` black to `1` white.
|
|
458
|
+
* @param dpi - What that page was rendered at.
|
|
459
|
+
* @param run - The run's box, in points.
|
|
460
|
+
* @param count - How many characters the run prints, spaces excluded.
|
|
461
|
+
* @param options - Contrast against the run's paper, column joining, and polarity.
|
|
462
|
+
* @returns One box per character, or `null` when they cannot be told apart.
|
|
463
|
+
*/
|
|
464
|
+
function glyphCells(page, dpi, run, count, options = {}) {
|
|
465
|
+
const profile = profileOf(page, dpi, run, count, options);
|
|
466
|
+
if (profile === null) return null;
|
|
467
|
+
const groups = fitGroups(profile.inked, options.join ?? JOIN, count);
|
|
468
|
+
return groups === null ? null : groups.map(group => boxOf(run, profile, group));
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* The box of each word of a run, in order, split at the run's own spaces.
|
|
472
|
+
*
|
|
473
|
+
* @param page - The original page, greyscale.
|
|
474
|
+
* @param dpi - What that page was rendered at.
|
|
475
|
+
* @param run - The run's box, in points, with its angle.
|
|
476
|
+
* @param counts - Characters in each word, in order, spaces excluded.
|
|
477
|
+
* @param options - Contrast against the run's paper, column joining, and polarity.
|
|
478
|
+
* @returns One box per word, or `null` when the words cannot be told apart.
|
|
479
|
+
*/
|
|
480
|
+
function glyphWords(page, dpi, run, counts, options = {}) {
|
|
481
|
+
const total = counts.reduce((sum, count) => sum + count, 0);
|
|
482
|
+
const profile = profileOf(page, dpi, run, total, options);
|
|
483
|
+
if (profile === null) return null;
|
|
484
|
+
if (counts.length === 1) return [{
|
|
485
|
+
...run
|
|
486
|
+
}];
|
|
487
|
+
// The widest gaps are the spaces: as many cuts as the run has spaces.
|
|
488
|
+
const groups = groupsOf(profile.inked, options.join ?? JOIN);
|
|
489
|
+
if (groups.length < counts.length) return null;
|
|
490
|
+
const gaps = groups.slice(1).map((group, index) => ({
|
|
491
|
+
at: index + 1,
|
|
492
|
+
gap: group.start - groups[index].end
|
|
493
|
+
})).toSorted((a, b) => b.gap - a.gap).slice(0, counts.length - 1).map(gap => gap.at).toSorted((a, b) => a - b);
|
|
494
|
+
const words = [];
|
|
495
|
+
let from = 0;
|
|
496
|
+
for (const cut of [...gaps, groups.length]) {
|
|
497
|
+
const first = groups[from];
|
|
498
|
+
const last = groups[cut - 1];
|
|
499
|
+
if (first === undefined || last === undefined) return null;
|
|
500
|
+
// A word holding fewer groups than it has characters has glyphs that run
|
|
501
|
+
// together, and nothing inside it can be placed - but that is that word's
|
|
502
|
+
// problem. Its box is still returned, and it is the caller who finds the
|
|
503
|
+
// letters unplaceable, one word at a time.
|
|
504
|
+
words.push(boxOf(run, profile, {
|
|
505
|
+
start: first.start,
|
|
506
|
+
end: last.end
|
|
507
|
+
}));
|
|
508
|
+
from = cut;
|
|
509
|
+
}
|
|
510
|
+
return words;
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* How much ink stands in each step along the run, in the run's own direction.
|
|
514
|
+
*
|
|
515
|
+
* @returns The profile and the frame to read boxes back out of, or `null` when
|
|
516
|
+
* the run is turned by something other than a quarter turn, or is too small.
|
|
517
|
+
*/
|
|
518
|
+
function profileOf(page, dpi, run, count, options) {
|
|
519
|
+
const {
|
|
520
|
+
contrast = CONTRAST,
|
|
521
|
+
lightOnDark = false
|
|
522
|
+
} = options;
|
|
523
|
+
if (count <= 0) return null;
|
|
524
|
+
const turn = Math.round(((run.angle ?? 0) % 360 + 360) % 360 / 90) % 4;
|
|
525
|
+
if (Math.abs(((run.angle ?? 0) % 360 + 360) % 360 - turn * 90) > 1) return null;
|
|
526
|
+
const along = turn % 2 === 0 ? 'x' : 'y';
|
|
527
|
+
// A quarter turn one way advances up the page, the other way down.
|
|
528
|
+
const reverse = turn === 2 || turn === 3;
|
|
529
|
+
const s = dpi / 72;
|
|
530
|
+
const left = Math.max(0, Math.floor(run.x * s));
|
|
531
|
+
const right = Math.min(page.width, Math.ceil((run.x + run.width) * s));
|
|
532
|
+
const top = Math.max(0, Math.floor(run.y * s));
|
|
533
|
+
const bottom = Math.min(page.height, Math.ceil((run.y + run.height) * s));
|
|
534
|
+
const steps = along === 'x' ? right - left : bottom - top;
|
|
535
|
+
const across = along === 'x' ? bottom - top : right - left;
|
|
536
|
+
if (steps < count || across < 2) return null;
|
|
537
|
+
// The paper this run is printed on: most of its box is background. How far the
|
|
538
|
+
// glyphs stand from it is the run's own contrast, and half of that separates
|
|
539
|
+
// ink from paper - on white or on the colour of a total bar alike.
|
|
540
|
+
const values = [];
|
|
541
|
+
for (let y = top; y < bottom; y++) for (let x = left; x < right; x++) values.push(page.data[y * page.width + x]);
|
|
542
|
+
const sorted = values.toSorted((a, b) => a - b);
|
|
543
|
+
const paper = sorted[Math.floor(sorted.length / 2)];
|
|
544
|
+
const darkest = sorted[Math.floor(sorted.length * (lightOnDark ? 0.98 : 0.02))];
|
|
545
|
+
const threshold = Math.max(contrast, Math.abs(darkest - paper) / 2);
|
|
546
|
+
const inked = [];
|
|
547
|
+
for (let step = 0; step < steps; step++) {
|
|
548
|
+
let dark = 0;
|
|
549
|
+
for (let other = 0; other < across; other++) {
|
|
550
|
+
const x = left + (along === 'x' ? step : other);
|
|
551
|
+
const y = top + (along === 'x' ? other : step);
|
|
552
|
+
const difference = lightOnDark ? page.data[y * page.width + x] - paper : paper - page.data[y * page.width + x];
|
|
553
|
+
if (difference > threshold) dark++;
|
|
554
|
+
}
|
|
555
|
+
inked.push(dark > 0);
|
|
556
|
+
}
|
|
557
|
+
return {
|
|
558
|
+
inked: reverse ? inked.toReversed() : inked,
|
|
559
|
+
along,
|
|
560
|
+
reverse,
|
|
561
|
+
left,
|
|
562
|
+
top,
|
|
563
|
+
right,
|
|
564
|
+
bottom,
|
|
565
|
+
s
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
/** A span of the profile, back in page points. */
|
|
569
|
+
function boxOf(run, profile, group) {
|
|
570
|
+
const steps = profile.along === 'x' ? profile.right - profile.left : profile.bottom - profile.top;
|
|
571
|
+
const start = profile.reverse ? steps - 1 - group.end : group.start;
|
|
572
|
+
const end = profile.reverse ? steps - 1 - group.start : group.end;
|
|
573
|
+
return profile.along === 'x' ? {
|
|
574
|
+
x: (profile.left + start) / profile.s,
|
|
575
|
+
y: run.y,
|
|
576
|
+
width: (end - start + 1) / profile.s,
|
|
577
|
+
height: run.height,
|
|
578
|
+
angle: run.angle
|
|
579
|
+
} : {
|
|
580
|
+
x: run.x,
|
|
581
|
+
y: (profile.top + start) / profile.s,
|
|
582
|
+
width: run.width,
|
|
583
|
+
height: (end - start + 1) / profile.s,
|
|
584
|
+
angle: run.angle
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
/** The groups of inked steps, closed up until there are exactly `count` of them. */
|
|
588
|
+
function fitGroups(inked, join, count) {
|
|
589
|
+
let groups = groupsOf(inked, join);
|
|
590
|
+
// Characters printed in two parts - an i, a colon, a percent sign - read as
|
|
591
|
+
// more groups than there are characters; the narrowest gaps close first.
|
|
592
|
+
while (groups.length > count) {
|
|
593
|
+
const gaps = groups.slice(1).map((group, i) => ({
|
|
594
|
+
at: i + 1,
|
|
595
|
+
gap: group.start - groups[i].end
|
|
596
|
+
}));
|
|
597
|
+
const narrowest = gaps.toSorted((a, b) => a.gap - b.gap)[0];
|
|
598
|
+
if (narrowest === undefined) break;
|
|
599
|
+
groups = [...groups.slice(0, narrowest.at - 1), {
|
|
600
|
+
start: groups[narrowest.at - 1].start,
|
|
601
|
+
end: groups[narrowest.at].end
|
|
602
|
+
}, ...groups.slice(narrowest.at + 1)];
|
|
603
|
+
}
|
|
604
|
+
return groups.length === count ? groups : null;
|
|
605
|
+
}
|
|
606
|
+
/** Runs of inked columns, separated by more than `join` empty ones. */
|
|
607
|
+
function groupsOf(inked, join) {
|
|
608
|
+
const groups = [];
|
|
609
|
+
let start = -1;
|
|
610
|
+
let last = -1;
|
|
611
|
+
for (const [x, dark] of inked.entries()) {
|
|
612
|
+
if (!dark) continue;
|
|
613
|
+
if (start === -1) start = x;else if (x - last - 1 > join) {
|
|
614
|
+
groups.push({
|
|
615
|
+
start,
|
|
616
|
+
end: last
|
|
617
|
+
});
|
|
618
|
+
start = x;
|
|
619
|
+
}
|
|
620
|
+
last = x;
|
|
621
|
+
}
|
|
622
|
+
if (start !== -1) groups.push({
|
|
623
|
+
start,
|
|
624
|
+
end: last
|
|
625
|
+
});
|
|
626
|
+
return groups;
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Where every character of a run sits, or `null` where it could not be placed.
|
|
630
|
+
*
|
|
631
|
+
* The whole run is tried first, which is what a figure wants: short, its glyphs
|
|
632
|
+
* separate, and nothing gained by taking it apart. When that fails the run is
|
|
633
|
+
* split at its spaces and each word placed on its own, so one pair of touching
|
|
634
|
+
* letters costs its own word rather than the sentence around it - on the W-9's
|
|
635
|
+
* certification line, two characters rather than fifty-two.
|
|
636
|
+
*
|
|
637
|
+
* @param page - The original page, greyscale.
|
|
638
|
+
* @param dpi - What that page was rendered at.
|
|
639
|
+
* @param run - The run's box, in points, with its angle.
|
|
640
|
+
* @param text - What the run prints; spaces divide the words.
|
|
641
|
+
* @param options - Contrast against the run's paper, column joining, and polarity.
|
|
642
|
+
* @returns One entry per printed character, spaces excluded, or `null` when not
|
|
643
|
+
* even the words could be told apart.
|
|
644
|
+
*/
|
|
645
|
+
function placeGlyphs(page, dpi, run, text, options = {}) {
|
|
646
|
+
const characters = [...text].filter(character => character.trim() !== '');
|
|
647
|
+
const whole = glyphCells(page, dpi, run, characters.length, options);
|
|
648
|
+
if (whole !== null) return whole;
|
|
649
|
+
const words = text.split(/\s+/).filter(word => word !== '');
|
|
650
|
+
if (words.length < 2) return null;
|
|
651
|
+
const boxes = glyphWords(page, dpi, run, words.map(word => [...word].length), options);
|
|
652
|
+
if (boxes === null) return null;
|
|
653
|
+
const cells = [];
|
|
654
|
+
for (const [index, word] of words.entries()) {
|
|
655
|
+
const letters = [...word].length;
|
|
656
|
+
const placed = glyphCells(page, dpi, {
|
|
657
|
+
...boxes[index],
|
|
658
|
+
angle: run.angle
|
|
659
|
+
}, letters, options);
|
|
660
|
+
for (let letter = 0; letter < letters; letter++) cells.push(placed === null ? null : placed[letter]);
|
|
661
|
+
}
|
|
662
|
+
return cells.length === characters.length ? cells : null;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Which way the original prints a run, read off its own crisp rendering: the
|
|
667
|
+
* glyphs are the pixels far from the background, and the background is most of
|
|
668
|
+
* the box.
|
|
669
|
+
*/
|
|
670
|
+
function printPolarity(page, dpi, run) {
|
|
671
|
+
const s = dpi / 72;
|
|
672
|
+
const left = Math.max(0, Math.floor(run.x * s));
|
|
673
|
+
const top = Math.max(0, Math.floor(run.y * s));
|
|
674
|
+
const right = Math.min(page.width, Math.ceil((run.x + run.width) * s));
|
|
675
|
+
const bottom = Math.min(page.height, Math.ceil((run.y + run.height) * s));
|
|
676
|
+
const values = [];
|
|
677
|
+
for (let y = top; y < bottom; y++) for (let x = left; x < right; x++) values.push(page.data[y * page.width + x]);
|
|
678
|
+
if (values.length === 0) return 'dark-on-light';
|
|
679
|
+
const background = values.toSorted((a, b) => a - b)[Math.floor(values.length / 2)];
|
|
680
|
+
const glyphs = values.filter(v => Math.abs(v - background) > 0.19);
|
|
681
|
+
if (glyphs.length === 0) return 'dark-on-light';
|
|
682
|
+
return glyphs.reduce((a, b) => a + b, 0) / glyphs.length > background ? 'light-on-dark' : 'dark-on-light';
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** Most glyphs kept per character - more is slower and adds nothing. */
|
|
686
|
+
const PER_CHARACTER = 4;
|
|
687
|
+
/** The key a run's glyphs are filed under. */
|
|
688
|
+
function templateKey(run, character) {
|
|
689
|
+
// The turn belongs in the key: a glyph printed up the margin and the same
|
|
690
|
+
// glyph printed across the page are different pictures, and matching one
|
|
691
|
+
// against the other would compare a letter with its own rotation.
|
|
692
|
+
const turn = (Math.round((run.angle ?? 0) / 90) % 4 + 4) % 4;
|
|
693
|
+
return `${run.fontName ?? ''}|${Math.round((run.fontSize ?? run.height) * 2) / 2}|${turn}|${character}`;
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Collects a glyph image for every character the page prints and can place.
|
|
697
|
+
*
|
|
698
|
+
* @param page - The original page, greyscale.
|
|
699
|
+
* @param dpi - What it was rendered at.
|
|
700
|
+
* @param runs - Its text layer.
|
|
701
|
+
* @returns Glyph images, by {@link templateKey}.
|
|
702
|
+
*/
|
|
703
|
+
function collectTemplates(page, dpi, runs) {
|
|
704
|
+
const templates = new Map();
|
|
705
|
+
for (const run of runs) {
|
|
706
|
+
const characters = [...run.text].filter(character => character.trim() !== '');
|
|
707
|
+
const lightOnDark = printPolarity(page, dpi, run) === 'light-on-dark';
|
|
708
|
+
const cells = placeGlyphs(page, dpi, run, run.text, {
|
|
709
|
+
lightOnDark
|
|
710
|
+
});
|
|
711
|
+
if (cells === null) continue;
|
|
712
|
+
for (const [index, character] of characters.entries()) {
|
|
713
|
+
const cell = cells[index];
|
|
714
|
+
if (cell !== null) keep(templates, templateKey(run, character), cut(page, dpi, cell, lightOnDark));
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
return templates;
|
|
718
|
+
}
|
|
719
|
+
/** Files one glyph under its key, up to the few that are worth keeping. */
|
|
720
|
+
function keep(templates, key, glyph) {
|
|
721
|
+
if (glyph === null) return;
|
|
722
|
+
const kept = templates.get(key) ?? [];
|
|
723
|
+
if (kept.length >= PER_CHARACTER) return;
|
|
724
|
+
kept.push(glyph);
|
|
725
|
+
templates.set(key, kept);
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* The greyscale of one box of the page, or `null` when it lies outside it;
|
|
729
|
+
* inverted when the run it belongs to is printed light on dark.
|
|
730
|
+
*/
|
|
731
|
+
function cut(page, dpi, box, invert = false) {
|
|
732
|
+
const s = dpi / 72;
|
|
733
|
+
const left = Math.max(0, Math.floor(box.x * s));
|
|
734
|
+
const top = Math.max(0, Math.floor(box.y * s));
|
|
735
|
+
const right = Math.min(page.width, Math.ceil((box.x + box.width) * s));
|
|
736
|
+
const bottom = Math.min(page.height, Math.ceil((box.y + box.height) * s));
|
|
737
|
+
const width = right - left;
|
|
738
|
+
const height = bottom - top;
|
|
739
|
+
if (width < 1 || height < 1) return null;
|
|
740
|
+
const data = new Float32Array(width * height);
|
|
741
|
+
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
|
|
742
|
+
const value = page.data[(top + y) * page.width + left + x];
|
|
743
|
+
data[y * width + x] = invert ? 1 - value : value;
|
|
744
|
+
}
|
|
745
|
+
return {
|
|
746
|
+
width,
|
|
747
|
+
height,
|
|
748
|
+
data
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Puts what the ink settled into what the scan was read as.
|
|
754
|
+
*
|
|
755
|
+
* The check speaks for the figures it decided, and for nothing else. It looks at
|
|
756
|
+
* digits, cell by cell, and leaves a cell it cannot call. So its answer is merged
|
|
757
|
+
* into the reading one character at a time rather than replacing it: replacing it
|
|
758
|
+
* would erase a changed letter, or a digit too soft to judge, along with the
|
|
759
|
+
* misreadings this is here to clear - and erasing a real change is the one thing
|
|
760
|
+
* the check must never do.
|
|
761
|
+
*
|
|
762
|
+
* @param read - What the scan was read as; empty when nothing was read there.
|
|
763
|
+
* @param run - The run as the original prints it.
|
|
764
|
+
* @param verification - What `verifyPrintedRun` decided about it.
|
|
765
|
+
* @returns The reading with the decided figures settled.
|
|
766
|
+
*/
|
|
767
|
+
function mergeVerifiedFigures(read, run, verification) {
|
|
768
|
+
const decided = verification.cells.filter(cell => cell.read !== null);
|
|
769
|
+
if (decided.length === 0) return read;
|
|
770
|
+
// Nothing was read here at all, yet the ink shows the glyphs: the ink is the better witness.
|
|
771
|
+
if (read.trim() === '') return verification.reading;
|
|
772
|
+
// `at` counts the run's printed characters, spaces aside, so the reading has to
|
|
773
|
+
// line up the same way before a position in one means anything in the other.
|
|
774
|
+
const places = [...read].map((character, index) => ({
|
|
775
|
+
character,
|
|
776
|
+
index
|
|
777
|
+
})).filter(entry => entry.character.trim() !== '');
|
|
778
|
+
const changed = decided.some(cell => cell.read !== cell.printed);
|
|
779
|
+
if (places.length !== [...run.text].filter(character => character.trim() !== '').length) return changed ? verification.reading : read;
|
|
780
|
+
const merged = [...read];
|
|
781
|
+
for (const cell of decided) if (cell.read !== null) merged[places[cell.at].index] = cell.read;
|
|
782
|
+
return merged.join('');
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Whether a printed figure is still the figure that was printed, decided by
|
|
787
|
+
* matching its ink rather than by reading it.
|
|
788
|
+
*
|
|
789
|
+
* OCR asks an open question - what does this say? - and at the resolution of a
|
|
790
|
+
* returned scan it answers badly: a 0 on a shaded bar at 120 dpi comes back as
|
|
791
|
+
* a 9, and a digit written over another comes back as whatever the language
|
|
792
|
+
* model prefers. The closed question is far easier: is this still the glyph the
|
|
793
|
+
* original printed here, or does it look more like a different one?
|
|
794
|
+
*
|
|
795
|
+
* The print is crisp and the scan is not, so the print is first softened to the
|
|
796
|
+
* scan's own sharpness - the amount that makes the scan's glyphs sit best on
|
|
797
|
+
* the original's, measured on the run itself - and every glyph is compared at
|
|
798
|
+
* that sharpness. Without it a blurred 0 matches a crisp 8 as well as it
|
|
799
|
+
* matches a crisp 0.
|
|
800
|
+
*
|
|
801
|
+
* Each character of a figure is matched twice over. Once against the original's
|
|
802
|
+
* own ink at that very place - same face, same size, same position, so a scan
|
|
803
|
+
* of it correlates highly however grey or grainy it is - and once against every
|
|
804
|
+
* other character the page prints in that face and size. The printed glyph has
|
|
805
|
+
* to win by a margin to pass, and a rival has to win by a margin to count as a
|
|
806
|
+
* change; anything in between is left undecided rather than guessed at.
|
|
807
|
+
*
|
|
808
|
+
* A run whose glyphs cannot be told apart is left to the reading, and so is a
|
|
809
|
+
* run on a page that prints too few characters in that face for the rivals to
|
|
810
|
+
* be represented - a glyph whose own template is missing could otherwise be
|
|
811
|
+
* confirmed as the one it replaced, simply for lacking anything better to
|
|
812
|
+
* match.
|
|
813
|
+
*
|
|
814
|
+
* **Two scopes.** `'figures'` checks the digits of a figure against the ten
|
|
815
|
+
* digits, and nothing else: a full stop read as a comma is how a figure is
|
|
816
|
+
* written, not what it says. It is cheap enough to run over every printed run
|
|
817
|
+
* of a page, which is how a changed amount is caught even when the reading
|
|
818
|
+
* never noticed.
|
|
819
|
+
*
|
|
820
|
+
* `'text'` checks every character against letters and digits alike. The rival
|
|
821
|
+
* set is six times larger and the work grows with it, so this is not for a
|
|
822
|
+
* whole page - it is for one run somebody is already arguing about, where the
|
|
823
|
+
* reading disagrees and the ink at that run says nothing moved. There it
|
|
824
|
+
* answers the question the reading could not: are these the same glyphs?
|
|
825
|
+
*
|
|
826
|
+
* Its two answers are not worth the same. Swept over 327 runs of four real
|
|
827
|
+
* documents, `'text'` called four unchanged runs changed - a `t` read as a `k`,
|
|
828
|
+
* a `g` as a `t` - where `'figures'` has never made a false call on any scan
|
|
829
|
+
* measured here. Letters at 8 pt through a scanner are simply more confusable
|
|
830
|
+
* than digits, and the margin that separates the ten does not separate the
|
|
831
|
+
* sixty-two. So `agrees === true` is good evidence that a run is untouched, and
|
|
832
|
+
* `agrees === false` is a reason to look closer rather than a verdict; callers
|
|
833
|
+
* are expected to use it to clear a dispute, not to open one.
|
|
834
|
+
*/
|
|
835
|
+
/** What a figure's characters are checked against: the ten digits, and only those. */
|
|
836
|
+
const FIGURE_CHARACTERS$1 = '0123456789';
|
|
837
|
+
/**
|
|
838
|
+
* What a run of text is checked against, under `scope: 'text'`: letters and
|
|
839
|
+
* digits. Punctuation is left out deliberately - a comma and a full stop differ
|
|
840
|
+
* by a few pixels at these sizes, and telling them apart is neither reliable
|
|
841
|
+
* nor worth reporting.
|
|
842
|
+
*/
|
|
843
|
+
const TEXT_CHARACTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
844
|
+
/**
|
|
845
|
+
* How much better the winner must correlate than the runner-up for the cell to
|
|
846
|
+
* be decided either way.
|
|
847
|
+
*
|
|
848
|
+
* Measured on real returned scans, matching every printed digit of pages 1 and
|
|
849
|
+
* 2 against the ten the page prints: on a genuine scan the printed glyph wins
|
|
850
|
+
* by 0.13 or more at 125 dpi, and where a badly scanned digit loses at 90 dpi
|
|
851
|
+
* it loses by 0.03 or less. A digit replaced by another - the same ink, a few
|
|
852
|
+
* points along - was won by its rival by 0.17.
|
|
853
|
+
*/
|
|
854
|
+
const MIN_MARGIN = 0.12;
|
|
855
|
+
/** A rival has to match this well before it can overturn what the original prints. */
|
|
856
|
+
const MIN_SCORE = 0.5;
|
|
857
|
+
/**
|
|
858
|
+
* ...and the printed glyph has to match at least this well before the ink can be
|
|
859
|
+
* called unchanged. A digit that genuinely changed may still beat the rivals the
|
|
860
|
+
* page happens to print, and confirming it on that basis would be the one error
|
|
861
|
+
* this check must never make.
|
|
862
|
+
*/
|
|
863
|
+
const MIN_PRINTED_SCORE = 0.7;
|
|
864
|
+
/**
|
|
865
|
+
* Distinct characters the page must print in this face and size before a run is
|
|
866
|
+
* checked at all. Fewer than this and the character actually on the scan may
|
|
867
|
+
* have no template to win with, which turns a change into a confirmation.
|
|
868
|
+
*
|
|
869
|
+
* Eight of the ten digits for a figure. For text the alphabet is far larger and
|
|
870
|
+
* no page prints all of it, so the bar is what a page of ordinary prose prints
|
|
871
|
+
* comfortably and a lone heading in a display face does not.
|
|
872
|
+
*/
|
|
873
|
+
const MIN_RIVALS = 8;
|
|
874
|
+
const MIN_TEXT_RIVALS = 24;
|
|
875
|
+
/** ...and the printed glyph has to match no better than this: a near-perfect match is not a forgery. */
|
|
876
|
+
const MAX_PRINTED_SCORE = 0.85;
|
|
877
|
+
/**
|
|
878
|
+
* Each cell is matched at this height, in pixels: the shape of a digit survives,
|
|
879
|
+
* the scanner's grain does not.
|
|
880
|
+
*/
|
|
881
|
+
const MATCH_HEIGHT = 16;
|
|
882
|
+
/** The template is slid this far, in match pixels, to absorb a cell landing half a pixel out. */
|
|
883
|
+
const SHIFT = 1;
|
|
884
|
+
/** How much the print may be softened by to meet the scan, in match pixels. */
|
|
885
|
+
const SOFTENING = [0, 0.5, 1, 1.5, 2];
|
|
886
|
+
/**
|
|
887
|
+
* Checks the figures of one run against the original's print.
|
|
888
|
+
*
|
|
889
|
+
* @param original - The original page, greyscale.
|
|
890
|
+
* @param scan - The scan, aligned onto the original's canvas, greyscale.
|
|
891
|
+
* @param dpi - What the original was rendered at.
|
|
892
|
+
* @param run - The run to check, from the original's text layer.
|
|
893
|
+
* @param templates - Glyphs collected from the original by `collectTemplates`.
|
|
894
|
+
* @param options - Margin, figure length and the characters figures use.
|
|
895
|
+
* @returns What the ink says, or `null` when the run could not be checked.
|
|
896
|
+
*/
|
|
897
|
+
function verifyPrintedRun(original, scan, dpi, run, templates, options = {}) {
|
|
898
|
+
const {
|
|
899
|
+
scope = 'figures',
|
|
900
|
+
minMargin = MIN_MARGIN,
|
|
901
|
+
minScore = MIN_SCORE,
|
|
902
|
+
maxPrinted = MAX_PRINTED_SCORE,
|
|
903
|
+
minPrinted = MIN_PRINTED_SCORE,
|
|
904
|
+
minRivals = scope === 'text' ? MIN_TEXT_RIVALS : MIN_RIVALS,
|
|
905
|
+
minDigits = 2,
|
|
906
|
+
characters = scope === 'text' ? TEXT_CHARACTERS : FIGURE_CHARACTERS$1
|
|
907
|
+
} = options;
|
|
908
|
+
const printed = [...run.text].filter(character => character.trim() !== '');
|
|
909
|
+
const wanted = scope === 'text' ? textCells(printed, characters) : figureCells(printed, minDigits);
|
|
910
|
+
if (wanted.size === 0) return null;
|
|
911
|
+
// A total on a shaded bar is printed light on dark; turned round, it is a figure like any other.
|
|
912
|
+
const lightOnDark = printPolarity(original, dpi, run) === 'light-on-dark';
|
|
913
|
+
const cells = placeGlyphs(original, dpi, run, run.text, {
|
|
914
|
+
lightOnDark
|
|
915
|
+
});
|
|
916
|
+
if (cells === null) return null;
|
|
917
|
+
// Rivals: every character the page prints in this face and size that a figure could use.
|
|
918
|
+
const rivals = [...characters].filter(character => templates.has(templateKey(run, character)));
|
|
919
|
+
if (rivals.length < minRivals) return null;
|
|
920
|
+
// How soft this scan's print is, measured where the answer is known: each cell
|
|
921
|
+
// against the very glyph the original prints there.
|
|
922
|
+
const pairs = [...wanted].flatMap(index => {
|
|
923
|
+
const cell = cells[index];
|
|
924
|
+
return cell === null ? [] : [{
|
|
925
|
+
glyph: cut(scan, dpi, cell, lightOnDark),
|
|
926
|
+
printed: cut(original, dpi, cell, lightOnDark)
|
|
927
|
+
}];
|
|
928
|
+
}).filter(pair => pair.glyph !== null && pair.printed !== null);
|
|
929
|
+
const softening = softeningFor(pairs);
|
|
930
|
+
const reading = [...printed];
|
|
931
|
+
const scored = [];
|
|
932
|
+
let confidence = 1;
|
|
933
|
+
let checked = 0;
|
|
934
|
+
for (const index of wanted) {
|
|
935
|
+
// The cell exactly: a margin would bring in the neighbouring glyphs, which
|
|
936
|
+
// both crops share, and shared ink correlates whatever the character is.
|
|
937
|
+
const cell = cells[index];
|
|
938
|
+
if (cell === null) continue;
|
|
939
|
+
const glyph = cut(scan, dpi, cell, lightOnDark);
|
|
940
|
+
// The original's own ink here: the same glyph, at the same size, in the same place.
|
|
941
|
+
const asPrinted = cut(original, dpi, cell, lightOnDark);
|
|
942
|
+
if (glyph === null || asPrinted === null) continue;
|
|
943
|
+
const printedScore = correlate(glyph, asPrinted, softening);
|
|
944
|
+
const others = rivals.filter(character => character !== printed[index]).map(character => ({
|
|
945
|
+
character,
|
|
946
|
+
score: bestMatch(glyph, templates.get(templateKey(run, character)) ?? [], softening)
|
|
947
|
+
})).toSorted((a, b) => b.score - a.score);
|
|
948
|
+
const rival = others[0];
|
|
949
|
+
if (rival === undefined) continue;
|
|
950
|
+
// Undecided unless one of the two wins clearly, and a change has to look like
|
|
951
|
+
// the character it is being read as, not merely less like the printed one.
|
|
952
|
+
const changed = rival.score >= printedScore + minMargin && rival.score >= minScore && printedScore <= maxPrinted;
|
|
953
|
+
const unchanged = printedScore >= rival.score + minMargin && printedScore >= minPrinted;
|
|
954
|
+
scored.push({
|
|
955
|
+
at: index,
|
|
956
|
+
printed: printed[index],
|
|
957
|
+
printedScore,
|
|
958
|
+
rival: rival.character,
|
|
959
|
+
rivalScore: rival.score,
|
|
960
|
+
read: changed ? rival.character : unchanged ? printed[index] : null
|
|
961
|
+
});
|
|
962
|
+
if (!changed && !unchanged) continue;
|
|
963
|
+
checked++;
|
|
964
|
+
if (changed) reading[index] = rival.character;
|
|
965
|
+
confidence = Math.min(confidence, Math.abs(printedScore - rival.score));
|
|
966
|
+
}
|
|
967
|
+
// Nothing decided is not an answer: say nothing rather than guess a digit.
|
|
968
|
+
if (checked === 0) return null;
|
|
969
|
+
// Put the spaces back, so the reading reads like the run it is about.
|
|
970
|
+
let cell = 0;
|
|
971
|
+
const text = [...run.text].map(character => character.trim() === '' ? character : reading[cell++]).join('');
|
|
972
|
+
return {
|
|
973
|
+
reading: text,
|
|
974
|
+
agrees: text === run.text,
|
|
975
|
+
checked,
|
|
976
|
+
confidence,
|
|
977
|
+
cells: scored
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* The digits of every figure of at least `minDigits` digits.
|
|
982
|
+
*
|
|
983
|
+
* Only digits: a full stop read as a comma is how a figure is written, not what
|
|
984
|
+
* it says, and at the size these are printed the two are a pixel apart.
|
|
985
|
+
*/
|
|
986
|
+
/** Every cell the character set covers: under `'text'`, the letters and digits of the run. */
|
|
987
|
+
function textCells(printed, characters) {
|
|
988
|
+
const wanted = new Set();
|
|
989
|
+
for (const [index, character] of printed.entries()) if (characters.includes(character)) wanted.add(index);
|
|
990
|
+
return wanted;
|
|
991
|
+
}
|
|
992
|
+
function figureCells(printed, minDigits) {
|
|
993
|
+
const wanted = new Set();
|
|
994
|
+
let group = [];
|
|
995
|
+
const close = () => {
|
|
996
|
+
if (group.length >= minDigits) for (const index of group) wanted.add(index);
|
|
997
|
+
group = [];
|
|
998
|
+
};
|
|
999
|
+
for (const [index, character] of printed.entries()) {
|
|
1000
|
+
if (/\d/.test(character)) group.push(index);
|
|
1001
|
+
// A separator inside a figure carries on the group; anything else ends it.
|
|
1002
|
+
else if (!/[.,\-/\s]/.test(character) || group.length === 0) close();
|
|
1003
|
+
}
|
|
1004
|
+
close();
|
|
1005
|
+
return wanted;
|
|
1006
|
+
}
|
|
1007
|
+
/** How soft the print has to be drawn to sit best on the scan's own glyphs. */
|
|
1008
|
+
function softeningFor(pairs) {
|
|
1009
|
+
let best = {
|
|
1010
|
+
softening: 0,
|
|
1011
|
+
score: -Infinity
|
|
1012
|
+
};
|
|
1013
|
+
for (const softening of SOFTENING) {
|
|
1014
|
+
const score = pairs.reduce((sum, pair) => sum + correlate(pair.glyph, pair.printed, softening), 0);
|
|
1015
|
+
if (score > best.score) best = {
|
|
1016
|
+
softening,
|
|
1017
|
+
score
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
return best.softening;
|
|
1021
|
+
}
|
|
1022
|
+
/** The best correlation between one glyph and a character's templates. */
|
|
1023
|
+
function bestMatch(glyph, templates, softening) {
|
|
1024
|
+
let best = -1;
|
|
1025
|
+
for (const template of templates) best = Math.max(best, correlate(glyph, template, softening));
|
|
1026
|
+
return best;
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* How alike two glyphs are, each scaled to the same small box, and the template
|
|
1030
|
+
* slid a pixel each way to allow for a cell that landed a fraction out.
|
|
1031
|
+
*
|
|
1032
|
+
* Correlation, rather than a difference of pixels, because a scan is darker or
|
|
1033
|
+
* lighter than the print it came from and that must not decide anything.
|
|
1034
|
+
*/
|
|
1035
|
+
function correlate(glyph, template, softening = 0) {
|
|
1036
|
+
const height = MATCH_HEIGHT;
|
|
1037
|
+
const width = Math.max(2, Math.round(height * ((glyph.width / glyph.height + template.width / template.height) / 2)));
|
|
1038
|
+
const a = resizeGray(glyph, width, height);
|
|
1039
|
+
const b = soften(resizeGray(template, width, height), softening);
|
|
1040
|
+
let best = -1;
|
|
1041
|
+
for (let dy = -SHIFT; dy <= SHIFT; dy++) for (let dx = -SHIFT; dx <= SHIFT; dx++) best = Math.max(best, pearson(a, b, dx, dy));
|
|
1042
|
+
return best;
|
|
1043
|
+
}
|
|
1044
|
+
/** A Gaussian blur of `sigma` match pixels, which is how print looks once it has been scanned. */
|
|
1045
|
+
function soften(image, sigma) {
|
|
1046
|
+
if (sigma <= 0) return image;
|
|
1047
|
+
const radius = Math.max(1, Math.ceil(sigma * 2));
|
|
1048
|
+
const kernel = [];
|
|
1049
|
+
for (let i = -radius; i <= radius; i++) kernel.push(Math.exp(-(i * i) / (2 * sigma * sigma)));
|
|
1050
|
+
const total = kernel.reduce((a, b) => a + b, 0);
|
|
1051
|
+
const weights = kernel.map(value => value / total);
|
|
1052
|
+
const {
|
|
1053
|
+
width,
|
|
1054
|
+
height
|
|
1055
|
+
} = image;
|
|
1056
|
+
const horizontal = new Float32Array(width * height);
|
|
1057
|
+
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
|
|
1058
|
+
let sum = 0;
|
|
1059
|
+
for (const [k, weight] of weights.entries()) {
|
|
1060
|
+
const sx = Math.min(width - 1, Math.max(0, x + k - radius));
|
|
1061
|
+
sum += image.data[y * width + sx] * weight;
|
|
1062
|
+
}
|
|
1063
|
+
horizontal[y * width + x] = sum;
|
|
1064
|
+
}
|
|
1065
|
+
const data = new Float32Array(width * height);
|
|
1066
|
+
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
|
|
1067
|
+
let sum = 0;
|
|
1068
|
+
for (const [k, weight] of weights.entries()) {
|
|
1069
|
+
const sy = Math.min(height - 1, Math.max(0, y + k - radius));
|
|
1070
|
+
sum += horizontal[sy * width + x] * weight;
|
|
1071
|
+
}
|
|
1072
|
+
data[y * width + x] = sum;
|
|
1073
|
+
}
|
|
1074
|
+
return {
|
|
1075
|
+
width,
|
|
1076
|
+
height,
|
|
1077
|
+
data
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
/** Pearson correlation of two images of one size, the second shifted by `dx`, `dy`. */
|
|
1081
|
+
function pearson(a, b, dx, dy) {
|
|
1082
|
+
let count = 0;
|
|
1083
|
+
let sumA = 0;
|
|
1084
|
+
let sumB = 0;
|
|
1085
|
+
for (let y = Math.max(0, -dy); y < Math.min(a.height, a.height - dy); y++) for (let x = Math.max(0, -dx); x < Math.min(a.width, a.width - dx); x++) {
|
|
1086
|
+
sumA += a.data[y * a.width + x];
|
|
1087
|
+
sumB += b.data[(y + dy) * b.width + x + dx];
|
|
1088
|
+
count++;
|
|
1089
|
+
}
|
|
1090
|
+
if (count === 0) return 0;
|
|
1091
|
+
const meanA = sumA / count;
|
|
1092
|
+
const meanB = sumB / count;
|
|
1093
|
+
let covariance = 0;
|
|
1094
|
+
let varianceA = 0;
|
|
1095
|
+
let varianceB = 0;
|
|
1096
|
+
for (let y = Math.max(0, -dy); y < Math.min(a.height, a.height - dy); y++) for (let x = Math.max(0, -dx); x < Math.min(a.width, a.width - dx); x++) {
|
|
1097
|
+
const da = a.data[y * a.width + x] - meanA;
|
|
1098
|
+
const db = b.data[(y + dy) * b.width + x + dx] - meanB;
|
|
1099
|
+
covariance += da * db;
|
|
1100
|
+
varianceA += da * da;
|
|
1101
|
+
varianceB += db * db;
|
|
1102
|
+
}
|
|
1103
|
+
const spread = Math.sqrt(varianceA * varianceB);
|
|
1104
|
+
return spread === 0 ? 0 : covariance / spread;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
436
1107
|
const DEFAULT_TESSERACT_OPTIONS = {
|
|
437
1108
|
languages: ['eng'],
|
|
438
1109
|
model: 'best',
|
|
@@ -611,6 +1282,45 @@ const DEFAULT_RECHECK_PASSES = [{
|
|
|
611
1282
|
const FIGURE_CHARACTERS = '0123456789.,:/%()+-';
|
|
612
1283
|
/** White kept around a crop, in pixels: tesseract reads poorly off the edge of an image. */
|
|
613
1284
|
const MARGIN = 12;
|
|
1285
|
+
/**
|
|
1286
|
+
* Reads one run's crop once per pass, and says what each pass read - without
|
|
1287
|
+
* judging it against anything.
|
|
1288
|
+
*
|
|
1289
|
+
* `recheckRun` asks "does this still say what the original prints?", which is
|
|
1290
|
+
* the right question while reading a page. It is the wrong question once the
|
|
1291
|
+
* pixels have disagreed with the reading, because a systematic misreading -
|
|
1292
|
+
* a face, a size, a resolution the engine handles badly - misreads the
|
|
1293
|
+
* *original* just as surely as the scan. Reading both sides the same way and
|
|
1294
|
+
* comparing the two readings to each other cancels exactly that error, and
|
|
1295
|
+
* needs the readings themselves rather than a verdict.
|
|
1296
|
+
*
|
|
1297
|
+
* @param engine - The engine to read with.
|
|
1298
|
+
* @param image - The page to crop from, and its resolution.
|
|
1299
|
+
* @param run - The run to read, placed on that page.
|
|
1300
|
+
* @param options - Which passes to try; the matching rules pick the charset.
|
|
1301
|
+
* @param polarity - Whether the original prints this run light on dark.
|
|
1302
|
+
* @returns What each pass read, in order; empty when the run cannot be cropped.
|
|
1303
|
+
*/
|
|
1304
|
+
async function readRun(engine, image, run, options, polarity) {
|
|
1305
|
+
const {
|
|
1306
|
+
passes = DEFAULT_RECHECK_PASSES
|
|
1307
|
+
} = options;
|
|
1308
|
+
const crop = cropRun(image.raster, image.dpi, run, polarity);
|
|
1309
|
+
if (crop === null) return [];
|
|
1310
|
+
const characters = FIGURE.test(run.text.trim()) ? FIGURE_CHARACTERS : undefined;
|
|
1311
|
+
const readings = [];
|
|
1312
|
+
for (const pass of passes) {
|
|
1313
|
+
const scale = Math.max(1, pass.dpi / image.dpi);
|
|
1314
|
+
const enlarged = scale === 1 ? crop : await resampleRaster(crop, Math.round(crop.width * scale), Math.round(crop.height * scale));
|
|
1315
|
+
const prepared = frame(polarity === 'light-on-dark' || pass.stretch === true ? stretch(enlarged) : enlarged);
|
|
1316
|
+
const read = await engine.recognise(prepared, {
|
|
1317
|
+
layout: pass.layout,
|
|
1318
|
+
characters
|
|
1319
|
+
});
|
|
1320
|
+
readings.push(read.lines.map(line => line.text).join(' ').trim());
|
|
1321
|
+
}
|
|
1322
|
+
return readings;
|
|
1323
|
+
}
|
|
614
1324
|
async function recheckRun(engine, image, run, options, polarity) {
|
|
615
1325
|
const {
|
|
616
1326
|
passes = DEFAULT_RECHECK_PASSES,
|
|
@@ -678,25 +1388,6 @@ function cropRun(raster, dpi, run, polarity) {
|
|
|
678
1388
|
if (invert) for (let i = 0; i < out.data.length; i += 4) for (let c = 0; c < 3; c++) out.data[i + c] = 255 - out.data[i + c];
|
|
679
1389
|
return out;
|
|
680
1390
|
}
|
|
681
|
-
/**
|
|
682
|
-
* Which way the original prints a run, read off its own crisp rendering: the
|
|
683
|
-
* glyphs are the pixels far from the background, and the background is most of
|
|
684
|
-
* the box.
|
|
685
|
-
*/
|
|
686
|
-
function printPolarity(raster, dpi, run) {
|
|
687
|
-
const s = dpi / 72;
|
|
688
|
-
const left = Math.max(0, Math.floor(run.x * s));
|
|
689
|
-
const top = Math.max(0, Math.floor(run.y * s));
|
|
690
|
-
const right = Math.min(raster.width, Math.ceil((run.x + run.width) * s));
|
|
691
|
-
const bottom = Math.min(raster.height, Math.ceil((run.y + run.height) * s));
|
|
692
|
-
const values = [];
|
|
693
|
-
for (let y = top; y < bottom; y++) for (let x = left; x < right; x++) values.push(luminance(raster.data, (y * raster.width + x) * 4));
|
|
694
|
-
if (values.length === 0) return 'dark-on-light';
|
|
695
|
-
const background = values.toSorted((a, b) => a - b)[Math.floor(values.length / 2)];
|
|
696
|
-
const glyphs = values.filter(v => Math.abs(v - background) > 48);
|
|
697
|
-
if (glyphs.length === 0) return 'dark-on-light';
|
|
698
|
-
return glyphs.reduce((a, b) => a + b, 0) / glyphs.length > background ? 'light-on-dark' : 'dark-on-light';
|
|
699
|
-
}
|
|
700
1391
|
/** Linear stretch so the darkest 2% become black and the lightest 2% white. */
|
|
701
1392
|
function stretch(raster) {
|
|
702
1393
|
const values = new Uint8Array(raster.width * raster.height);
|
|
@@ -793,7 +1484,8 @@ async function readPage(page, engine, options) {
|
|
|
793
1484
|
scoreMetric = 'levenshteinSimilarity',
|
|
794
1485
|
matchThreshold = 0.8,
|
|
795
1486
|
minWordConfidence = 60,
|
|
796
|
-
recheck = {}
|
|
1487
|
+
recheck = {},
|
|
1488
|
+
printCheck = {}
|
|
797
1489
|
} = options;
|
|
798
1490
|
const warnings = [];
|
|
799
1491
|
// The scan: its enhanced image as it is, or the aligned scan made fine enough to read.
|
|
@@ -846,6 +1538,7 @@ async function readPage(page, engine, options) {
|
|
|
846
1538
|
// image - belong to the original even though its text layer lacks them.
|
|
847
1539
|
// Writing over printed matter is for @scanmate/diff to see, not this.
|
|
848
1540
|
const originalDpi = page.original.dpi ?? assumeDpi;
|
|
1541
|
+
const originalGray = toGrayscale(page.original.raster);
|
|
849
1542
|
claims.added = claims.added.map(group => group.filter(word => !printedUnder(page.original.raster, originalDpi, word))).filter(group => group.length > 0);
|
|
850
1543
|
const rechecks = {
|
|
851
1544
|
attempted: 0,
|
|
@@ -859,12 +1552,36 @@ async function readPage(page, engine, options) {
|
|
|
859
1552
|
const second = await recheckRun(engine, scanImage, run, {
|
|
860
1553
|
...rules,
|
|
861
1554
|
...recheck
|
|
862
|
-
}, printPolarity(
|
|
1555
|
+
}, printPolarity(originalGray, originalDpi, run));
|
|
863
1556
|
if (second.reading === null) continue;
|
|
864
1557
|
claims.found[r] = second.reading;
|
|
865
1558
|
rechecks.cleared++;
|
|
866
1559
|
}
|
|
867
|
-
|
|
1560
|
+
// Figures are matched against the original's own glyphs, which settles what no
|
|
1561
|
+
// reading of a returned scan can: whether this is still the digit that was printed.
|
|
1562
|
+
const printChecks = {
|
|
1563
|
+
checked: 0,
|
|
1564
|
+
different: 0
|
|
1565
|
+
};
|
|
1566
|
+
const seenChanged = new Set();
|
|
1567
|
+
if (printCheck !== false && useLayer) {
|
|
1568
|
+
const scanGray = toGrayscale(page.aligned.raster);
|
|
1569
|
+
const printed = items.map(item => ({
|
|
1570
|
+
...item
|
|
1571
|
+
}));
|
|
1572
|
+
const templates = collectTemplates(originalGray, originalDpi, printed);
|
|
1573
|
+
for (const [r, run] of printed.entries()) {
|
|
1574
|
+
const verified = verifyPrintedRun(originalGray, scanGray, originalDpi, run, templates, printCheck);
|
|
1575
|
+
if (verified === null) continue;
|
|
1576
|
+
printChecks.checked++;
|
|
1577
|
+
if (!verified.agrees) {
|
|
1578
|
+
printChecks.different++;
|
|
1579
|
+
seenChanged.add(r);
|
|
1580
|
+
}
|
|
1581
|
+
claims.found[r] = mergeVerifiedFigures(claims.found[r], run, verified);
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
const match = judgeRuns(references, claims, rules, seenChanged);
|
|
868
1585
|
const runs = references.map((run, r) => ({
|
|
869
1586
|
text: run.text,
|
|
870
1587
|
found: claims.found[r],
|
|
@@ -887,6 +1604,7 @@ async function readPage(page, engine, options) {
|
|
|
887
1604
|
metrics,
|
|
888
1605
|
differences: match.differences,
|
|
889
1606
|
rechecks,
|
|
1607
|
+
printChecks,
|
|
890
1608
|
warnings
|
|
891
1609
|
};
|
|
892
1610
|
}
|
|
@@ -965,5 +1683,5 @@ function mean(values) {
|
|
|
965
1683
|
return values.length === 0 ? 0 : values.reduce((a, b) => a + b, 0) / values.length;
|
|
966
1684
|
}
|
|
967
1685
|
|
|
968
|
-
export { DEFAULT_NORMALISE, DEFAULT_RECHECK_PASSES, DEFAULT_TESSERACT_OPTIONS, claimWords, compareTexts, cosine, createTesseractEngine, diacriticsMap, dice, foldConfusables, foldDiacritics, jaccard, jaroWinkler, judgeRun, judgeRuns, levenshtein, levenshteinSimilarity, matchWords, normaliseText, ocrPages, printPolarity, recheckRun, tokenise, wordDistance, wordRecall };
|
|
1686
|
+
export { DEFAULT_NORMALISE, DEFAULT_RECHECK_PASSES, DEFAULT_TESSERACT_OPTIONS, FIGURE_CHARACTERS$1 as FIGURE_CHARACTERS, TEXT_CHARACTERS, claimWords, collectTemplates, compareTexts, cosine, createTesseractEngine, diacriticsMap, dice, foldConfusables, foldDiacritics, glyphCells, glyphWords, jaccard, jaroWinkler, judgeRun, judgeRuns, levenshtein, levenshteinSimilarity, matchWords, normaliseText, ocrPages, placeGlyphs, printPolarity, readRun, recheckRun, templateKey, tokenise, verifyPrintedRun, wordDistance, wordRecall };
|
|
969
1687
|
//# sourceMappingURL=index.esm.js.map
|