diff --git a/src/deckle/geometry.py b/src/deckle/geometry.py new file mode 100644 index 0000000..db3b097 --- /dev/null +++ b/src/deckle/geometry.py @@ -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"]), + ] + ) diff --git a/src/deckle/units.py b/src/deckle/units.py new file mode 100644 index 0000000..8786a6d --- /dev/null +++ b/src/deckle/units.py @@ -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))) diff --git a/tests/test_geometry.py b/tests/test_geometry.py new file mode 100644 index 0000000..024877d --- /dev/null +++ b/tests/test_geometry.py @@ -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"}) diff --git a/tests/test_units.py b/tests/test_units.py new file mode 100644 index 0000000..9dbf737 --- /dev/null +++ b/tests/test_units.py @@ -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)