Skip to content
Open
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
125 changes: 125 additions & 0 deletions src/deckle/geometry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"""Line fitting and intersection in image coordinates.

The card's corners are never observed directly — they are rounded, and RFC-001 defines
the corner as the intersection of the four fitted edge lines (the card's *sharp* corner).
So the primitives here are: fit a line to many noisy points, and intersect two lines.

Fits are total-least-squares (perpendicular distance), not y-on-x: a card edge can be
near-vertical, and an ordinary least-squares fit blows up there.
"""

from __future__ import annotations

from collections.abc import Mapping
from dataclasses import dataclass

import numpy as np


@dataclass(frozen=True)
class Line:
"""An infinite line as a point on it plus a unit direction."""

point: np.ndarray # (2,) a point on the line
direction: np.ndarray # (2,) unit vector

def normal(self) -> np.ndarray:
return np.array([-self.direction[1], self.direction[0]])

def distance(self, pts: np.ndarray) -> np.ndarray:
"""Signed perpendicular distance from each of pts (N,2) to the line."""
return (pts - self.point) @ self.normal()

def angle_deg(self) -> float:
"""Direction angle in degrees, wrapped to (-90, 90]."""
a = np.degrees(np.arctan2(self.direction[1], self.direction[0]))
while a <= -90.0:
a += 180.0
while a > 90.0:
a -= 180.0
return float(a)


@dataclass(frozen=True)
class LineFit:
line: Line
inliers: np.ndarray # (M,2) points that survived trimming
residual_sd_px: float
n_input: int

@property
def n_inliers(self) -> int:
return len(self.inliers)


def fit_line_tls(pts: np.ndarray) -> Line:
"""Total-least-squares line through pts (N,2)."""
if len(pts) < 2:
raise ValueError("need at least 2 points to fit a line")
centroid = pts.mean(axis=0)
_, _, vt = np.linalg.svd(pts - centroid, full_matrices=False)
return Line(point=centroid, direction=vt[0] / np.linalg.norm(vt[0]))


def fit_line_trimmed(pts: np.ndarray, sigma: float = 2.5, iterations: int = 5) -> LineFit:
"""Iteratively-trimmed TLS fit: fit, drop points beyond `sigma` sd, refit.

This is the RANSAC-trim of RFC-001 in its cheap deterministic form. With hundreds of
scanline points per edge and outliers that are individually rare, iterative trimming
converges to the same answer as sampling consensus without the randomness — which
matters because a detector that gives different answers on reruns cannot be regression
tested.
"""
n_input = len(pts)
keep = pts
line = fit_line_tls(keep)
for _ in range(iterations):
d = line.distance(keep)
sd = float(np.std(d))
if sd <= 0.0:
break
mask = np.abs(d) <= sigma * sd
# Never trim away so much that the fit stops being supported.
if mask.sum() < max(8, 0.25 * n_input) or mask.all():
break
keep = keep[mask]
line = fit_line_tls(keep)
return LineFit(
line=line,
inliers=keep,
residual_sd_px=float(np.std(line.distance(keep))),
n_input=n_input,
)


def intersect(a: Line, b: Line) -> np.ndarray:
"""Intersection point of two lines. Raises if they are near-parallel."""
m = np.column_stack((a.direction, -b.direction))
det = float(np.linalg.det(m))
if abs(det) < 1e-9:
raise ValueError("lines are parallel; no intersection")
t = np.linalg.solve(m, b.point - a.point)
return a.point + t[0] * a.direction


def corners_from_lines(lines: Mapping[str, Line]) -> np.ndarray:
"""The (4,2) TL, TR, BR, BL quad enclosed by four named edge lines.

`lines` is keyed "top", "bottom", "left", "right". Two callers want this — the jig
finder turning four fitted window walls into a window, and the detector turning four
fitted card edges into a card — and they want the identical thing, because RFC-001
defines a corner as the intersection of the adjacent fitted lines rather than as
anything observed. Real corners are rounded; there is nothing there to observe.

The winding is load-bearing downstream: `rectify` maps this quad onto the output
rectangle in this order, so TL/TR/BR/BL is part of the contract and not an
implementation detail. One place says what a corner is, and it says it once.
"""
return np.array(
[
intersect(lines["top"], lines["left"]),
intersect(lines["top"], lines["right"]),
intersect(lines["bottom"], lines["right"]),
intersect(lines["bottom"], lines["left"]),
]
)
58 changes: 58 additions & 0 deletions src/deckle/units.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""What the rig is, in numbers — and pixel/millimetre conversion.

Every geometric number deckle reports is in millimetres; every number OpenCV hands
back is in pixels. Keeping the conversion in one place means the scan DPI appears
exactly once per run and the rest of the code never guesses it.

