Skwik/src/lib/datums.ts
Samuel Prevost b87f933b9e feat(solver): iterative homography solver with circle datums
Replace the two-pass closed-form deskew (getPerspectiveTransform +
per-axis scale corrections) with an alternating-minimisation loop around
cv.findHomography (internal Levenberg–Marquardt). Each outer iteration
recomputes per-datum point correspondences from the current H and the
datum's shape constraint, then findHomography refines H. Confidence
drives per-correspondence replication; primary gets a 3× gauge boost.

- Add EllipseDatum type (center + two conjugate semi-axis endpoints +
  known diameter) with 3-handle Konva rendering and coin presets.
- Generalise primary selection to any datum type. Priority rect >
  ellipse > line; within type, confidence then image size. Warm-start
  anchors: rect = 4 axis-aligned corners; ellipse = 4 conjugate-axis
  samples on a world circle; line = 2 endpoints + 2 synthetic
  perpendicular points (isotropic image-scale assumption).
- Direction-agnostic shape residuals: Procrustes-fit ideal (w × h) rect
  to projected corners; midpoint-preserving line rescale; radial-snap
  ellipse samples to a circle at projectPoint(H, center).
- Drop the "at least one rectangle" requirement. Any datum combination
  works; diagnostics widgets auto-pick a scale reference across types.
- Diagnostics: replace X/Y axis-correction cards with RMS residual +
  iteration count; per-datum table shows a residual breakdown column
  (edge %, perp Δ°, iso/skew/dia).
- Detect period-2 oscillation in the outer loop and warn to console.
- Relative convergence threshold so the affine and perspective entries
  of H are weighted comparably.
- Guard diagnostic diameter via geometric-mean-radius for non-circular
  conics; guard collinear-axes ellipses; fix Mat leak in
  solveHomography on the exception path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:42:40 +02:00

109 lines
3.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { nanoid } from "nanoid"
import type {
CirclePreset,
EllipseDatum,
LineDatum,
Point,
RectDatum,
RectPreset,
} from "@/types"
export const RECT_PRESETS: RectPreset[] = [
{ label: "A3", widthMm: 297, heightMm: 420 },
{ label: "A4", widthMm: 210, heightMm: 297 },
{ label: "A5", widthMm: 148, heightMm: 210 },
{ label: "A6", widthMm: 105, heightMm: 148 },
{ label: "15×10 cm", widthMm: 150, heightMm: 100 },
]
export const CIRCLE_PRESETS: CirclePreset[] = [
{ label: "€1", diameterMm: 23.25 },
{ label: "€2", diameterMm: 25.75 },
{ label: "US 25¢", diameterMm: 24.26 },
{ label: "UK 1p", diameterMm: 20.3 },
{ label: "CD", diameterMm: 120 },
]
const DATUM_COLORS = [
"#3b82f6", // blue
"#ef4444", // red
"#22c55e", // green
"#f59e0b", // amber
"#8b5cf6", // violet
"#ec4899", // pink
"#06b6d4", // cyan
"#f97316", // orange
]
export function getDatumColor(index: number): string {
const color = DATUM_COLORS[index % DATUM_COLORS.length]
if (!color) throw new Error("Unreachable: DATUM_COLORS is non-empty")
return color
}
export function createRectDatum(
center: Point,
index: number,
preset?: RectPreset,
): RectDatum {
const spread = 80
return {
id: nanoid(),
type: "rectangle",
corners: [
{ x: center.x - spread, y: center.y - spread },
{ x: center.x + spread, y: center.y - spread },
{ x: center.x + spread, y: center.y + spread },
{ x: center.x - spread, y: center.y + spread },
],
widthMm: preset?.widthMm ?? 0,
heightMm: preset?.heightMm ?? 0,
confidence: 3,
label: preset?.label ?? `Rectangle ${String(index)}`,
}
}
/**
* Rectangle corners are stored as [TL, TR, BR, BL]. A rectangle is
* "crossed" when a user has dragged corners past each other so the ordering
* no longer holds: top corners must sit above bottom corners (smaller y in
* image coordinates), and right corners must sit right of left corners.
*/
export function isRectCrossed(rect: RectDatum): boolean {
const [tl, tr, br, bl] = rect.corners
return tl.y >= bl.y || tr.y >= br.y || tl.x >= tr.x || bl.x >= br.x
}
export function createLineDatum(center: Point, index: number): LineDatum {
const spread = 100
return {
id: nanoid(),
type: "line",
endpoints: [
{ x: center.x - spread, y: center.y },
{ x: center.x + spread, y: center.y },
],
lengthMm: 0,
confidence: 3,
label: `Line ${String(index)}`,
}
}
export function createEllipseDatum(
center: Point,
index: number,
preset?: CirclePreset,
): EllipseDatum {
const spread = 80
return {
id: nanoid(),
type: "ellipse",
center: { x: center.x, y: center.y },
axisEndA: { x: center.x + spread, y: center.y },
axisEndB: { x: center.x, y: center.y + spread },
diameterMm: preset?.diameterMm ?? 0,
confidence: 3,
label: preset?.label ?? `Circle ${String(index)}`,
}
}