Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
49 changes: 44 additions & 5 deletions python-packages/scanmate-scan/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
24 changes: 24 additions & 0 deletions python-packages/scanmate-scan/scanmate_scan/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Original file line number Diff line number Diff line change
@@ -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",
]
Original file line number Diff line number Diff line change
@@ -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))
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading