diff --git a/package-lock.json b/package-lock.json index e193f50..c126b99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@commitlint/cli": "^21.2.2", "@commitlint/config-conventional": "^21.2.2", "@mnci/eslint-config": ">=0.1.0 <1.0.0", - "@mnci/nx-python-pip": "^0.2.9", + "@mnci/nx-python-pip": "^0.2.10", "@nx/esbuild": "23.2.0", "@nx/eslint": "23.2.0", "@nx/eslint-plugin": "23.2.0", @@ -4449,9 +4449,9 @@ } }, "node_modules/@mnci/nx-python-pip": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@mnci/nx-python-pip/-/nx-python-pip-0.2.9.tgz", - "integrity": "sha512-5QJpkPnRtqAGfR6BufolxbcgG73ntYsL+vtaowovoUn6k4t/tmJ9iNP6yWEaTtQuXBM8z/fp3LzKajabhubrYg==", + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@mnci/nx-python-pip/-/nx-python-pip-0.2.10.tgz", + "integrity": "sha512-QI6w8+m4Nk2Mu/cC5TaAduXP+fZRmBzPnKSYH1R8uPks4Fuy7UGFvsmi4FJXNN1mIgbAHGYPHwRwQm57Pr1Usg==", "dev": true, "license": "MIT", "peerDependencies": { diff --git a/package.json b/package.json index eb10e50..f610492 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "@commitlint/cli": "^21.2.2", "@commitlint/config-conventional": "^21.2.2", "@mnci/eslint-config": ">=0.1.0 <1.0.0", - "@mnci/nx-python-pip": "^0.2.9", + "@mnci/nx-python-pip": "^0.2.10", "@nx/esbuild": "23.2.0", "@nx/eslint": "23.2.0", "@nx/eslint-plugin": "23.2.0", diff --git a/python-packages/scanmate-scan/README.md b/python-packages/scanmate-scan/README.md index afd9c4f..266f37e 100644 --- a/python-packages/scanmate-scan/README.md +++ b/python-packages/scanmate-scan/README.md @@ -83,6 +83,46 @@ Laplacian-difference kernel, scaled so Gaussian noise of standard deviation sigma scores sigma. Text and edges occupy a small share of a page and noise is everywhere, so on a document the noise dominates. +## Levelling and contrast + +Scanned and photographed pages come back with shadows, yellowed or grey paper and +washed-out text. Dividing each pixel by an estimate of the paper behind it - a +wide box mean, which no stroke is big enough to move - flattens the lighting and +leaves the ink; a linear stretch between the black and white points then clamps +paper towards white and ink towards black. In colour mode each channel is divided +by its own background, which corrects the white balance while a blue pen stays +blue. + +```python +from scanmate_scan import EnhanceOptions, SharpenOptions, enhance_raster + +result = enhance_raster(page) # every default +result.applied.white_point, result.applied.despeckled # what it chose + +enhance_raster(page, EnhanceOptions(white_point=0.95)) # fix one point +enhance_raster(page, EnhanceOptions(sharpen=SharpenOptions(sigma=2))) +``` + +Read what `"auto"` actually does, because it is **not** "paper to white". Paper is +nearly all of a page and noise spreads it both ways, so its brightest 1% sit +above 1 and the white point lands on its 1.1 bound; ink is a few percent of a +text page, so the black point lands low. `"auto"` is therefore a gentle stretch +that leaves paper light grey - kept because on OCR word recall it beat fixed +points that do whiten the paper. For white paper, pass a fixed `white_point` +below 1. + +Two roundings sit side by side here, and either would be right on most pixels on +its own: the contrast stretch rounds a half **up** (`Math.round`), while the +unsharp mask writes into a `Uint8ClampedArray`, which rounds a half **to even**. +Both are pinned, and the tests compare the whole enhanced page as a sha256 over +every byte rather than sampling it, because sampling is exactly how a +one-rule-for-both port passes. + +`sharpen_raster` is **absent**: it is the same unsharp mask with the blur done by +libvips, and `blur_raster` is absent from `scanmate-ink` for the reason that +package's README gives. `sharpen` here uses the box-blur mask, which is the one +the sharpening was tuned with. + ## What is not here **The session that ties the pipeline together**, and most of what it calls. The @@ -92,11 +132,10 @@ rasteriser and text-layer segmentation, and Tesseract, have no Python equivalent that could honestly be called a parallel port. What remains portable, and is not yet done: the rest of the image algorithms -(`illumination-correction`, `scan-enhancement`, `checkbox-reading`, -`audit-evidence`, `audit-calibration`), `region-comparison`, -`finding-correlation`, `corpus-calibration`, `audit-reuse`, -`signature-checking`, and `content-search`. About 2,800 of the TypeScript's -6,700 lines. +(`scan-enhancement`, `checkbox-reading`, `audit-evidence`, +`audit-calibration`), `region-comparison`, `finding-correlation`, +`corpus-calibration`, `audit-reuse`, `signature-checking`, and +`content-search`. About 2,400 of the TypeScript's 6,700 lines. ## Requirements diff --git a/python-packages/scanmate-scan/scanmate_scan/__init__.py b/python-packages/scanmate-scan/scanmate_scan/__init__.py index a005955..a09cf6d 100644 --- a/python-packages/scanmate-scan/scanmate_scan/__init__.py +++ b/python-packages/scanmate-scan/scanmate_scan/__init__.py @@ -20,17 +20,41 @@ best_match, word_span, ) +from .illumination_correction import ( + DEFAULT_ENHANCE_OPTIONS, + AppliedEnhancement, + ContrastPoint, + ContrastPoints, + DespeckleChoice, + EnhancedRaster, + EnhanceOptions, + SharpenOptions, + enhance_raster, + estimate_contrast_points, + resolve_contrast_points, +) from .noise_reduction import despeckle, estimate_noise_sigma from .stage_caching import fingerprint __all__ = [ + "DEFAULT_ENHANCE_OPTIONS", + "AppliedEnhancement", "ApproximateMatch", + "ContrastPoint", + "ContrastPoints", + "DespeckleChoice", + "EnhanceOptions", + "EnhancedRaster", "SearchOptions", + "SharpenOptions", "Span", "approximate_search", "best_match", "despeckle", + "enhance_raster", + "estimate_contrast_points", "estimate_noise_sigma", "fingerprint", + "resolve_contrast_points", "word_span", ] diff --git a/python-packages/scanmate-scan/scanmate_scan/illumination_correction/__init__.py b/python-packages/scanmate-scan/scanmate_scan/illumination_correction/__init__.py new file mode 100644 index 0000000..5e8093a --- /dev/null +++ b/python-packages/scanmate-scan/scanmate_scan/illumination_correction/__init__.py @@ -0,0 +1,27 @@ +"""Levelling a page's lighting, and stretching its contrast.""" + +from .contrast_points_policy import estimate_contrast_points, resolve_contrast_points +from .enhance_options_contract import ( + DEFAULT_ENHANCE_OPTIONS, + AppliedEnhancement, + ContrastPoint, + ContrastPoints, + DespeckleChoice, + EnhanceOptions, + SharpenOptions, +) +from .enhance_raster_use_case import EnhancedRaster, enhance_raster + +__all__ = [ + "DEFAULT_ENHANCE_OPTIONS", + "AppliedEnhancement", + "ContrastPoint", + "ContrastPoints", + "DespeckleChoice", + "EnhanceOptions", + "EnhancedRaster", + "SharpenOptions", + "enhance_raster", + "estimate_contrast_points", + "resolve_contrast_points", +] diff --git a/python-packages/scanmate-scan/scanmate_scan/illumination_correction/contrast_points_policy.py b/python-packages/scanmate-scan/scanmate_scan/illumination_correction/contrast_points_policy.py new file mode 100644 index 0000000..1c59dbd --- /dev/null +++ b/python-packages/scanmate-scan/scanmate_scan/illumination_correction/contrast_points_policy.py @@ -0,0 +1,105 @@ +"""Where to clamp to black and to white, read from the page itself. + +Every pixel is a ratio to its local background: paper sits near 1, ink well +below. The black point is the ratio the darkest 1% of pixels reach, the white +point the one the brightest 1% start at, each kept within bounds - black at most +0.4, white between 0.7 and 1.1. + +Read what that does on a real page, because it is **not** "paper to white". +Paper is nearly all of a page and noise spreads it both ways, so its brightest +1% sit above 1 and the white point lands on its 1.1 bound; ink is a few percent +of a text page and softened at scan resolution, so the black point lands on its +0.4 bound. On three real scans that held on every one of 21 pages: ``"auto"`` +became a gentle stretch from 0.4 to 1.1, leaving paper light grey (about 220) +and deepening ink. It is kept because it read best - on OCR word recall it beat +fixed points that do whiten the paper. For white paper, pass a fixed +``white_point`` below 1. +""" + +from __future__ import annotations + +import numpy +from scanmate_ink import GrayImage + +from .enhance_options_contract import ContrastPoint, ContrastPoints + +_BINS = 256 +_MAX_RATIO = 1.5 +_BLACK_PERCENTILE = 0.01 +_WHITE_PERCENTILE = 0.99 + + +def estimate_contrast_points(gray: GrayImage, background: GrayImage) -> ContrastPoints: + """Read the clamp points off the page's own histogram. + + :param gray: The page, greyscale. + :param background: Its local background, the same size. + :returns: The two points, each held within its bounds. + """ + ratio = gray.pixels.astype(numpy.float64) / numpy.maximum( + background.pixels.astype(numpy.float64), 1e-3 + ) + # `Math.round`, half UP - not numpy's `rint`, which goes to even and would + # put a ratio landing exactly between two bins in the wrong one. + bins = numpy.floor(ratio / _MAX_RATIO * (_BINS - 1) + 0.5) + numpy.clip(bins, 0, _BINS - 1, out=bins) + histogram = numpy.bincount(bins.reshape(-1).astype(numpy.int64), minlength=_BINS) + + total = gray.pixels.size + + return ContrastPoints( + black_point=_clamp( + _percentile_bin(histogram, total, _BLACK_PERCENTILE) / (_BINS - 1) * _MAX_RATIO, + 0, + 0.4, + ), + white_point=_clamp( + _percentile_bin(histogram, total, _WHITE_PERCENTILE) / (_BINS - 1) * _MAX_RATIO, + 0.7, + 1.1, + ), + ) + + +def resolve_contrast_points( + white_point: ContrastPoint, + black_point: ContrastPoint, + gray: GrayImage, + background: GrayImage, +) -> ContrastPoints: + """Fixed points pass through; ``"auto"`` ones are read from the page. + + Computed only if needed - two fixed points never touch the histogram. + + :param white_point: A share of the background, or ``"auto"``. + :param black_point: The same. + :param gray: The page, greyscale. + :param background: Its local background. + :returns: The resolved points. + """ + if white_point != "auto" and black_point != "auto": + return ContrastPoints(white_point=float(white_point), black_point=float(black_point)) + + auto = estimate_contrast_points(gray, background) + + return ContrastPoints( + white_point=auto.white_point if white_point == "auto" else float(white_point), + black_point=auto.black_point if black_point == "auto" else float(black_point), + ) + + +def _percentile_bin(histogram: numpy.ndarray, total: int, percentile: float) -> int: + """The bin at which the cumulative count first reaches ``percentile`` of ``total``.""" + target = total * percentile + cumulative = 0.0 + for bin_index, count in enumerate(histogram.tolist()): + cumulative += count + if cumulative >= target: + return bin_index + + return len(histogram) - 1 + + +def _clamp(value: float, low: float, high: float) -> float: + """Held between the two, the TypeScript's way round: ``min(high, max(low, v))``.""" + return min(high, max(low, value)) diff --git a/python-packages/scanmate-scan/scanmate_scan/illumination_correction/enhance_options_contract.py b/python-packages/scanmate-scan/scanmate_scan/illumination_correction/enhance_options_contract.py new file mode 100644 index 0000000..c52b423 --- /dev/null +++ b/python-packages/scanmate-scan/scanmate_scan/illumination_correction/enhance_options_contract.py @@ -0,0 +1,90 @@ +"""How a page is cleaned, and what was actually done to it.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal, TypeAlias + +#: A clamp point read from the page, or given as a share of the background. +ContrastPoint: TypeAlias = "float | Literal['auto']" + +#: Whether to median-filter before normalising. +DespeckleChoice: TypeAlias = "bool | Literal['auto']" + + +@dataclass(frozen=True, slots=True) +class SharpenOptions: + """How hard to put back the edges a scanner softened.""" + + #: Radius, in pixels of the enhanced page - so fitted after any enlargement. + sigma: float + #: How much of what the blur removed is added back. + amount: float = 1.5 + + +@dataclass(frozen=True, slots=True) +class EnhanceOptions: + """Every option has a default; see :data:`DEFAULT_ENHANCE_OPTIONS`.""" + + #: Sharpen the levelled page. ``False`` by default: it helps a soft scan a + #: great deal and costs a good one a little, so it is chosen per document + #: rather than applied to every one. + sharpen: SharpenOptions | Literal[False] = False + #: Background window as a share of the page's shorter side. Wide enough + #: that no stroke fills it - or a bold heading becomes its own background + #: and fades - narrow enough to follow a shadow across the page. + background_fraction: float = 1 / 16 + #: Share of the local background above which a pixel becomes pure white. + #: ``"auto"`` reads it from the page's own histogram, and on real scans + #: settles at 1.1, which leaves paper light grey rather than white. + white_point: ContrastPoint = "auto" + #: Share of the local background below which a pixel becomes pure black. + black_point: ContrastPoint = "auto" + #: ``"color"`` normalises R, G and B each against its own background, which + #: removes a colour cast and keeps a blue signature blue. ``"grayscale"`` + #: converts first and returns a clean monochrome page. + mode: Literal["color", "grayscale"] = "color" + #: Median-filter before normalising. Speckle left in place fuses into thin + #: strokes once contrast is stretched; a median can equally erase a stroke + #: only a pixel or two wide. ``"auto"`` measures the page's noise first. + despeckle: DespeckleChoice = "auto" + #: Noise level, on the ``[0, 1]`` grey scale, above which ``"auto"`` + #: despeckles. + despeckle_threshold: float = 0.01 + #: Median window half-size in pixels: ``1`` is a 3x3 window. + despeckle_radius: float = 1 + + +#: Chosen by OCR word recall on three real scans (93, 120 and 144 dpi, 21 +#: pages) enlarged to 300 dpi: automatic clamp points gave the best mean, 0.738 +#: against 0.728 unenhanced, and gained most where reading is hardest - 0.448 +#: against 0.397 at 93 dpi. +#: +#: Forcing a despeckle cost recall at every resolution tried, and badly at the +#: scans' own (0.141 against 0.392 at 93 dpi): a 3x3 median erases a stroke one +#: or two pixels wide. So despeckling is ``"auto"``, which only fires on a page +#: that measures noisy. +DEFAULT_ENHANCE_OPTIONS = EnhanceOptions() + + +@dataclass(frozen=True, slots=True) +class AppliedEnhancement: + """What was actually done - the values ``"auto"`` settled on, among them.""" + + white_point: float + black_point: float + mode: Literal["color", "grayscale"] + despeckled: bool + #: The page's measured noise level; ``None`` when it was not measured + #: because nothing depended on it. + noise_sigma: float | None + #: The sharpening applied afterwards, or ``False`` when none was. + sharpened: SharpenOptions | Literal[False] + + +@dataclass(frozen=True, slots=True) +class ContrastPoints: + """Where to clamp to black and to white.""" + + white_point: float + black_point: float diff --git a/python-packages/scanmate-scan/scanmate_scan/illumination_correction/enhance_raster_use_case.py b/python-packages/scanmate-scan/scanmate_scan/illumination_correction/enhance_raster_use_case.py new file mode 100644 index 0000000..f6800fb --- /dev/null +++ b/python-packages/scanmate-scan/scanmate_scan/illumination_correction/enhance_raster_use_case.py @@ -0,0 +1,211 @@ +"""Even out the lighting, whiten the paper and darken the ink. + +Scanned and photographed pages come back with shadows, yellowed or grey paper +and washed-out text. Dividing each pixel by an estimate of the paper behind it - +a wide box mean, which no stroke is big enough to move - flattens the lighting +and leaves the ink; a linear stretch between the black and white points then +clamps paper to white and ink to black. In colour mode each channel is divided +by its own background, which also corrects the white balance while a blue pen +stays blue. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass + +import numpy +from numpy.typing import NDArray +from scanmate_ink import Raster, box_blur, box_blur_raster, to_grayscale + +from ..noise_reduction import despeckle as median_filter +from ..noise_reduction import estimate_noise_sigma +from .contrast_points_policy import resolve_contrast_points +from .enhance_options_contract import ( + DEFAULT_ENHANCE_OPTIONS, + AppliedEnhancement, + EnhanceOptions, + SharpenOptions, +) + + +@dataclass(frozen=True, slots=True) +class EnhancedRaster: + """The cleaned page, and what was done to it.""" + + raster: Raster + applied: AppliedEnhancement + + +def enhance_raster(raster: Raster, options: EnhanceOptions | None = None) -> EnhancedRaster: + """Clean a page: level the lighting, then stretch the contrast. + + :param raster: The page. + :param options: How to clean it. + :returns: The cleaned page and an account of what was applied. + + .. note:: + ``sharpenRaster`` - the same unsharp mask with the blur done by libvips + - is **absent**, because ``blur_raster`` is absent from ``scanmate-ink`` + for the reason its README gives. The box-blur mask below is what + ``sharpen`` uses here, and it is the one the sharpening was tuned with. + """ + settings = options if options is not None else DEFAULT_ENHANCE_OPTIONS + width = raster.width + height = raster.height + + raw_gray = to_grayscale(raster) + noise_sigma = estimate_noise_sigma(raw_gray) if settings.despeckle == "auto" else None + despeckled = settings.despeckle is True or ( + noise_sigma is not None and noise_sigma > settings.despeckle_threshold + ) + pixels = ( + median_filter(raster.pixels, width, height, settings.despeckle_radius) + if despeckled + else raster.pixels + ) + gray = to_grayscale(Raster(pixels=pixels)) if despeckled else raw_gray + + radius = max(4, _js_round(min(width, height) * settings.background_fraction)) + background = box_blur(gray, radius) + points = resolve_contrast_points( + settings.white_point, settings.black_point, gray, background + ) + span = max(1e-4, points.white_point - points.black_point) + out = numpy.empty((height, width, 4), dtype=numpy.uint8) + + if settings.mode == "color": + for channel in range(3): + blurred = _blur_channel(pixels, channel, width, height, radius) + ratio = pixels[:, :, channel].astype(numpy.float64) / numpy.maximum(blurred, 1) + out[:, :, channel] = _stretch(ratio, points.black_point, span) + else: + ratio = gray.pixels.astype(numpy.float64) / numpy.maximum( + background.pixels.astype(numpy.float64), 1e-3 + ) + value = _stretch(ratio, points.black_point, span) + for channel in range(3): + out[:, :, channel] = value + out[:, :, 3] = pixels[:, :, 3] + + cleaned = Raster(pixels=out) + sharpened = cleaned if settings.sharpen is False else _unsharp_mask(cleaned, settings.sharpen) + + return EnhancedRaster( + raster=sharpened, + applied=AppliedEnhancement( + white_point=points.white_point, + black_point=points.black_point, + mode=settings.mode, + despeckled=despeckled, + noise_sigma=noise_sigma, + sharpened=settings.sharpen, + ), + ) + + +def _unsharp_mask(raster: Raster, sharpen: SharpenOptions) -> Raster: + """The page, plus what a blur of it throws away. + + Applied last, on the enlarged and levelled page, and both of those matter. + Enlarging first is what sets the radius: a stroke on a page taken from 93 + dpi to 300 is three times the width it was scanned at, so a radius fitted + to the original resolution sharpens detail that is no longer there - + measured, a radius of 0.6 on such a page changed the reading in no way at + all, while 4 was the optimum. Levelling first is what gives it edges to + work on rather than paper shading: on the same page, levelling alone gained + 0.003 and sharpening alone 0.074, but the two in this order gained 0.144. + + Three box blurs stand in for a Gaussian, which is close enough for a mask. + """ + if sharpen.sigma <= 0 or sharpen.amount <= 0: + return raster + + radius = _box_radius(sharpen.sigma) + blurred = raster + for _ in range(3): + blurred = box_blur_raster(blurred, radius) + + return _add_back(raster, blurred, sharpen.amount) + + +def _box_radius(sigma: float) -> int: + """The box radius a sharpening sigma has always meant.""" + return max(1, _js_round(sigma)) + + +def _add_back(raster: Raster, blurred: Raster, amount: float) -> Raster: + """The page, plus ``amount`` times what the blur took from it.""" + original = raster.pixels[:, :, :3].astype(numpy.float64) + soft = blurred.pixels[:, :, :3].astype(numpy.float64) + out = numpy.empty(raster.pixels.shape, dtype=numpy.uint8) + out[:, :, :3] = _to_uint8_clamped(original + amount * (original - soft)) + out[:, :, 3] = raster.pixels[:, :, 3] + + return Raster(pixels=out) + + +def _stretch( + ratio: NDArray[numpy.float64], black_point: float, span: float +) -> NDArray[numpy.uint8]: + """A background ratio to an 8-bit value: linear between the clamp points. + + ``Math.round`` here - half UP - and then held inside 0 to 255. This is the + one place the two roundings sit side by side: the value is rounded by + ``Math.round`` *before* it reaches the clamped array, so the array's own + half-to-even rule never applies. + """ + value = numpy.floor((ratio - black_point) / span * 255 + 0.5) + + return numpy.clip(value, 0, 255).astype(numpy.uint8) + + +def _to_uint8_clamped(values: NDArray[numpy.float64]) -> NDArray[numpy.uint8]: + """What writing a float into a ``Uint8ClampedArray`` does. + + Clamped to 0-255 and rounded **half to even** - not half up. Writing 2.5 + stores 2 and 1.5 stores 2, which is the opposite of ``Math.round`` and is + easy to get wrong in both directions. ``numpy.rint`` has the same rule. + """ + return numpy.clip(numpy.rint(values), 0, 255).astype(numpy.uint8) + + +def _blur_channel( + pixels: NDArray[numpy.uint8], channel: int, width: int, height: int, radius: float +) -> NDArray[numpy.float32]: + """Box mean of one RGBA channel with a summed-area table. + + Averaged over the part of the window that lies **on the page**, so the + border is never darkened by paper that is not there. + """ + data = pixels[:, :, channel].astype(numpy.float64) + # The summed-area table, built row by row exactly as the TypeScript does: + # a running row total added to the row above. + total = numpy.zeros((height + 1, width + 1), dtype=numpy.float64) + total[1:, 1:] = numpy.cumsum(data, axis=1) + numpy.cumsum(total[1:, 1:], axis=0, out=total[1:, 1:]) + + r = max(1, _js_round(radius)) + ys = numpy.arange(height) + xs = numpy.arange(width) + y0 = numpy.maximum(0, ys - r) + y1 = numpy.minimum(height, ys + r + 1) + x0 = numpy.maximum(0, xs - r) + x1 = numpy.minimum(width, xs + r + 1) + + window = ( + total[numpy.ix_(y1, x1)] + - total[numpy.ix_(y1, x0)] + - total[numpy.ix_(y0, x1)] + + total[numpy.ix_(y0, x0)] + ) + area = numpy.outer(y1 - y0, x1 - x0).astype(numpy.float64) + + means: NDArray[numpy.float32] = (window / area).astype(numpy.float32) + + return means + + +def _js_round(value: float) -> int: + """``Math.round``: halves go up, not to even.""" + return math.floor(value + 0.5) diff --git a/python-packages/scanmate-scan/scanmate_scan/illumination_correction/test_illumination_correction.py b/python-packages/scanmate-scan/scanmate_scan/illumination_correction/test_illumination_correction.py new file mode 100644 index 0000000..24c38bc --- /dev/null +++ b/python-packages/scanmate-scan/scanmate_scan/illumination_correction/test_illumination_correction.py @@ -0,0 +1,392 @@ +"""Levelling and contrast, held to the TypeScript exactly. + +The enhanced page is compared as a whole image, sha256 over every byte, because +this is where two roundings sit side by side and either could be wrong on its +own: ``Math.round`` rounds a half **up** in the contrast stretch, and a +``Uint8ClampedArray`` rounds a half **to even** where the unsharp mask writes +into one. A port that used one rule for both would be right on most pixels. +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Any + +import numpy +import pytest +from scanmate_ink import ( + DocumentOptions, + GrayImage, + Raster, + ScanmateRect, + box_blur, + create_gray, + create_raster, + create_synthetic_document, + fill_rect, + to_grayscale, +) + +from ..noise_reduction import despeckle as median_filter +from .contrast_points_policy import estimate_contrast_points, resolve_contrast_points +from .enhance_options_contract import ( + DEFAULT_ENHANCE_OPTIONS, + EnhanceOptions, + SharpenOptions, +) +from .enhance_raster_use_case import _js_round, enhance_raster + +_GOLDEN: dict[str, Any] = json.loads( + ( + Path(__file__).parents[4] / "tools" / "parity" / "goldens" / "scan-illumination.json" + ).read_text(encoding="utf-8") +) + + +@pytest.fixture(scope="module") +def pages() -> dict[str, Raster]: + """Every page the golden names: generated documents, and hand-built ones. + + The hand-built ones exist because a generated document never lands on the + values a few of these cases are for - an exactly-half rounding, a page too + small for the background window's own floor, a background mean below one. + Each is built from the recipe the golden carries, so both sides build the + same pixels or every case using it fails at once. + """ + built = {} + for name, spec in _GOLDEN["builtPages"].items(): + grey = spec["grey"] + page = create_raster(spec["width"], spec["height"], (grey, grey, grey, 255)) + if spec.get("rect") is not None: + x, y, width, height = spec["rect"] + fill_rect(page, ScanmateRect(x=x, y=y, width=width, height=height), spec["rectValue"]) + built[name] = page + + return { + **built, + **{ + name: create_synthetic_document( + DocumentOptions(width=spec["width"], height=spec["height"], seed=spec["seed"]) + ).raster + for name, spec in _GOLDEN["pages"].items() + }, + } + + +_CASES: dict[str, tuple[str, EnhanceOptions]] = { + "defaults": ("small", EnhanceOptions()), + "grayscale": ("small", EnhanceOptions(mode="grayscale")), + "fixed-points": ("small", EnhanceOptions(white_point=0.95, black_point=0.2)), + "one-auto-point": ("small", EnhanceOptions(white_point=0.95)), + "no-despeckle": ("small", EnhanceOptions(despeckle=False)), + "forced-despeckle": ("small", EnhanceOptions(despeckle=True)), + "a-wider-background": ("small", EnhanceOptions(background_fraction=1 / 4)), + "sharpened": ("small", EnhanceOptions(sharpen=SharpenOptions(sigma=2))), + "sharpen-rounds-up": ("small", EnhanceOptions(sharpen=SharpenOptions(sigma=1.6))), + "sharpen-rounds-down": ("small", EnhanceOptions(sharpen=SharpenOptions(sigma=1.4))), + "sharpen-off-by-sigma": ("small", EnhanceOptions(sharpen=SharpenOptions(sigma=0))), + "sharpen-off-by-amount": ( + "small", + EnhanceOptions(sharpen=SharpenOptions(sigma=2, amount=0)), + ), + "a-wide-page": ("wide", EnhanceOptions()), + "stretch-half-up": ( + "uniform-200", + EnhanceOptions(mode="grayscale", white_point=6, black_point=0, despeckle=False), + ), + "inverted-points": ( + "uniform-200", + EnhanceOptions(mode="grayscale", white_point=0.2, black_point=0.5, despeckle=False), + ), + "a-tiny-page": ("a-small-mark", EnhanceOptions(despeckle=False)), + "sub-half-sigma": ( + "a-medium-mark", + EnhanceOptions(despeckle=False, sharpen=SharpenOptions(sigma=0.4)), + ), + "one-lit-pixel": ( + "one-lit-on-black", + EnhanceOptions(despeckle=False, white_point=1, black_point=0), + ), + "one-lit-pixel-in-grey": ( + "one-lit-on-black", + EnhanceOptions(mode="grayscale", despeckle=False, white_point=1, black_point=0), + ), + "one-lit-pixel-wide-span": ( + "one-lit-on-black", + EnhanceOptions(despeckle=False, white_point=100, black_point=0), + ), +} + + +@pytest.mark.parametrize("name", sorted(_GOLDEN["enhanceRaster"])) +def test_enhance_matches_the_typescript(name: str, pages: dict[str, Raster]) -> None: + want = _GOLDEN["enhanceRaster"][name] + page, options = _CASES[name] + raster = pages[page] + result = enhance_raster(raster, options) + + assert ( + hashlib.sha256(numpy.ascontiguousarray(result.raster.pixels).tobytes()).hexdigest() + == want["sha256"] + ) + + applied = result.applied + assert applied.white_point == want["applied"]["whitePoint"] + assert applied.black_point == want["applied"]["blackPoint"] + assert applied.mode == want["applied"]["mode"] + assert applied.despeckled == want["applied"]["despeckled"] + assert applied.noise_sigma == want["applied"]["noiseSigma"] + + flat = result.raster.pixels.reshape(-1, 4) + offsets = [0, 1, raster.width, raster.width * raster.height - 1] + for offset, sample in zip(offsets, want["samples"], strict=True): + assert [int(v) for v in flat[offset]] == sample + + +@pytest.mark.parametrize("name", sorted(_GOLDEN["contrastPoints"])) +def test_contrast_points_match_the_typescript(name: str, pages: dict[str, Raster]) -> None: + want = _GOLDEN["contrastPoints"][name] + gray = to_grayscale(pages[name]) + background = box_blur(gray, 8) + + estimated = estimate_contrast_points(gray, background) + assert estimated.white_point == want["estimated"]["whitePoint"] + assert estimated.black_point == want["estimated"]["blackPoint"] + + resolved = resolve_contrast_points(0.9, "auto", gray, background) + assert resolved.white_point == want["resolved"]["whitePoint"] + assert resolved.black_point == want["resolved"]["blackPoint"] + + +def _gray_of(values: list[float], width: int, height: int) -> GrayImage: + image = create_gray(width, height) + image.pixels[:] = numpy.asarray(values, dtype=numpy.float32).reshape(height, width) + + return image + + +@pytest.mark.parametrize("name", sorted(_GOLDEN["histogramPoints"])) +def test_a_chosen_histogram_reads_the_points_the_typescript_reads(name: str) -> None: + """Pages whose histogram is chosen, not measured. + + A generated page pins none of the bounds: its percentiles land in the middle + of a wide block of near-identical ratios, so nudging a percentile or a clamp + by one changes nothing and a wrong constant passes. These two pages put a + single pixel at each boundary that matters - including a ratio of 0.25, + whose histogram position is exactly 42.5, the one value where rounding a + half up and rounding it to even disagree. + """ + spec = _GOLDEN["histogramFixtures"][name] + want = _GOLDEN["histogramPoints"][name] + gray = _gray_of(spec["greys"], spec["width"], spec["height"]) + background = create_gray(spec["width"], spec["height"]) + background.pixels[:] = numpy.float32(spec["background"]) + + estimated = estimate_contrast_points(gray, background) + assert estimated.black_point == want["estimated"]["blackPoint"] + assert estimated.white_point == want["estimated"]["whitePoint"] + + resolved = resolve_contrast_points(0.9, "auto", gray, background) + assert resolved.white_point == want["resolved"]["whitePoint"] + assert resolved.black_point == want["resolved"]["blackPoint"] + + +def test_the_noise_threshold_is_strict(pages: dict[str, Raster]) -> None: + """The threshold set to exactly the page's own measured noise. + + ``"auto"`` despeckles when the measured sigma is **above** the threshold, so + at equality it does nothing. Nothing else reaches this boundary: a measured + statistic never equals a hand-written constant by accident, which is why the + golden carries the measured value and passes it back as the threshold. + """ + want = _GOLDEN["noiseThresholdBoundary"] + page = pages[want["page"]] + + applied = enhance_raster( + page, EnhanceOptions(despeckle_threshold=want["threshold"]) + ).applied + + assert applied.noise_sigma == want["applied"]["noiseSigma"] + assert applied.noise_sigma == want["threshold"] + assert applied.despeckled is want["applied"]["despeckled"] + + +def test_the_defaults_are_the_typescripts() -> None: + want = _GOLDEN["defaultOptions"] + + assert DEFAULT_ENHANCE_OPTIONS.sharpen is False + assert DEFAULT_ENHANCE_OPTIONS.background_fraction == want["backgroundFraction"] + assert DEFAULT_ENHANCE_OPTIONS.white_point == want["whitePoint"] + assert DEFAULT_ENHANCE_OPTIONS.black_point == want["blackPoint"] + assert DEFAULT_ENHANCE_OPTIONS.mode == want["mode"] + assert DEFAULT_ENHANCE_OPTIONS.despeckle == want["despeckle"] + assert DEFAULT_ENHANCE_OPTIONS.despeckle_threshold == want["despeckleThreshold"] + assert DEFAULT_ENHANCE_OPTIONS.despeckle_radius == want["despeckleRadius"] + + +class TestWhatAutoActuallyDoes: + def test_it_is_not_paper_to_white(self, pages: dict[str, Raster]) -> None: + """The point the policy's own note makes, asserted. + + Paper is nearly all of a page and noise spreads it both ways, so its + brightest 1% sit above 1 and the white point lands on its 1.1 bound; + ink is a few percent of a text page, so the black point lands low. The + result is a gentle stretch, not a whitening - and it is kept because on + real scans it read best. + """ + applied = enhance_raster(pages["small"]).applied + + assert applied.white_point == 1.1 + assert applied.black_point <= 0.4 + + def test_fixed_points_pass_straight_through(self, pages: dict[str, Raster]) -> None: + applied = enhance_raster( + pages["small"], EnhanceOptions(white_point=0.95, black_point=0.2) + ).applied + + assert (applied.white_point, applied.black_point) == (0.95, 0.2) + + def test_two_fixed_points_never_measure_the_page(self, pages: dict[str, Raster]) -> None: + # `noise_sigma` is None when nothing depended on it - the honest way of + # saying "not measured" rather than reporting a zero nobody computed. + applied = enhance_raster( + pages["small"], + EnhanceOptions(white_point=0.95, black_point=0.2, despeckle=False), + ).applied + + assert applied.noise_sigma is None + + def test_one_auto_point_still_reads_the_page(self, pages: dict[str, Raster]) -> None: + applied = enhance_raster(pages["small"], EnhanceOptions(white_point=0.95)).applied + + assert applied.white_point == 0.95 + assert applied.black_point != 0.95 + + +class TestDespecklingIsChosenNotForced: + def test_auto_measures_before_deciding(self, pages: dict[str, Raster]) -> None: + # Forcing a despeckle cost OCR recall at every resolution tried, badly + # at the scans' own: a 3x3 median erases a stroke one or two pixels + # wide. So it fires only on a page that measures noisy. + applied = enhance_raster(pages["small"]).applied + + assert applied.noise_sigma is not None + + def test_a_threshold_above_the_page_leaves_it_alone( + self, pages: dict[str, Raster] + ) -> None: + applied = enhance_raster( + pages["small"], EnhanceOptions(despeckle_threshold=10) + ).applied + + assert applied.despeckled is False + + def test_false_never_measures_and_never_filters(self, pages: dict[str, Raster]) -> None: + applied = enhance_raster(pages["small"], EnhanceOptions(despeckle=False)).applied + + assert applied.despeckled is False + assert applied.noise_sigma is None + + def test_true_filters_without_measuring(self, pages: dict[str, Raster]) -> None: + applied = enhance_raster(pages["small"], EnhanceOptions(despeckle=True)).applied + + assert applied.despeckled is True + assert applied.noise_sigma is None + + +class TestSharpening: + def test_it_is_off_by_default(self, pages: dict[str, Raster]) -> None: + # It helps a soft scan a great deal and costs a good one a little, so + # it is chosen per document rather than applied to every one. + assert enhance_raster(pages["small"]).applied.sharpened is False + + def test_a_zero_sigma_changes_nothing(self, pages: dict[str, Raster]) -> None: + plain = enhance_raster(pages["small"]) + none = enhance_raster(pages["small"], EnhanceOptions(sharpen=SharpenOptions(sigma=0))) + + assert numpy.array_equal(none.raster.pixels, plain.raster.pixels) + + def test_a_zero_amount_changes_nothing(self, pages: dict[str, Raster]) -> None: + plain = enhance_raster(pages["small"]) + none = enhance_raster( + pages["small"], EnhanceOptions(sharpen=SharpenOptions(sigma=2, amount=0)) + ) + + assert numpy.array_equal(none.raster.pixels, plain.raster.pixels) + + def test_the_sigma_rounds_rather_than_truncates(self, pages: dict[str, Raster]) -> None: + # 1.6 is a box radius of 2. A port that truncated would blur with a + # radius of 1 and sharpen the wrong scale of detail. + up = enhance_raster(pages["small"], EnhanceOptions(sharpen=SharpenOptions(sigma=1.6))) + two = enhance_raster(pages["small"], EnhanceOptions(sharpen=SharpenOptions(sigma=2))) + down = enhance_raster(pages["small"], EnhanceOptions(sharpen=SharpenOptions(sigma=1.4))) + + assert numpy.array_equal(up.raster.pixels, two.raster.pixels) + assert not numpy.array_equal(up.raster.pixels, down.raster.pixels) + + def test_it_changes_the_page(self, pages: dict[str, Raster]) -> None: + plain = enhance_raster(pages["small"]) + sharp = enhance_raster(pages["small"], EnhanceOptions(sharpen=SharpenOptions(sigma=2))) + + assert not numpy.array_equal(sharp.raster.pixels, plain.raster.pixels) + + +def test_alpha_survives_the_whole_pipeline(pages: dict[str, Raster]) -> None: + # Every stage copies it rather than computing it, and a page that lost its + # alpha would composite wrongly everywhere downstream. + result = enhance_raster(pages["small"], EnhanceOptions(sharpen=SharpenOptions(sigma=2))) + + assert numpy.array_equal(result.raster.pixels[:, :, 3], pages["small"].pixels[:, :, 3]) + + +class TestWhatNoFixtureCanSeparate: + """Three choices in this slice that no input distinguishes, asserted as the + invariants that make them safe rather than left as untested lines. + + Mutation testing reports each of them as replaceable, and each really is - + but only because something else holds. They are kept as written because the + TypeScript is written that way, and a reader comparing the two files should + find the same expression, not a locally-simplified one. + """ + + def test_the_clamp_bounds_are_ordered(self) -> None: + """Why ``min(high, max(low, v))`` and ``max(low, min(high, v))`` agree. + + They agree for every ``v`` exactly when ``low <= high``, and both call + sites pass literals that satisfy it - ``0`` to ``0.4`` for the black + point, ``0.7`` to ``1.1`` for the white. So the order of the two calls + is unobservable, and this is the fact that makes it so. + """ + assert 0 <= 0.4 + assert 0.7 <= 1.1 + + def test_despeckling_leaves_alpha_alone(self, pages: dict[str, Raster]) -> None: + """Why taking alpha from the despeckled pixels or the raw ones is the same. + + ``despeckle`` copies the alpha channel rather than filtering it - a + median of alpha would invent edges where a page is transparent - so the + two are equal for every page, and the choice cannot be tested. It is + this that makes it safe. + """ + page = pages["small"] + filtered = median_filter(page.pixels, page.width, page.height, 1) + + assert numpy.array_equal(filtered[:, :, 3], page.pixels[:, :, 3]) + + def test_the_background_radius_never_reaches_the_blur_floor( + self, pages: dict[str, Raster] + ) -> None: + """Why the blur's own ``max(1, ...)`` cannot fire. + + The background radius is already floored at 4 before it is handed to the + blur, so the blur's own floor of 1 is unreachable through + ``enhance_raster`` - defensive code mirroring the TypeScript's, not a + branch with a case behind it. Asserted on the smallest page here, + because the smaller the page the smaller the computed radius. + """ + page = pages["a-small-mark"] + fraction = DEFAULT_ENHANCE_OPTIONS.background_fraction + + assert max(4, _js_round(min(page.width, page.height) * fraction)) >= 4 diff --git a/tools/parity/goldens/scan-illumination.json b/tools/parity/goldens/scan-illumination.json new file mode 100644 index 0000000..36eea39 --- /dev/null +++ b/tools/parity/goldens/scan-illumination.json @@ -0,0 +1,1103 @@ +{ + "pages": { + "small": { + "width": 120, + "height": 90, + "seed": 4 + }, + "wide": { + "width": 200, + "height": 80, + "seed": 9 + } + }, + "builtPages": { + "uniform-200": { + "width": 24, + "height": 24, + "grey": 200, + "rectValue": 0 + }, + "a-small-mark": { + "width": 32, + "height": 24, + "grey": 230, + "rect": [ + 6, + 6, + 8, + 8 + ], + "rectValue": 20 + }, + "a-medium-mark": { + "width": 40, + "height": 40, + "grey": 230, + "rect": [ + 10, + 10, + 12, + 12 + ], + "rectValue": 20 + }, + "one-lit-on-black": { + "width": 32, + "height": 32, + "grey": 0, + "rect": [ + 16, + 16, + 1, + 1 + ], + "rectValue": 1 + } + }, + "defaultOptions": { + "sharpen": false, + "backgroundFraction": 0.0625, + "whitePoint": "auto", + "blackPoint": "auto", + "mode": "color", + "despeckle": "auto", + "despeckleThreshold": 0.01, + "despeckleRadius": 1 + }, + "enhanceRaster": { + "defaults": { + "applied": { + "whitePoint": 1.1, + "blackPoint": 0.11764705882352941, + "mode": "color", + "despeckled": true, + "noiseSigma": 0.048178951530111924, + "sharpened": false + }, + "sha256": "bf6ca2046c70f1a990cdb883fd70c5d07843471aea8dca9cc9b1146259ad4f0b", + "samples": [ + [ + 229, + 229, + 229, + 255 + ], + [ + 233, + 233, + 233, + 255 + ], + [ + 233, + 233, + 233, + 255 + ], + [ + 229, + 229, + 229, + 255 + ] + ] + }, + "grayscale": { + "applied": { + "whitePoint": 1.1, + "blackPoint": 0.11764705882352941, + "mode": "grayscale", + "despeckled": true, + "noiseSigma": 0.048178951530111924, + "sharpened": false + }, + "sha256": "bf6ca2046c70f1a990cdb883fd70c5d07843471aea8dca9cc9b1146259ad4f0b", + "samples": [ + [ + 229, + 229, + 229, + 255 + ], + [ + 233, + 233, + 233, + 255 + ], + [ + 233, + 233, + 233, + 255 + ], + [ + 229, + 229, + 229, + 255 + ] + ] + }, + "fixed-points": { + "applied": { + "whitePoint": 0.95, + "blackPoint": 0.2, + "mode": "color", + "despeckled": true, + "noiseSigma": 0.048178951530111924, + "sharpened": false + }, + "sha256": "03386f2959b0abd9171675002b0b6e11e8037c2519ecf948c385edbcbb0cd96b", + "samples": [ + [ + 255, + 255, + 255, + 255 + ], + [ + 255, + 255, + 255, + 255 + ], + [ + 255, + 255, + 255, + 255 + ], + [ + 255, + 255, + 255, + 255 + ] + ] + }, + "one-auto-point": { + "applied": { + "whitePoint": 0.95, + "blackPoint": 0.11764705882352941, + "mode": "color", + "despeckled": true, + "noiseSigma": 0.048178951530111924, + "sharpened": false + }, + "sha256": "f71885c5b43ce35c1ef2fdae31341561473765c2c5173f8afad48a5dba95f4b9", + "samples": [ + [ + 255, + 255, + 255, + 255 + ], + [ + 255, + 255, + 255, + 255 + ], + [ + 255, + 255, + 255, + 255 + ], + [ + 255, + 255, + 255, + 255 + ] + ] + }, + "no-despeckle": { + "applied": { + "whitePoint": 1.1, + "blackPoint": 0.12352941176470589, + "mode": "color", + "despeckled": false, + "noiseSigma": null, + "sharpened": false + }, + "sha256": "769df9ac34f7a71f7cb4e80204cee732cac68f7d6f5ecc054b3afb953ac8a740", + "samples": [ + [ + 234, + 234, + 234, + 255 + ], + [ + 238, + 238, + 238, + 255 + ], + [ + 238, + 238, + 238, + 255 + ], + [ + 237, + 237, + 237, + 255 + ] + ] + }, + "forced-despeckle": { + "applied": { + "whitePoint": 1.1, + "blackPoint": 0.11764705882352941, + "mode": "color", + "despeckled": true, + "noiseSigma": null, + "sharpened": false + }, + "sha256": "bf6ca2046c70f1a990cdb883fd70c5d07843471aea8dca9cc9b1146259ad4f0b", + "samples": [ + [ + 229, + 229, + 229, + 255 + ], + [ + 233, + 233, + 233, + 255 + ], + [ + 233, + 233, + 233, + 255 + ], + [ + 229, + 229, + 229, + 255 + ] + ] + }, + "a-wider-background": { + "applied": { + "whitePoint": 1.1, + "blackPoint": 0.1, + "mode": "color", + "despeckled": true, + "noiseSigma": 0.048178951530111924, + "sharpened": false + }, + "sha256": "7f73f9ffacef47833873808d1a3e110d1add5b0db782ba47c2135b007b9d6899", + "samples": [ + [ + 255, + 255, + 255, + 255 + ], + [ + 255, + 255, + 255, + 255 + ], + [ + 255, + 255, + 255, + 255 + ], + [ + 255, + 255, + 255, + 255 + ] + ] + }, + "sharpened": { + "applied": { + "whitePoint": 1.1, + "blackPoint": 0.11764705882352941, + "mode": "color", + "despeckled": true, + "noiseSigma": 0.048178951530111924, + "sharpened": { + "sigma": 2 + } + }, + "sha256": "d8432eff19e9b07c3f6d312660291671edeaa0d528124dc0fab08749039d8966", + "samples": [ + [ + 206, + 206, + 206, + 255 + ], + [ + 215, + 215, + 215, + 255 + ], + [ + 216, + 216, + 216, + 255 + ], + [ + 217, + 217, + 217, + 255 + ] + ] + }, + "sharpen-rounds-up": { + "applied": { + "whitePoint": 1.1, + "blackPoint": 0.11764705882352941, + "mode": "color", + "despeckled": true, + "noiseSigma": 0.048178951530111924, + "sharpened": { + "sigma": 1.6 + } + }, + "sha256": "d8432eff19e9b07c3f6d312660291671edeaa0d528124dc0fab08749039d8966", + "samples": [ + [ + 206, + 206, + 206, + 255 + ], + [ + 215, + 215, + 215, + 255 + ], + [ + 216, + 216, + 216, + 255 + ], + [ + 217, + 217, + 217, + 255 + ] + ] + }, + "sharpen-rounds-down": { + "applied": { + "whitePoint": 1.1, + "blackPoint": 0.11764705882352941, + "mode": "color", + "despeckled": true, + "noiseSigma": 0.048178951530111924, + "sharpened": { + "sigma": 1.4 + } + }, + "sha256": "69d47f50f3e87c72d837dd833ed0912032148fb17db68d54c58b4ac9f4e74f2f", + "samples": [ + [ + 216, + 216, + 216, + 255 + ], + [ + 222, + 222, + 222, + 255 + ], + [ + 226, + 226, + 226, + 255 + ], + [ + 222, + 222, + 222, + 255 + ] + ] + }, + "sharpen-off-by-sigma": { + "applied": { + "whitePoint": 1.1, + "blackPoint": 0.11764705882352941, + "mode": "color", + "despeckled": true, + "noiseSigma": 0.048178951530111924, + "sharpened": { + "sigma": 0 + } + }, + "sha256": "bf6ca2046c70f1a990cdb883fd70c5d07843471aea8dca9cc9b1146259ad4f0b", + "samples": [ + [ + 229, + 229, + 229, + 255 + ], + [ + 233, + 233, + 233, + 255 + ], + [ + 233, + 233, + 233, + 255 + ], + [ + 229, + 229, + 229, + 255 + ] + ] + }, + "sharpen-off-by-amount": { + "applied": { + "whitePoint": 1.1, + "blackPoint": 0.11764705882352941, + "mode": "color", + "despeckled": true, + "noiseSigma": 0.048178951530111924, + "sharpened": { + "sigma": 2, + "amount": 0 + } + }, + "sha256": "bf6ca2046c70f1a990cdb883fd70c5d07843471aea8dca9cc9b1146259ad4f0b", + "samples": [ + [ + 229, + 229, + 229, + 255 + ], + [ + 233, + 233, + 233, + 255 + ], + [ + 233, + 233, + 233, + 255 + ], + [ + 229, + 229, + 229, + 255 + ] + ] + }, + "a-wide-page": { + "applied": { + "whitePoint": 1.1, + "blackPoint": 0.12352941176470589, + "mode": "color", + "despeckled": true, + "noiseSigma": 0.05032537842093183, + "sharpened": false + }, + "sha256": "3a63c01a72d3726e0c41030e344894c79afbeeac42b3e299b7327cb24bf0217d", + "samples": [ + [ + 229, + 229, + 229, + 255 + ], + [ + 229, + 229, + 229, + 255 + ], + [ + 229, + 229, + 229, + 255 + ], + [ + 229, + 229, + 229, + 255 + ] + ] + }, + "stretch-half-up": { + "applied": { + "whitePoint": 6, + "blackPoint": 0, + "mode": "grayscale", + "despeckled": false, + "noiseSigma": null, + "sharpened": false + }, + "sha256": "08e44b80e7c85713d0dc198fe3cd48bc2444c7ed34802342a28ac5f4f4ca2e53", + "samples": [ + [ + 43, + 43, + 43, + 255 + ], + [ + 43, + 43, + 43, + 255 + ], + [ + 43, + 43, + 43, + 255 + ], + [ + 43, + 43, + 43, + 255 + ] + ] + }, + "inverted-points": { + "applied": { + "whitePoint": 0.2, + "blackPoint": 0.5, + "mode": "grayscale", + "despeckled": false, + "noiseSigma": null, + "sharpened": false + }, + "sha256": "adea5e0a9361c2700525b2452f81230a0f5c1fb310669e1a5b1d779143b9e297", + "samples": [ + [ + 255, + 255, + 255, + 255 + ], + [ + 255, + 255, + 255, + 255 + ], + [ + 255, + 255, + 255, + 255 + ], + [ + 255, + 255, + 255, + 255 + ] + ] + }, + "a-tiny-page": { + "applied": { + "whitePoint": 1.1, + "blackPoint": 0.12941176470588234, + "mode": "color", + "despeckled": false, + "noiseSigma": null, + "sharpened": false + }, + "sha256": "2b9e90a767256840584f90fa3ba48acd9170df9c86d1c0f48e9fb78f793cad37", + "samples": [ + [ + 229, + 229, + 229, + 255 + ], + [ + 229, + 229, + 229, + 255 + ], + [ + 229, + 229, + 229, + 255 + ], + [ + 229, + 229, + 229, + 255 + ] + ] + }, + "sub-half-sigma": { + "applied": { + "whitePoint": 1.1, + "blackPoint": 0.1411764705882353, + "mode": "color", + "despeckled": false, + "noiseSigma": null, + "sharpened": { + "sigma": 0.4 + } + }, + "sha256": "24621cc1bdde533059399ffe54d54da39b76d2715f841f81471393e45982c58e", + "samples": [ + [ + 228, + 228, + 228, + 255 + ], + [ + 228, + 228, + 228, + 255 + ], + [ + 228, + 228, + 228, + 255 + ], + [ + 228, + 228, + 228, + 255 + ] + ] + }, + "one-lit-pixel": { + "applied": { + "whitePoint": 1, + "blackPoint": 0, + "mode": "color", + "despeckled": false, + "noiseSigma": null, + "sharpened": false + }, + "sha256": "a580f31c7f69a17f18b7b02dd460d3f6398a806279d388f805c566770ee99b8a", + "samples": [ + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ] + ] + }, + "one-lit-pixel-in-grey": { + "applied": { + "whitePoint": 1, + "blackPoint": 0, + "mode": "grayscale", + "despeckled": false, + "noiseSigma": null, + "sharpened": false + }, + "sha256": "a580f31c7f69a17f18b7b02dd460d3f6398a806279d388f805c566770ee99b8a", + "samples": [ + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ] + ] + }, + "one-lit-pixel-wide-span": { + "applied": { + "whitePoint": 100, + "blackPoint": 0, + "mode": "color", + "despeckled": false, + "noiseSigma": null, + "sharpened": false + }, + "sha256": "50c0b7d3a772a1b095c26b9b35b2757384a636968b431fed21ce5a68c90a7a6f", + "samples": [ + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ] + ] + } + }, + "contrastPoints": { + "small": { + "estimated": { + "blackPoint": 0.11176470588235293, + "whitePoint": 1.1 + }, + "resolved": { + "whitePoint": 0.9, + "blackPoint": 0.11176470588235293 + }, + "bothFixed": { + "whitePoint": 0.9, + "blackPoint": 0.2 + } + }, + "wide": { + "estimated": { + "blackPoint": 0.11764705882352941, + "whitePoint": 1.1 + }, + "resolved": { + "whitePoint": 0.9, + "blackPoint": 0.11764705882352941 + }, + "bothFixed": { + "whitePoint": 0.9, + "blackPoint": 0.2 + } + } + }, + "histogramFixtures": { + "a-half-bin-and-two-percentiles": { + "width": 10, + "height": 10, + "background": 0.5, + "greys": [ + 0.125, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.425, + 0.45, + 0.5 + ] + }, + "a-clamped-black-point": { + "width": 10, + "height": 10, + "background": 0.5, + "greys": [ + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.4, + 0.5 + ] + } + }, + "histogramPoints": { + "a-half-bin-and-two-percentiles": { + "estimated": { + "blackPoint": 0.2529411764705882, + "whitePoint": 0.8999999999999999 + }, + "resolved": { + "whitePoint": 0.9, + "blackPoint": 0.2529411764705882 + } + }, + "a-clamped-black-point": { + "estimated": { + "blackPoint": 0.4, + "whitePoint": 0.8 + }, + "resolved": { + "whitePoint": 0.9, + "blackPoint": 0.4 + } + } + }, + "noiseThresholdBoundary": { + "page": "a-medium-mark", + "threshold": 0.001906077669630376, + "applied": { + "whitePoint": 1.1, + "blackPoint": 0.1411764705882353, + "mode": "color", + "despeckled": false, + "noiseSigma": 0.001906077669630376, + "sharpened": false + } + } +} diff --git a/tools/parity/write-goldens.mts b/tools/parity/write-goldens.mts index 909aa8a..4c82a67 100644 --- a/tools/parity/write-goldens.mts +++ b/tools/parity/write-goldens.mts @@ -112,10 +112,15 @@ import { findSignatureFields, verifySignatures } from '../../packages/seal/dist/ import { approximateSearch, bestMatch, + DEFAULT_ENHANCE_OPTIONS, despeckle, + enhanceRaster, + estimateContrastPoints, estimateNoiseSigma, + resolveContrastPoints, wordSpan, } from '../../packages/scan/dist/index.esm.js' +import type { EnhanceOptions } from '../../packages/scan/dist/src/index.d.ts' import { FIGURE_CHARACTERS, TEXT_CHARACTERS, @@ -3640,3 +3645,201 @@ writeFileSync( }, undefined, 2) + '\n', ) process.stdout.write('wrote ' + join(goldenDir, 'scan-noise-reduction.json') + '\n') + +/* `@scanmate/scan`'s illumination correction. */ +const enhancePages = { + small: createSyntheticDocument({ width: 120, height: 90, seed: 4 }), + wide: createSyntheticDocument({ width: 200, height: 80, seed: 9 }), +} as const + +/* + * Pages built by hand rather than generated, because a random document never + * lands on the values these cases exist to pin: an exactly-half rounding, a + * page too small for the background window's own floor, a background mean + * below one. Each is a recipe the Python side follows to the letter. + */ +const BUILT_PAGES = { + 'uniform-200': { width: 24, height: 24, grey: 200, rect: undefined, rectValue: 0 }, + 'a-small-mark': { width: 32, height: 24, grey: 230, rect: [6, 6, 8, 8], rectValue: 20 }, + 'a-medium-mark': { width: 40, height: 40, grey: 230, rect: [10, 10, 12, 12], rectValue: 20 }, + 'one-lit-on-black': { width: 32, height: 32, grey: 0, rect: [16, 16, 1, 1], rectValue: 1 }, +} as const + +function buildPage (spec: typeof BUILT_PAGES[keyof typeof BUILT_PAGES]): Raster { + const page = createRaster(spec.width, spec.height, [spec.grey, spec.grey, spec.grey, 255]) + if (spec.rect !== undefined) { + const [x, y, width, height] = spec.rect + fillRect(page, { x, y, width, height }, spec.rectValue) + } + + return page +} + +const builtPages = Object.fromEntries( + Object.entries(BUILT_PAGES).map(([name, spec]) => [name, buildPage(spec)]), +) as Record + +const enhanceCases: Array<[string, Raster, EnhanceOptions]> = [ + ['defaults', enhancePages.small.raster, {}], + ['grayscale', enhancePages.small.raster, { mode: 'grayscale' }], + ['fixed-points', enhancePages.small.raster, { whitePoint: 0.95, blackPoint: 0.2 }], + ['one-auto-point', enhancePages.small.raster, { whitePoint: 0.95 }], + ['no-despeckle', enhancePages.small.raster, { despeckle: false }], + ['forced-despeckle', enhancePages.small.raster, { despeckle: true }], + ['a-wider-background', enhancePages.small.raster, { backgroundFraction: 1 / 4 }], + ['sharpened', enhancePages.small.raster, { sharpen: { sigma: 2 } }], + /* A sigma that rounds UP to a radius of 2, and one that rounds down. */ + ['sharpen-rounds-up', enhancePages.small.raster, { sharpen: { sigma: 1.6 } }], + ['sharpen-rounds-down', enhancePages.small.raster, { sharpen: { sigma: 1.4 } }], + ['sharpen-off-by-sigma', enhancePages.small.raster, { sharpen: { sigma: 0 } }], + ['sharpen-off-by-amount', enhancePages.small.raster, { sharpen: { sigma: 2, amount: 0 } }], + ['a-wide-page', enhancePages.wide.raster, {}], + /* + * On a uniform page the background mean IS the page, so every ratio is + * exactly 1 and the stretch lands on exactly 255/6 = 42.5. `Math.round` + * takes that UP to 43; the half-to-even rule a Uint8ClampedArray would + * apply takes it to 42. Nothing in a generated document lands on a half, + * which is why a port that used one rounding rule for both places passes + * every other case here. + */ + ['stretch-half-up', builtPages['uniform-200'], { + mode: 'grayscale', whitePoint: 6, blackPoint: 0, despeckle: false, + }], + /* + * A white point BELOW the black point: the span is negative, and the floor + * of 1e-4 is what keeps the page white instead of inverting it. + */ + ['inverted-points', builtPages['uniform-200'], { + mode: 'grayscale', whitePoint: 0.2, blackPoint: 0.5, despeckle: false, + }], + /* 24/16 rounds to 2, so the background window here is the floor of 4, not the fraction. */ + ['a-tiny-page', builtPages['a-small-mark'], { despeckle: false }], + /* 0.4 rounds to 0, so the radius floor of 1 is what makes this sharpen at all. */ + ['sub-half-sigma', builtPages['a-medium-mark'], { + despeckle: false, sharpen: { sigma: 0.4 }, + }], + /* + * One pixel of 1 in a field of 0. The background mean around it is a small + * fraction of one, so what that pixel becomes depends entirely on the + * divisor's floor - 1 in colour mode, 1e-3 in grey. + */ + ['one-lit-pixel', builtPages['one-lit-on-black'], { + despeckle: false, whitePoint: 1, blackPoint: 0, + }], + ['one-lit-pixel-in-grey', builtPages['one-lit-on-black'], { + mode: 'grayscale', despeckle: false, whitePoint: 1, blackPoint: 0, + }], + /* + * The same page with room above ratio 1, so the lit pixel's ratio is + * REPORTED rather than clipped away at 255 - which is what makes the colour + * divisor's floor of 1 visible at all. Its background mean is 1/81, so the + * floor is the whole answer: 1 with it, eighty-one times larger without. + */ + ['one-lit-pixel-wide-span', builtPages['one-lit-on-black'], { + despeckle: false, whitePoint: 100, blackPoint: 0, + }], +] + +const enhanced = enhanceCases.map(([name, raster, options]) => { + const result = enhanceRaster(raster, options) + const out = result.raster.data + + return [name, { + applied: result.applied, + sha256: createHash('sha256').update(Buffer.from(out.buffer, out.byteOffset, out.byteLength)).digest('hex'), + samples: [0, 1, raster.width, raster.width * raster.height - 1] + .map(p => [out[p * 4], out[p * 4 + 1], out[p * 4 + 2], out[p * 4 + 3]]), + }] as const +}) + +/* + * Grey pages with a histogram CHOSEN rather than measured, so each percentile + * bound, the bin rounding and the clamps are each pinned by a case that moves + * when they do. A generated page pins none of them: its percentiles land in + * the middle of a wide block of near-identical ratios, where nudging a bound + * by one changes nothing. + * + * The background is 0.5 throughout, so a ratio is just twice the grey. 0.125 + * gives a ratio of 0.25, whose histogram position is exactly 42.5 - the one + * value where rounding a half up (bin 43) and to even (bin 42) disagree. + */ +const HISTOGRAM_FIXTURES = { + /* + * Ratios, in ascending order of bin: 0.25 once (bin 43), 0.8 ninety-six + * times (136), then 0.85 (145), 0.9 (153) and 1.0 (170) once each. The + * darkest 1% is that single 0.25, so the black point reads the half-bin + * and stays below its 0.4 bound; the cumulative count reaches exactly 1% + * there, so `>=` and `>` part company. The brightest 1% starts at 0.9 and + * the brightest 2% at 0.85, both inside the white bounds, so 0.99 and 0.98 + * give different answers. + */ + 'a-half-bin-and-two-percentiles': { + width: 10, + height: 10, + background: 0.5, + greys: [0.125, ...Array.from({ length: 96 }, () => 0.4), 0.425, 0.45, 0.5], + }, + /* Nothing dark: the raw black point is 0.8, so its 0.4 bound is what answers. */ + 'a-clamped-black-point': { + width: 10, + height: 10, + background: 0.5, + greys: [...Array.from({ length: 99 }, () => 0.4), 0.5], + }, +} as const + +const histogramCases = Object.entries(HISTOGRAM_FIXTURES).map(([name, spec]) => { + const gray = createGray(spec.width, spec.height) + gray.data.set(spec.greys) + const background = createGray(spec.width, spec.height) + background.data.fill(spec.background) + + return [name, { + estimated: estimateContrastPoints(gray, background), + resolved: resolveContrastPoints(0.9, 'auto', gray, background), + }] as const +}) + +const contrastCases = (['small', 'wide'] as const).map(name => { + const gray = toGrayscale(enhancePages[name].raster) + const background = boxBlur(gray, 8) + + return [name, { + estimated: estimateContrastPoints(gray, background), + resolved: resolveContrastPoints(0.9, 'auto', gray, background), + bothFixed: resolveContrastPoints(0.9, 0.2, gray, background), + }] as const +}) + +writeFileSync( + join(goldenDir, 'scan-illumination.json'), + JSON.stringify({ + pages: { + small: { width: 120, height: 90, seed: 4 }, + wide: { width: 200, height: 80, seed: 9 }, + }, + builtPages: BUILT_PAGES, + defaultOptions: DEFAULT_ENHANCE_OPTIONS, + enhanceRaster: Object.fromEntries(enhanced), + contrastPoints: Object.fromEntries(contrastCases), + histogramFixtures: HISTOGRAM_FIXTURES, + histogramPoints: Object.fromEntries(histogramCases), + /* + * The threshold set to EXACTLY the page's own measured noise, where the + * comparison's strictness is the only thing that decides. `>` leaves the + * page alone; `>=` would filter it. Nothing else reaches this boundary - + * a measured statistic never equals a hand-written constant by accident. + */ + noiseThresholdBoundary: (() => { + const page = builtPages['a-medium-mark'] + const threshold = estimateNoiseSigma(toGrayscale(page)) + + return { + page: 'a-medium-mark', + threshold, + applied: enhanceRaster(page, { despeckleThreshold: threshold }).applied, + } + })(), + }, undefined, 2) + '\n', +) +process.stdout.write('wrote ' + join(goldenDir, 'scan-illumination.json') + '\n')