Skip to content

osrforge.tracing.registration

Grid registration: the fit of the printed grid to each region of a map image, and the alignment of two halves.

The tracing stage's second step. A region is one framed drawing on a map image, and registering it means fitting the printed grid to it: one fit over the whole region, with a separate x and y pitch, a shear, and a smooth, low-order correction for scan drift, written out as the pixel position of every grid corner (a Registration). A gate with three parts judges the fit, and a region whose fit fails any part is unregistered. Whether a registered region is refused, for a style the stage can't trace or a scale it can't use, is decided after the whole-map reading, not here.

Two regions printed as the halves of one map on facing pages get a row alignment and three candidate column offsets from their two registrations and the ink along the fold. The reading provider later picks one of the offsets, or none.

The functions here use numpy and Pillow only, call no model, and give the same result for the same image on every run.

BACKGROUND_RADIUS module-attribute

BACKGROUND_RADIUS = 6

Pixels. A pixel's local background is the lightest pixel within this distance of it.

COVERAGE_LINE_SHARE module-attribute

COVERAGE_LINE_SHARE = 0.6

A cell is covered when ink lies under at least this share of each of its two fitted lines.

COVERAGE_MINIMUM module-attribute

COVERAGE_MINIMUM = 0.7

The least share of a region's counted cells that must have ink under the fitted grid lines through them.

Measured with the reference fit of the registration measurement: the committed real maps give 0.857 to 0.996, the synthetic maps over their true grids 0.815 to 1.0, a copy of Chaotic Caves page 47 bowed by 0.3 of a pitch 0.875, and a copy of page 38 stretched by 4% 0.939. Copies of page 47 warped by a sine of 0.45 to 0.8 of a pitch over two periods give 0.38 to 0.55, and the sine of 0.6 over three periods 0.526.

COVERAGE_TOLERANCE module-attribute

COVERAGE_TOLERANCE = 0.15

Pitches. Ink within this distance of a fitted grid line counts as lying under it.

EXPLAINED_MINIMUM module-attribute

EXPLAINED_MINIMUM = 0.9

The least share of detected lines the fit must explain within LINE_TOLERANCE.

FILL_DARKNESS module-attribute

FILL_DARKNESS = 80.0

A pixel whose darkest channel is at most 255 - FILL_DARKNESS is fill or solid ink, not paper.

GAP_TOLERANCE module-attribute

GAP_TOLERANCE = 0.1

Cells. A candidate offset's gap at the fold may be this far below zero, for the fits' error.

INK_MARGIN module-attribute

INK_MARGIN = 20.0

How much darker than its local background a pixel must be to count as ink.

The implementation sets this value. On the synthetic TSR blue maps the blurred grid lines are 16 to 47 darker than the paper beside them, so the reference fit's 35 loses part of every line there. A build from this contract passed every synthetic map at 20: at 15 fold-east fails, and at 25 old-stair-2 finds no region. Whatever the value, the committed maps and the perturbed copies must stay on their sides of the gate.

LINE_TOLERANCE module-attribute

LINE_TOLERANCE = 0.1

Cells. A detected line is explained when it lies within this distance of the fitted line.

OFFSET_CANDIDATES module-attribute

OFFSET_CANDIDATES = 3

How many column offsets align_halves returns for the reading provider to choose among.

REJECT_TOLERANCE module-attribute

REJECT_TOLERANCE = 0.25

Pitches. A detected line farther than this from every fitted line is rejected as not part of the grid (a wall, a stroke of a symbol, or a label). It counts against the explained share all the same.

GateFigures

Bases: BaseModel

The registration gate's figures for one region's fit.

The detected lines are found band by band: the region is cut, along each axis in turn, into bands about three pitches wide (at least three pitches, and at most twelve bands), and each peak of a band's profile of thin ink runs that stands out is one detected line. One printed line that crosses twelve bands is twelve detected lines, and a band with no grid in it contributes none. A detected line within REJECT_TOLERANCE of a pitch of a fitted line gets that line's index. The others are rejected, and stay in the denominator of the explained share.

The gate passes when explained_share is at least EXPLAINED_MINIMUM, index_jumps is 0, and coverage is at least COVERAGE_MINIMUM.

detected_lines class-attribute instance-attribute

detected_lines: int = Field(ge=0)

Every detected line, indexed or rejected, over both axes.

rejected_lines class-attribute instance-attribute

rejected_lines: int = Field(ge=0)

The detected lines the fit rejected.

explained_share class-attribute instance-attribute

explained_share: float = Field(ge=0.0, le=1.0)

The share of detected_lines that lie within LINE_TOLERANCE of the fitted line they're indexed to.

index_jumps class-attribute instance-attribute

index_jumps: int = Field(ge=0)

Pairs of neighbouring indexed lines in one band, with no other indexed line between them, whose index difference isn't their distance in pitches rounded to a whole number. Rejected lines between them don't matter. The jumps catch a numbering that slips along a band: a fitted pitch wrong enough that the lattice drifts a whole index across the band, so two printed lines one pitch apart take indices two apart where the lattice crosses from one line to the next. A pitch taken as half the printed one passes both the jumps and the share, because every printed line lies on that lattice and neighbours two indices apart are two pitches apart. The coverage fails it: half the lattice's lines run over paper.

counted_cells class-attribute instance-attribute

counted_cells: int = Field(ge=0)

The cells of the fitted grid that hold ink and aren't fill. Ink is what register_image defines by INK_MARGIN. A cell is blank when it holds fewer ink pixels, as a count, than three tenths of the mean pitch in pixels, and fill when FILL_DARKNESS or darker covers at least half its pixels. Neither is counted.

covered_cells class-attribute instance-attribute

covered_cells: int = Field(ge=0)