The measured constants live here for the same reason, and it is worth stating because
they did not start out that way. `detect`, `project` and `cli` each carried their own
copy, and they disagreed: the detector defaulted to an aspect of 0.5843 with no
provenance in any doc, while the project file used the measured 0.583 — so `deckle
detect` outside a project was gated against a different card than the same command
inside one. That is the whole argument for one home. A number obtained by holding
calipers against a physical card should appear once.

Every value here is a fact about the scanner, the jig and the deck in front of them,
not a tuning parameter. That is the test for whether something belongs.
"""

from __future__ import annotations

MM_PER_INCH = 25.4

# RFC-001: scan at 600dpi. A 120mm card is 2835px tall, so the whole h750/h1200/h2400
# pyramid falls out of one master with headroom.
DEFAULT_DPI = 600.0

#: Calipers, 2026-08-04, across eight cards. RFC-001 and CLAUDE.md both record it.
DEFAULT_CARD_MM = (70.0, 120.0)

#: Measured, *not* the deck spec's 0.5789 default — the sample deck is genuinely a
#: different shape, and the detector must never quietly substitute the spec figure.
#: Configurable per project; this is only the fallback when nothing says otherwise.
DEFAULT_ASPECT = 0.583

#: How `edges` decides which step along a scanline is the card boundary. Two exist
#: because two optical situations do: with the foam pad the true edge is a hard
#: 150-248 luma step right at the boundary ("brightest"), while pre-pad scans have a
#: shadow ramp in the clearance gap and need the innermost step instead. Which one a
#: scan needs is a fact about how it was taken, so it is pinned here with the rest of
#: the rig rather than inside the fitter.
STRATEGIES = ("brightest", "innermost")
DEFAULT_STRATEGY = "brightest"


def mm_per_px(dpi: float = DEFAULT_DPI) -> float:
return MM_PER_INCH / dpi


def px_to_mm(px: float, dpi: float = DEFAULT_DPI) -> float:
return px * MM_PER_INCH / dpi


def mm_to_px(mm: float, dpi: float = DEFAULT_DPI) -> float:
return mm * dpi / MM_PER_INCH


def mm_to_px_int(mm: float, dpi: float = DEFAULT_DPI) -> int:
return int(round(mm_to_px(mm, dpi)))
105 changes: 105 additions & 0 deletions tests/test_geometry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""Unit tests for the line primitives. These need no scan and always run."""

from __future__ import annotations

import numpy as np
import pytest

from deckle.geometry import corners_from_lines, fit_line_tls, fit_line_trimmed, intersect


def _rect_lines(x0, y0, x1, y1, deg=0.0):
"""The four edge lines of a rectangle, optionally rotated about its centre."""
t = np.radians(deg)
rot = np.array([[np.cos(t), -np.sin(t)], [np.sin(t), np.cos(t)]])
centre = np.array([(x0 + x1) / 2, (y0 + y1) / 2])
corners = np.array([[x0, y0], [x1, y0], [x1, y1], [x0, y1]], dtype=np.float64)
corners = (corners - centre) @ rot.T + centre
tl, tr, br, bl = corners
return corners, {
"top": fit_line_tls(np.array([tl, tr])),
"right": fit_line_tls(np.array([tr, br])),
"bottom": fit_line_tls(np.array([br, bl])),
"left": fit_line_tls(np.array([bl, tl])),
}


def test_fits_a_vertical_line():
"""A y-on-x fit blows up here; a card edge really can be vertical."""
pts = np.column_stack((np.full(50, 7.0), np.linspace(0, 100, 50)))
line = fit_line_tls(pts)
assert abs(abs(line.direction[1]) - 1.0) < 1e-9
assert np.allclose(line.distance(pts), 0.0, atol=1e-9)


def test_recovers_a_known_slope():
x = np.linspace(0, 1000, 500)
pts = np.column_stack((x, 3.0 + 0.01 * x))
assert fit_line_tls(pts).angle_deg() == pytest.approx(np.degrees(np.arctan(0.01)))


def test_trimming_rejects_outliers():
rng = np.random.default_rng(0)
x = np.linspace(0, 1000, 600)
y = 50.0 + rng.normal(0, 0.3, x.size)
y[::37] += 40.0 # a notch-like cluster well off the line
fit = fit_line_trimmed(np.column_stack((x, y)))
assert fit.n_inliers < fit.n_input
assert fit.residual_sd_px < 1.0
assert abs(fit.line.point[1] - 50.0) < 0.5


def test_intersection():
a = fit_line_tls(np.array([[0.0, 0.0], [10.0, 0.0]]))
b = fit_line_tls(np.array([[4.0, -5.0], [4.0, 5.0]]))
assert np.allclose(intersect(a, b), [4.0, 0.0])


