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>
109 lines
3.1 KiB
TypeScript
109 lines
3.1 KiB
TypeScript
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)}`,
|
||
}
|
||
}
|