The counted cells whose west and north fitted lines each have ink within COVERAGE_TOLERANCE of a pitch along at least COVERAGE_LINE_SHARE of their length.

coverage class-attribute instance-attribute

coverage: float = Field(ge=0.0, le=1.0)

covered_cells over counted_cells, or 0 when no cell is counted.

passes property

passes: bool

Whether the three parts of the gate pass.

HalfAlignment

Bases: BaseModel

How two halves of one map line up, from their registrations and the ink along the fold.

The fold is the first half's right image edge and the second half's left image edge. The halves share one grid when the second half's first grid line sits a whole number of columns from the first half's grid. Each candidate column offset is that whole number counted past the first half's last column, as HalfPair.columns records it, and its gap is the paper the two scans would leave between them at the fold, in cells. The candidates are the OFFSET_CANDIDATES offsets with the smallest gaps that aren't below -GAP_TOLERANCE, in increasing order.

rows instance-attribute

rows: int

The row shift: the second half's row 0 lies this many rows south of the first half's row 0, as HalfPair.rows records it. The grid phase at the fold gives the fraction of a row, and the whole rows are the shift at which the ink along the fold agrees best between the two halves. Two shifts tie when their correlations lie within 0.01 of each other. On a tie, the shift that puts matching rows at the nearest heights on their two pages wins, and if that ties too, the shift north wins.

columns class-attribute instance-attribute

columns: tuple[int, ...] = Field(min_length=OFFSET_CANDIDATES, max_length=OFFSET_CANDIDATES)

The candidate column offsets, consecutive and increasing.

gaps class-attribute instance-attribute

gaps: tuple[float, ...] = Field(min_length=OFFSET_CANDIDATES, max_length=OFFSET_CANDIDATES)

Each candidate's gap at the fold in cells, increasing, none below -GAP_TOLERANCE.

RegionFit

Bases: BaseModel

One gridded region of a map image, with its fit and the gate's verdict.

The region's rectangle is the framed drawing. Where a frame is drawn, the grid runs from the frame's west line to its east line and from its north line to its south line, so a title, a legend, or a compass outside the frame is no part of the region, and rock inside the frame is. Where no frame is drawn, the grid runs from the first printed grid line to the last on each axis. On a side that the image edge cuts, the grid runs to the outermost fitted line that lies no more than half a pitch outside the image, so a cell at least half inside the image is part of the grid. A legend or a label inside the rectangle doesn't move the fit. Regions are disjoint, one per framed drawing: two candidate grids that reach the same frame are one region.

rect instance-attribute

rect: tuple[int, int, int, int]

(x0, y0, x1, y1) in pixels of the image, the end exclusive.

pitch class-attribute instance-attribute

pitch: tuple[float, float] | None = None

The fitted x and y pitch in pixels, present whenever a fit was made.

verdict instance-attribute

verdict: Literal['registered', 'unregistered']

Whether the fit passed the gate.

reason class-attribute instance-attribute

reason: str | None = None

Why the region is unregistered, or None when registered.

gate class-attribute instance-attribute

gate: GateFigures | None = None

The gate's figures, present whenever a fit was made. A region with too few lines for a fit has none.

registration class-attribute instance-attribute

registration: Registration | None = None

The fitted grid, present exactly when the verdict is registered.

align_halves

align_halves(first: Path, first_fit: RegionFit, second: Path, second_fit: RegionFit) -> HalfAlignment

Align the registered halves of one map across the fold.

The first half is the one cut off at the right edge of its image and the second the one cut off at the left edge of its image, in page order. The column candidates come from the two registrations alone: where the first half's grid reaches its right image edge, and where the second half's first grid line lies from its left image edge. The row shift's fraction comes from the two grids' phases at the fold, and its whole rows from the ink within a cell and a half of the fold on each side, measured from the image edges: the first half's strip against the second half's, over every whole-row shift.

Parameters:

Name Type Description Default
first Path

The first half's image file.

required
first_fit RegionFit

Its registered fit.

required
second Path

The second half's image file.

required
second_fit RegionFit

Its registered fit.

required

Returns:

Type Description
HalfAlignment

The row shift and the candidate column offsets with their gaps.

Raises:

Type Description
ValueError

If either fit is unregistered.

OSError

If an image can't be read.

register_image

register_image(image: Path) -> tuple[RegionFit, ...]

Find the gridded regions of a map image and fit the printed grid to each.

The image is read as RGB. Ink is what's darker than its local background by INK_MARGIN, so a grid line on grey fill counts and the inside of a solid wall doesn't. Each framed drawing with a printed grid is one region, found without any rectangle given by hand, and two drawings on one image at different pitches are two regions with a fit each, because each drawing's pitch is found on its own, not from one pitch estimated over the whole image. A drawing with no grid yields no region, so an image of a gridless map yields an empty tuple.

Each region gets one fit over the whole region: a separate x and y pitch, a shear, and a smooth, low-order correction for scan drift, never a fit tile by tile. The fit is judged by the three parts of the gate (see GateFigures), and the region's registration holds every corner of the fitted grid when the gate passes. A copy of a map bowed by three tenths of a pitch, or stretched by 4% from top to bottom, registers, because the drift correction follows it. A copy with part of its grid shifted by half a pitch doesn't. The gate has a limit: a warp whose crests and troughs lie about half a pitch apart can register on a lattice shifted by half a pitch, with corners about half a cell off, and no part of the gate catches it.

Parameters:

Name Type Description Default
image Path

The image file, as the workdir's maps/ directory stores it or as the test assets commit it.

required

Returns:

Type Description
RegionFit

The regions in page order: by the top of their rectangles, then by the

...

left. The same image gives the same result on every run.

Raises:

Type Description
OSError

If the image can't be read.