def test_parallel_lines_have_no_intersection():
a = fit_line_tls(np.array([[0.0, 0.0], [10.0, 0.0]]))
b = fit_line_tls(np.array([[0.0, 5.0], [10.0, 5.0]]))
with pytest.raises(ValueError):
intersect(a, b)


def test_corners_come_back_wound_tl_tr_br_bl():
"""The winding is a contract: `rectify` maps this quad onto the output in this order."""
expected, lines = _rect_lines(10.0, 20.0, 110.0, 220.0)
assert np.allclose(corners_from_lines(lines), expected)


def test_corners_survive_the_skew_hand_placement_produces():
"""RFC-001 measured up to 1.2deg of skew from placing cards by hand."""
expected, lines = _rect_lines(10.0, 20.0, 110.0, 220.0, deg=1.2)
assert np.allclose(corners_from_lines(lines), expected)


def test_corners_are_the_intersection_not_an_observed_point():
"""Lines fitted from stubs that stop well short of the corner still give the corner.

This is the whole reason the primitive exists -- a card's real corner is rounded and
there is nothing there to sample, so it is only ever recovered by intersecting.
"""
_, lines = _rect_lines(0.0, 0.0, 100.0, 200.0)
stubs = {
"top": fit_line_tls(np.array([[40.0, 0.0], [60.0, 0.0]])),
"right": fit_line_tls(np.array([[100.0, 80.0], [100.0, 120.0]])),
"bottom": fit_line_tls(np.array([[40.0, 200.0], [60.0, 200.0]])),
"left": fit_line_tls(np.array([[0.0, 80.0], [0.0, 120.0]])),
}
assert np.allclose(corners_from_lines(stubs), corners_from_lines(lines))


def test_a_degenerate_quad_raises_rather_than_returning_nonsense():
"""A side edge fitted parallel to a top edge has no corner, and saying so beats
handing back an arbitrary point that later reads as a real measurement."""
_, lines = _rect_lines(0.0, 0.0, 100.0, 200.0)
lines = {**lines, "left": lines["top"]}
with pytest.raises(ValueError):
corners_from_lines(lines)


def test_a_missing_edge_is_not_silently_tolerated():
_, lines = _rect_lines(0.0, 0.0, 100.0, 200.0)
with pytest.raises(KeyError):
corners_from_lines({k: v for k, v in lines.items() if k != "bottom"})
56 changes: 56 additions & 0 deletions tests/test_units.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""The measured constants, and the property that they are measured in one place.

R2 in RFC-005: `detect`, `project` and `cli` each carried their own copy of the card
geometry and two of them disagreed, so the same command was gated against a different
card inside a project than outside one. These tests pin the values against the calipers
CLAUDE.md records. The *consolidation* is checked where the copies used to be -- layer 4
for `detect`, layer 7 for `project`, layer 10 for `cli` -- since those modules do not
exist yet.
"""

from __future__ import annotations

import pytest

from deckle import units


def test_dpi_is_the_one_that_makes_the_height_pyramid_fit():
"""h2400 is the tallest variant the deck spec asks for; 300dpi cannot reach it."""
assert units.DEFAULT_DPI == 600.0
assert units.mm_to_px(120.0) == pytest.approx(2834.6, abs=0.1)
assert units.mm_to_px(120.0) > 2400


def test_card_size_is_the_calipered_one():
assert units.DEFAULT_CARD_MM == (70.0, 120.0)


def test_aspect_is_measured_and_not_the_spec_default():
"""0.5789 is the deck spec's default and is *not* this deck. Substituting it would
put every card 0.6mm out on the long side and the detector would be right to fail."""
assert units.DEFAULT_ASPECT == 0.583
assert pytest.approx(0.5789, abs=1e-4) != units.DEFAULT_ASPECT


def test_aspect_agrees_with_the_card_size_it_sits_beside():
"""The two are independent constants that describe one card, so they can drift apart.
70/120 is 0.5833; the measured 0.583 is that, rounded."""
w, h = units.DEFAULT_CARD_MM
assert pytest.approx(w / h, abs=5e-4) == units.DEFAULT_ASPECT


def test_the_default_strategy_is_one_that_exists():
"""R5 moved the default here while the fitter keeps using it, so the two can drift."""
assert units.DEFAULT_STRATEGY in units.STRATEGIES


def test_the_default_strategy_is_the_one_the_foam_pad_calls_for():
"""The pad is mandatory equipment now (RFC-001), so "brightest" is the live path and
"innermost" is kept only for the pre-pad scans the suite still pins."""
assert units.DEFAULT_STRATEGY == "brightest"


@pytest.mark.parametrize("mm", [0.0, 1.0, 70.0, 120.0, 273.5])
def test_px_and_mm_round_trip(mm):
assert units.px_to_mm(units.mm_to_px(mm)) == pytest.approx(mm)
Loading