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>
This commit is contained in:
Samuel Prevost 2026-04-24 17:42:40 +02:00
parent a71c8c73ef
commit b87f933b9e
9 changed files with 1293 additions and 471 deletions

View File

@ -50,23 +50,34 @@ function datumIndex(datum: Datum): number {
return store.datums.findIndex((d) => d.id === datum.id)
}
function datumPoints(datum: Datum): Point[] {
if (datum.type === "rectangle") return datum.corners as unknown as Point[]
if (datum.type === "line") return datum.endpoints as unknown as Point[]
return [datum.center, datum.axisEndA, datum.axisEndB]
}
function getPointConfigs(datum: Datum, dIdx: number) {
const color = getDatumColor(dIdx)
const isSelected = store.selectedDatumId === datum.id
const points = datum.type === "rectangle" ? datum.corners : datum.endpoints
const points = datumPoints(datum)
const baseRadius = isSelected ? 6 : 4
const visualRadius = Math.max(
baseRadius / scale.value,
baseRadius * 0.5
baseRadius * 0.5,
)
return points.map((pt, pIdx) => ({
// Ellipse center (index 0) is visually bigger + hollow to distinguish it
x: pt.x,
y: pt.y,
radius: visualRadius,
fill: color,
radius:
datum.type === "ellipse" && pIdx === 0
? visualRadius * 1.4
: visualRadius,
fill: datum.type === "ellipse" && pIdx === 0 ? "transparent" : color,
stroke: isSelected ? "#fff" : color,
strokeWidth: 1.5 / scale.value,
strokeWidth: (datum.type === "ellipse" && pIdx === 0 ? 2.5 : 1.5) /
scale.value,
draggable: true,
_datumId: datum.id,
_pointIndex: pIdx,
@ -74,9 +85,30 @@ function getPointConfigs(datum: Datum, dIdx: number) {
}))
}
function ellipseCurvePoints(datum: Datum & { type: "ellipse" }): number[] {
const vAx = datum.axisEndA.x - datum.center.x
const vAy = datum.axisEndA.y - datum.center.y
const vBx = datum.axisEndB.x - datum.center.x
const vBy = datum.axisEndB.y - datum.center.y
const N = 72
const pts: number[] = []
for (let i = 0; i <= N; i++) {
const t = (2 * Math.PI * i) / N
const cs = Math.cos(t)
const sn = Math.sin(t)
pts.push(
datum.center.x + vAx * cs + vBx * sn,
datum.center.y + vAy * cs + vBy * sn,
)
}
return pts
}
function getLineConfigs(datum: Datum, dIdx: number) {
const color = getDatumColor(dIdx)
const isSelected = store.selectedDatumId === datum.id
const dash = isSelected ? [] : [8 / scale.value, 4 / scale.value]
const strokeWidth = (isSelected ? 3 : 2) / scale.value
if (datum.type === "line") {
return [
@ -88,22 +120,55 @@ function getLineConfigs(datum: Datum, dIdx: number) {
datum.endpoints[1].y,
],
stroke: color,
strokeWidth: (isSelected ? 3 : 2) / scale.value,
dash: isSelected ? [] : [8 / scale.value, 4 / scale.value],
strokeWidth,
dash,
},
]
}
// Rectangle: draw 4 edges
const c = datum.corners
const pts = [c[0], c[1], c[2], c[3], c[0]].flatMap((p) => [p.x, p.y])
if (datum.type === "rectangle") {
const c = datum.corners
const pts = [c[0], c[1], c[2], c[3], c[0]].flatMap((p) => [p.x, p.y])
return [
{
points: pts,
stroke: color,
strokeWidth,
closed: true,
dash,
},
]
}
// Ellipse: sampled curve + two thin axis lines for visual reference
return [
{
points: pts,
points: ellipseCurvePoints(datum),
stroke: color,
strokeWidth: (isSelected ? 3 : 2) / scale.value,
closed: true,
dash: isSelected ? [] : [8 / scale.value, 4 / scale.value],
strokeWidth,
dash,
},
{
points: [
datum.center.x,
datum.center.y,
datum.axisEndA.x,
datum.axisEndA.y,
],
stroke: color,
strokeWidth: 1 / scale.value,
opacity: 0.5,
},
{
points: [
datum.center.x,
datum.center.y,
datum.axisEndB.x,
datum.axisEndB.y,
],
stroke: color,
strokeWidth: 1 / scale.value,
opacity: 0.5,
},
]
}
@ -117,13 +182,18 @@ function getLabelConfig(datum: Datum, dIdx: number) {
x: (datum.corners[0].x + datum.corners[2].x) / 2,
y: (datum.corners[0].y + datum.corners[2].y) / 2,
}
} else {
} else if (datum.type === "line") {
pos = {
x: (datum.endpoints[0].x + datum.endpoints[1].x) / 2,
y:
(datum.endpoints[0].y + datum.endpoints[1].y) / 2 -
20 / scale.value,
}
} else {
pos = {
x: datum.center.x,
y: datum.center.y - 20 / scale.value,
}
}
return {
@ -155,10 +225,29 @@ function onPointDragMove(e: {
const newCorners = [...datum.corners] as [Point, Point, Point, Point]
newCorners[_pointIndex] = newPos
store.updateDatum(_datumId, { corners: newCorners })
} else {
} else if (datum.type === "line") {
const newEndpoints = [...datum.endpoints] as [Point, Point]
newEndpoints[_pointIndex] = newPos
store.updateDatum(_datumId, { endpoints: newEndpoints })
} else if (_pointIndex === 0) {
// Ellipse center translate all three handles together
const dx = newPos.x - datum.center.x
const dy = newPos.y - datum.center.y
store.updateDatum(_datumId, {
center: newPos,
axisEndA: {
x: datum.axisEndA.x + dx,
y: datum.axisEndA.y + dy,
},
axisEndB: {
x: datum.axisEndB.x + dx,
y: datum.axisEndB.y + dy,
},
})
} else if (_pointIndex === 1) {
store.updateDatum(_datumId, { axisEndA: newPos })
} else {
store.updateDatum(_datumId, { axisEndB: newPos })
}
}

View File

@ -33,7 +33,8 @@ const canvasHeight = computed(() =>
const incompleteDatums = computed(() =>
store.datums.filter((d) => {
if (d.type === "rectangle") return d.widthMm <= 0 || d.heightMm <= 0
return d.lengthMm <= 0
if (d.type === "line") return d.lengthMm <= 0
return d.diameterMm <= 0
}),
)

View File

@ -2,8 +2,10 @@
import { useAppStore } from "@/stores/app"
import {
RECT_PRESETS,
CIRCLE_PRESETS,
createRectDatum,
createLineDatum,
createEllipseDatum,
getDatumColor,
} from "@/lib/datums"
import type { ConfidenceScore, Datum, RectDatum } from "@/types"
@ -31,6 +33,10 @@ function nextLineIndex(): number {
return store.datums.filter((d) => d.type === "line").length + 1
}
function nextEllipseIndex(): number {
return store.datums.filter((d) => d.type === "ellipse").length + 1
}
function addRect(presetLabel?: string) {
const preset = presetLabel
? RECT_PRESETS.find((p) => p.label === presetLabel)
@ -42,6 +48,15 @@ function addLine() {
store.addDatum(createLineDatum(imageCenter(), nextLineIndex()))
}
function addCircle(presetLabel?: string) {
const preset = presetLabel
? CIRCLE_PRESETS.find((p) => p.label === presetLabel)
: undefined
store.addDatum(
createEllipseDatum(imageCenter(), nextEllipseIndex(), preset),
)
}
function updateField(datum: Datum, field: string, value: string | number) {
store.updateDatum(datum.id, { [field]: value })
}
@ -65,7 +80,16 @@ function formatDimensions(datum: Datum): string {
if (datum.type === "rectangle") {
return `${String(datum.widthMm)} \u00D7 ${String(datum.heightMm)} mm`
}
return `${String(datum.lengthMm)} mm`
if (datum.type === "line") {
return `${String(datum.lengthMm)} mm`
}
return `${String(datum.diameterMm)} mm`
}
function typeBadge(datum: Datum): string {
if (datum.type === "rectangle") return "Rect"
if (datum.type === "line") return "Line"
return "Circle"
}
</script>
@ -77,14 +101,14 @@ function formatDimensions(datum: Datum): string {
<CardTitle class="text-sm">Add Datum</CardTitle>
</CardHeader>
<CardContent class="space-y-3">
<div class="grid grid-cols-2 gap-2">
<div class="grid grid-cols-3 gap-2">
<Button
variant="outline"
size="sm"
class="w-full"
@click="addRect()"
>
+ Rectangle
+ Rect
</Button>
<Button
variant="outline"
@ -94,6 +118,14 @@ function formatDimensions(datum: Datum): string {
>
+ Line
</Button>
<Button
variant="outline"
size="sm"
class="w-full"
@click="addCircle()"
>
+ Circle
</Button>
</div>
<div class="flex flex-wrap gap-1.5">
<Button
@ -106,6 +138,16 @@ function formatDimensions(datum: Datum): string {
>
{{ preset.label }}
</Button>
<Button
v-for="preset in CIRCLE_PRESETS"
:key="`circle-${preset.label}`"
variant="secondary"
size="sm"
class="h-7 text-xs"
@click="addCircle(preset.label)"
>
{{ preset.label }}
</Button>
</div>
</CardContent>
</Card>
@ -147,9 +189,7 @@ function formatDimensions(datum: Datum): string {
:style="{ backgroundColor: getDatumColor(idx) }"
/>
<Badge variant="outline" class="text-xs">
{{
datum.type === "rectangle" ? "Rect" : "Line"
}}
{{ typeBadge(datum) }}
</Badge>
<span class="text-xs text-muted-foreground">{{
formatDimensions(datum)
@ -262,7 +302,7 @@ function formatDimensions(datum: Datum): string {
/>
</div>
</div>
<div v-else>
<div v-else-if="datum.type === 'line'">
<Label class="text-xs">Length (mm)</Label>
<Input
:model-value="String(datum.lengthMm)"
@ -276,6 +316,21 @@ function formatDimensions(datum: Datum): string {
@click.stop
/>
</div>
<div v-else>
<Label class="text-xs">Diameter (mm)</Label>
<Input
:model-value="String(datum.diameterMm)"
type="number"
min="1"
step="0.01"
class="mt-1 h-8 text-sm"
@update:model-value="
(v: string | number) =>
updateField(datum, 'diameterMm', Number(v))
"
@click.stop
/>
</div>
<!-- Confidence -->
<div>

View File

@ -2,7 +2,7 @@
import { ref, computed, onMounted, watch } from "vue"
import { useAppStore } from "@/stores/app"
import { deskewImage, waitForOpenCV } from "@/lib/deskew"
import type { RectDatum } from "@/types"
import type { Datum } from "@/types"
import { DEFAULT_SCALE_PX_PER_MM } from "@/types"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@ -59,39 +59,67 @@ watch(scaleInput, (v) => {
const MAX_AUTO_SCALE_DIM = 8192
/** Estimate the image-pixels-per-mm implied by a single datum. Picks the
* best datum by type priority (rect > line > ellipse) and then confidence.
* Returns null if no datum gives a usable scale. */
function pickScaleRef(): { srcPxPerMm: number } | null {
const best = [...store.datums].sort((a, b) => {
const rank = (d: Datum) =>
d.type === "rectangle" ? 0 : d.type === "ellipse" ? 1 : 2
const r = rank(a) - rank(b)
if (r !== 0) return r
return b.confidence - a.confidence
})[0]
if (!best) return null
if (best.type === "rectangle") {
if (best.widthMm <= 0 || best.heightMm <= 0) return null
const c = best.corners
const srcW = Math.max(
Math.hypot(c[1].x - c[0].x, c[1].y - c[0].y),
Math.hypot(c[2].x - c[3].x, c[2].y - c[3].y),
)
const srcH = Math.max(
Math.hypot(c[3].x - c[0].x, c[3].y - c[0].y),
Math.hypot(c[2].x - c[1].x, c[2].y - c[1].y),
)
const sx = srcW / best.widthMm
const sy = srcH / best.heightMm
return { srcPxPerMm: Math.max(sx, sy) }
}
if (best.type === "line") {
if (best.lengthMm <= 0) return null
const L = Math.hypot(
best.endpoints[1].x - best.endpoints[0].x,
best.endpoints[1].y - best.endpoints[0].y,
)
return { srcPxPerMm: L / best.lengthMm }
}
if (best.diameterMm <= 0) return null
// Approximate the ellipse's "diameter" as max of the two semi-axis lengths × 2
const vA = Math.hypot(
best.axisEndA.x - best.center.x,
best.axisEndA.y - best.center.y,
)
const vB = Math.hypot(
best.axisEndB.x - best.center.x,
best.axisEndB.y - best.center.y,
)
return { srcPxPerMm: (2 * Math.max(vA, vB)) / best.diameterMm }
}
function computeAutoScale(): number {
const img = store.loadedImage
const primary = store.datums.find(
(d): d is RectDatum => d.type === "rectangle",
)
if (!img || !primary) return DEFAULT_SCALE_PX_PER_MM
const ref = pickScaleRef()
if (!img || !ref || ref.srcPxPerMm <= 0) return DEFAULT_SCALE_PX_PER_MM
// Approximate source-pixel size of the datum
const c = primary.corners
const datumSrcW = Math.max(
Math.hypot(c[1].x - c[0].x, c[1].y - c[0].y),
Math.hypot(c[2].x - c[3].x, c[2].y - c[3].y),
)
const datumSrcH = Math.max(
Math.hypot(c[3].x - c[0].x, c[3].y - c[0].y),
Math.hypot(c[2].x - c[1].x, c[2].y - c[1].y),
)
// Scale that would make the datum the same pixel size as in source
const sx =
datumSrcW > 0 ? datumSrcW / primary.widthMm : 0
const sy =
datumSrcH > 0 ? datumSrcH / primary.heightMm : 0
let autoScale = Math.max(sx, sy)
let autoScale = ref.srcPxPerMm
// Clamp so the full output doesn't exceed MAX_AUTO_SCALE_DIM
const estW = img.naturalWidth * autoScale / Math.max(datumSrcW / primary.widthMm, 0.001)
const estH = img.naturalHeight * autoScale / Math.max(datumSrcH / primary.heightMm, 0.001)
if (estW > MAX_AUTO_SCALE_DIM || estH > MAX_AUTO_SCALE_DIM) {
autoScale *= MAX_AUTO_SCALE_DIM / Math.max(estW, estH)
const estMax = Math.max(img.naturalWidth, img.naturalHeight)
if (estMax > MAX_AUTO_SCALE_DIM) {
autoScale *= MAX_AUTO_SCALE_DIM / estMax
}
// Round to a clean number
return Math.max(1, Math.round(autoScale * 10) / 10)
}
@ -134,39 +162,18 @@ const progressPercent = computed(() =>
// Estimated output size accounts for full warped image, not just datum
const MAX_RGBA_MB = 512
const estimatedOutput = computed(() => {
const primary = store.datums.find(
(d): d is RectDatum => d.type === "rectangle",
)
const ref = pickScaleRef()
const img = store.loadedImage
if (!primary || !img || store.scalePxPerMm <= 0) return null
if (!ref || !img || store.scalePxPerMm <= 0 || ref.srcPxPerMm <= 0)
return null
// Datum dimensions in output pixels
const datumOutW = primary.widthMm * store.scalePxPerMm
const datumOutH = primary.heightMm * store.scalePxPerMm
// source-pixels-per-mm implied by the datum vs. requested output px/mm
const avgScale = store.scalePxPerMm / ref.srcPxPerMm
// Datum dimensions in source pixels (approximate from corner spread)
const c = primary.corners
const datumSrcW = Math.max(
Math.hypot(c[1].x - c[0].x, c[1].y - c[0].y),
Math.hypot(c[2].x - c[3].x, c[2].y - c[3].y),
)
const datumSrcH = Math.max(
Math.hypot(c[3].x - c[0].x, c[3].y - c[0].y),
Math.hypot(c[2].x - c[1].x, c[2].y - c[1].y),
)
// Scale factor from source to output (per-axis average)
const sx =
datumSrcW > 0 ? datumOutW / datumSrcW : store.scalePxPerMm
const sy =
datumSrcH > 0 ? datumOutH / datumSrcH : store.scalePxPerMm
const avgScale = (sx + sy) / 2
// Estimated full warped output = source image scaled
const w = Math.round(img.naturalWidth * avgScale)
const h = Math.round(img.naturalHeight * avgScale)
const mb = (w * h * 4) / (1024 * 1024)
return { w, h, mb, datumW: Math.round(datumOutW), datumH: Math.round(datumOutH) }
return { w, h, mb }
})
const tooLarge = computed(
@ -343,9 +350,6 @@ async function download() {
URL.revokeObjectURL(url)
}
function hasRects(): boolean {
return store.datums.some((d) => d.type === "rectangle")
}
</script>
<template>
@ -451,7 +455,9 @@ function hasRects(): boolean {
<span class="ml-1 font-mono text-xs">{{
datum.type === "rectangle"
? `${datum.widthMm}\u00D7${datum.heightMm}mm`
: `${datum.lengthMm}mm`
: datum.type === "line"
? `${datum.lengthMm}mm`
: `${datum.diameterMm}mm`
}}</span>
<span class="ml-1 text-muted-foreground"
>conf {{ datum.confidence }}/5</span
@ -459,11 +465,11 @@ function hasRects(): boolean {
</Badge>
</div>
<p
v-if="!hasRects()"
v-if="store.datums.length === 0"
class="mt-3 text-sm text-destructive"
>
At least one rectangle datum is required for perspective
correction.
Add at least one datum (rectangle, line, or circle) to run
the correction.
</p>
</CardContent>
</Card>
@ -474,7 +480,7 @@ function hasRects(): boolean {
size="lg"
:disabled="
store.isProcessing ||
!hasRects() ||
store.datums.length === 0 ||
tooLarge ||
!scaleValid
"
@ -585,7 +591,7 @@ function hasRects(): boolean {
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<!-- Axis corrections -->
<!-- Solver summary -->
<div class="grid grid-cols-2 gap-4">
<div
class="rounded-md border border-border/50 p-3"
@ -593,24 +599,19 @@ function hasRects(): boolean {
<p
class="text-xs font-medium uppercase tracking-wide text-muted-foreground"
>
X-axis correction
Residual (RMS)
</p>
<p class="font-mono text-lg font-semibold">
{{
(
store.deskewResult.diagnostics
.xCorrection.ratio * 100
).toFixed(2)
store.deskewResult.diagnostics.finalRMSPercent.toFixed(
3,
)
}}%
</p>
<p
class="font-mono text-xs text-muted-foreground"
>
w={{
store.deskewResult.diagnostics.xCorrection.totalWeight.toFixed(
1,
)
}}
across all datums
</p>
</div>
<div
@ -619,24 +620,17 @@ function hasRects(): boolean {
<p
class="text-xs font-medium uppercase tracking-wide text-muted-foreground"
>
Y-axis correction
Iterations
</p>
<p class="font-mono text-lg font-semibold">
{{
(
store.deskewResult.diagnostics
.yCorrection.ratio * 100
).toFixed(2)
}}%
store.deskewResult.diagnostics.iterations
}}
</p>
<p
class="font-mono text-xs text-muted-foreground"
>
w={{
store.deskewResult.diagnostics.yCorrection.totalWeight.toFixed(
1,
)
}}
outer alternating passes
</p>
</div>
</div>
@ -661,7 +655,7 @@ function hasRects(): boolean {
<TableHead class="text-right"
>Error</TableHead
>
<TableHead>Axis</TableHead>
<TableHead>Residual breakdown</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@ -681,24 +675,28 @@ function hasRects(): boolean {
>
</TableCell>
<TableCell class="font-mono text-right">{{
report.expectedMm.toFixed(1)
report.expectedMm.toFixed(2)
}}</TableCell>
<TableCell class="font-mono text-right">{{
report.measuredMm.toFixed(1)
report.measuredMm.toFixed(2)
}}</TableCell>
<TableCell
class="font-mono text-right"
:class="
report.errorPercent > 5
? 'text-destructive'
: ''
: report.errorPercent > 1
? 'text-amber-500'
: ''
"
>
{{ report.errorPercent.toFixed(1) }}%
{{ report.errorPercent.toFixed(2) }}%
</TableCell>
<TableCell
class="font-mono text-xs text-muted-foreground"
>
{{ report.details }}
</TableCell>
<TableCell>{{
report.axisContribution
}}</TableCell>
</TableRow>
</TableBody>
</Table>
@ -743,43 +741,49 @@ function hasRects(): boolean {
class="list-decimal space-y-2 text-sm leading-relaxed text-muted-foreground marker:font-semibold marker:text-foreground/60"
>
<li>
The primary rectangle datum
defines a
<strong
class="text-foreground/80"
>homography</strong
>
&mdash; a 3&times;3 projective
transform mapping the
quadrilateral in the source
image to a true rectangle at
the specified real-world
dimensions.
The highest-confidence rectangle
gives a closed-form warm start via
<code
class="rounded bg-muted px-1 py-0.5 font-mono text-xs text-foreground"
>cv::getPerspectiveTransform</code
>, fixing the output
orientation.
</li>
<li>
Secondary datums (additional
rectangles or line segments)
provide
Each datum is turned into
<strong
class="text-foreground/80"
>weighted correction
factors</strong
>shape-based point
correspondences</strong
>
for the X and Y axes. Each
secondary datum's contribution
is weighted by its confidence
score, refining the scale in
each axis independently.
whose target positions are
recomputed from the current
homography on every outer pass:
Procrustes-fit ideal rectangles,
midpoint-preserving line rescales,
and radially-snapped ellipse
samples that force circles to stay
circular.
</li>
<li>
The final correction is applied
as a single
<code
class="rounded bg-muted px-1 py-0.5 font-mono text-xs text-foreground"
>cv::findHomography</code
>
refines the homography by
Levenberg&ndash;Marquardt on those
correspondences; confidence drives
per-datum replication. The loop
stops once the homography stops
moving.
</li>
<li>
A single
<code
class="rounded bg-muted px-1 py-0.5 font-mono text-xs text-foreground"
>cv::warpPerspective</code
>
call via OpenCV WASM, producing
the output image at the
produces the output at the
requested px/mm scale.
</li>
</ol>

View File

@ -1,12 +1,27 @@
import { nanoid } from "nanoid"
import type { LineDatum, Point, RectDatum, RectPreset } from "@/types"
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\u00D710 cm", widthMm: 150, heightMm: 100 },
{ 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 = [
@ -73,3 +88,21 @@ export function createLineDatum(center: Point, index: number): LineDatum {
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)}`,
}
}

View File

@ -1,126 +1,51 @@
/**
* deskew.ts Browser-based perspective correction using OpenCV.js (WASM)
* deskew.ts perspective correction pipeline.
*
* Accepts N datums (rectangles and/or lines), each with known real-world
* dimensions and a confidence score (15). Minimum: one rectangle.
*
* Algorithm:
* 1. Pick the highest-confidence rectangle as primary reference.
* 2. getPerspectiveTransform from its 4 corners initial correction.
* 3. Project all other datums through that transform and measure them.
* 4. Compute per-axis weighted scale corrections from all secondary datums.
* 5. Fold corrections into the destination rectangle, recompute
* getPerspectiveTransform single clean perspective matrix.
* 6. warpPerspective the image.
* Delegates the homography solve to src/lib/solver.ts (alternating
* minimization around cv.findHomography, which internally runs
* Levenberg-Marquardt). This file handles I/O: image loading,
* output-bounds computation, warp, PNG export, progress reporting.
*/
import cv from "@techstark/opencv-js"
import type {
AxisCorrection,
Datum,
DatumReport,
DeskewDiagnostics,
DeskewInput,
DeskewResult,
Point,
RectDatum,
} from "@/types"
import { solveHomographyForDatums, type Mat3 } from "@/lib/solver"
// Max output dimension in pixels to avoid WASM OOM
// 12288 = ~576MB RGBA at square, but actual images are rarely square
const MAX_OUTPUT_DIM = 12288
// ─── OpenCV helpers ──────────────────────────────────────────────────────────
// ─── Small helpers ──────────────────────────────────────────────────────────
function pointsToMat(points: Point[]): InstanceType<typeof cv.Mat> {
const flat = points.flatMap((p) => [p.x, p.y])
return cv.matFromArray(points.length, 1, cv.CV_32FC2, flat)
}
function transformPoints(
points: Point[],
M: InstanceType<typeof cv.Mat>,
): Point[] {
const src = pointsToMat(points)
const dst = new cv.Mat()
cv.perspectiveTransform(src, dst, M)
const result: Point[] = []
const data = dst.data32F
for (let i = 0; i < points.length; i++) {
const x = data[i * 2]
const y = data[i * 2 + 1]
if (x === undefined || y === undefined) continue
result.push({ x, y })
}
src.delete()
dst.delete()
return result
}
function dist(a: Point, b: Point): number {
return Math.hypot(b.x - a.x, b.y - a.y)
}
function readMat3x3(M: InstanceType<typeof cv.Mat>): number[] {
const d: number[] = []
for (let r = 0; r < 3; r++) {
for (let c = 0; c < 3; c++) {
d.push(M.doubleAt(r, c))
function projectPoints(h: Mat3, pts: Point[]): Point[] {
return pts.map((p) => {
const w = h[6] * p.x + h[7] * p.y + h[8]
return {
x: (h[0] * p.x + h[1] * p.y + h[2]) / w,
y: (h[3] * p.x + h[4] * p.y + h[5]) / w,
}
}
return d
})
}
/** Row-major 3x3 matrix multiply */
function mul3x3(A: number[], B: number[]): number[] {
const R = Array<number>(9).fill(0)
function mul3x3(A: Mat3, B: Mat3): Mat3 {
const R: number[] = Array<number>(9).fill(0)
for (let r = 0; r < 3; r++) {
for (let c = 0; c < 3; c++) {
let sum = 0
for (let k = 0; k < 3; k++) {
sum +=
(A[r * 3 + k] ?? 0) * (B[k * 3 + c] ?? 0)
sum += (A[r * 3 + k] ?? 0) * (B[k * 3 + c] ?? 0)
}
R[r * 3 + c] = sum
}
}
return R
return R as Mat3
}
// ─── Validation ──────────────────────────────────────────────────────────────
function pickPrimary(datums: Datum[]): RectDatum {
const rects = datums.filter(
(d): d is RectDatum => d.type === "rectangle",
)
if (rects.length === 0) {
throw new Error(
"At least one rectangle datum is required for perspective correction.",
)
}
rects.sort((a, b) => {
if (b.confidence !== a.confidence)
return b.confidence - a.confidence
const area = (r: RectDatum) =>
dist(r.corners[0], r.corners[1]) *
dist(r.corners[0], r.corners[3])
return area(b) - area(a)
})
return rects[0] as RectDatum
}
/**
* Convert our app corner order (TL, TR, BR, BL) to the algorithm's
* expected order (TL, TR, BL, BR) for getPerspectiveTransform.
*/
function cornersToAlgoOrder(
corners: [Point, Point, Point, Point],
): [Point, Point, Point, Point] {
return [corners[0], corners[1], corners[3], corners[2]]
}
// ─── Canvas → Blob helper ───────────────────────────────────────────────────
function canvasToBlob(
canvas: HTMLCanvasElement,
type = "image/png",
@ -138,33 +63,32 @@ function canvasToBlob(
})
}
// ─── Core ────────────────────────────────────────────────────────────────────
const log = (tag: string, ...args: unknown[]) => {
console.log(`[deskew:${tag}]`, ...args)
}
// ─── Core ───────────────────────────────────────────────────────────────────
export async function deskewImage(
input: DeskewInput,
): Promise<DeskewResult> {
const { image, datums, scalePxPerMm: scale, onProgress } = input
log("start", `${String(datums.length)} datums, scale=${String(scale)} px/mm`)
log(
"start",
`${String(datums.length)} datums, scale=${String(scale)} px/mm`,
)
const TOTAL_STEPS = 7
const TOTAL_STEPS = 5
const progress = async (step: number, label: string) => {
log(`progress`, `[${String(step + 1)}/${String(TOTAL_STEPS)}] ${label}`)
log("progress", `[${String(step + 1)}/${String(TOTAL_STEPS)}] ${label}`)
onProgress?.(step, TOTAL_STEPS, label)
// Yield to let the browser repaint
await new Promise((r) => {
requestAnimationFrame(r)
})
}
if (datums.length === 0) throw new Error("No datums provided.")
const primary = pickPrimary(datums)
log("primary", primary.label, `${String(primary.widthMm)}×${String(primary.heightMm)}mm`, `conf=${String(primary.confidence)}`)
// Load source image into OpenCV
// Load source image into a canvas
let srcCanvas: HTMLCanvasElement
if (image instanceof HTMLCanvasElement) {
srcCanvas = image
@ -173,7 +97,10 @@ export async function deskewImage(
srcCanvas = document.createElement("canvas")
srcCanvas.width = image.naturalWidth
srcCanvas.height = image.naturalHeight
log("input", `img ${String(image.naturalWidth)}×${String(image.naturalHeight)}, drawing to canvas`)
log(
"input",
`img ${String(image.naturalWidth)}×${String(image.naturalHeight)}`,
)
const ctx = srcCanvas.getContext("2d")
if (!ctx) throw new Error("Failed to get 2d context")
ctx.drawImage(image, 0, 0)
@ -181,7 +108,6 @@ export async function deskewImage(
await progress(0, "Loading image into OpenCV")
// All OpenCV mats to clean up
const mats: InstanceType<typeof cv.Mat>[] = []
const track = <T extends InstanceType<typeof cv.Mat>>(m: T): T => {
mats.push(m)
@ -189,184 +115,41 @@ export async function deskewImage(
}
try {
log("cv.imread", "reading source canvas into cv.Mat")
const src = track(cv.imread(srcCanvas))
const imgW = src.cols
const imgH = src.rows
log("cv.imread", `done: ${String(imgW)}×${String(imgH)}, type=${String(src.type())}, channels=${String(src.channels())}`)
// ============================================================
// STEP 1 — Initial perspective correction from primary rect
// ============================================================
await progress(1, "Computing initial homography")
const pw = primary.widthMm * scale
const ph = primary.heightMm * scale
log("step1", `dest rect: ${pw.toFixed(1)}×${ph.toFixed(1)} px`)
const algoCorners = cornersToAlgoOrder(primary.corners)
log("step1", `corners (algo order): ${JSON.stringify(algoCorners)}`)
const srcPts = track(pointsToMat(algoCorners))
const dstInit = track(
pointsToMat([
{ x: 0, y: 0 },
{ x: pw, y: 0 },
{ x: 0, y: ph },
{ x: pw, y: ph },
]),
log(
"cv.imread",
`${String(imgW)}×${String(imgH)}, channels=${String(src.channels())}`,
)
log("step1", "calling getPerspectiveTransform (initial)")
const mInit = track(
cv.getPerspectiveTransform(srcPts, dstInit),
// ========================================================
// STEP 1 — Solve homography (outer loop around findHomography)
// ========================================================
await progress(1, "Solving homography")
const solved = solveHomographyForDatums(datums, scale)
const H = solved.H
log(
"solve",
`primary=${solved.primaryLabel} (${solved.primaryType}), iters=${String(solved.iterations)}, rms=${solved.rmsPercent.toFixed(3)}%`,
)
log("step1", `mInit type=${String(mInit.type())}, rows=${String(mInit.rows)}, cols=${String(mInit.cols)}`)
// ============================================================
// STEP 2 — Measure secondary datums, accumulate corrections
// ============================================================
await progress(2, "Measuring secondary datums")
let xWSum = 0,
xWTotal = 0
let yWSum = 0,
yWTotal = 0
const reports: DatumReport[] = []
for (const datum of datums) {
const w = datum.confidence
if (datum === primary) {
reports.push({
label: datum.label,
type: "rectangle",
measuredMm: datum.widthMm,
expectedMm: datum.widthMm,
errorPercent: 0,
axisContribution: "both",
})
continue
}
if (datum.type === "line") {
const pts = transformPoints(
datum.endpoints as Point[],
mInit,
)
const s = pts[0]
const e = pts[1]
if (!s || !e) continue
const dx = Math.abs(e.x - s.x)
const dy = Math.abs(e.y - s.y)
const measured = dist(s, e)
const expected = datum.lengthMm * scale
const ratio = expected / measured
const total = dx + dy
if (total > 1e-6) {
const xFrac = dx / total
const yFrac = dy / total
xWSum += ratio * w * xFrac
xWTotal += w * xFrac
yWSum += ratio * w * yFrac
yWTotal += w * yFrac
}
reports.push({
label: datum.label,
type: "line",
measuredMm: measured / scale,
expectedMm: datum.lengthMm,
errorPercent: Math.abs(1 - ratio) * 100,
axisContribution: dx > dy ? "x" : "y",
})
} else {
const ac = cornersToAlgoOrder(datum.corners)
const pts = transformPoints(
[ac[0], ac[1], ac[2]],
mInit,
)
const tl = pts[0]
const tr = pts[1]
const bl = pts[2]
if (!tl || !tr || !bl) continue
const mW = dist(tl, tr)
const mH = dist(tl, bl)
const xR = (datum.widthMm * scale) / mW
const yR = (datum.heightMm * scale) / mH
xWSum += xR * w
xWTotal += w
yWSum += yR * w
yWTotal += w
reports.push({
label: datum.label,
type: "rectangle",
measuredMm: mW / scale,
expectedMm: datum.widthMm,
errorPercent:
(Math.abs(1 - xR) + Math.abs(1 - yR)) * 50,
axisContribution: "both",
})
}
}
// ============================================================
// STEP 3 — Weighted corrections (1.0 = no secondary data)
// ============================================================
await progress(3, "Computing axis corrections")
const xCorr: AxisCorrection = {
ratio: xWTotal > 0 ? xWSum / xWTotal : 1.0,
totalWeight: xWTotal,
}
const yCorr: AxisCorrection = {
ratio: yWTotal > 0 ? yWSum / yWTotal : 1.0,
totalWeight: yWTotal,
}
log("step3", `xCorr=${xCorr.ratio.toFixed(4)} (w=${xCorr.totalWeight.toFixed(1)}), yCorr=${yCorr.ratio.toFixed(4)} (w=${yCorr.totalWeight.toFixed(1)})`)
// ============================================================
// STEP 4 — Fold corrections, recompute transform
// ============================================================
await progress(4, "Recomputing final transform")
const pwFinal = pw * xCorr.ratio
const phFinal = ph * yCorr.ratio
log("step4", `final dest rect: ${pwFinal.toFixed(1)}×${phFinal.toFixed(1)} px`)
const dstFinal = track(
pointsToMat([
{ x: 0, y: 0 },
{ x: pwFinal, y: 0 },
{ x: 0, y: phFinal },
{ x: pwFinal, y: phFinal },
]),
)
log("step4", "calling getPerspectiveTransform (final)")
const mFinal = track(
cv.getPerspectiveTransform(srcPts, dstFinal),
)
log("step4", `mFinal type=${String(mFinal.type())}, rows=${String(mFinal.rows)}, cols=${String(mFinal.cols)}`)
// ============================================================
// STEP 5 — Output bounds + translation shift
// ============================================================
await progress(5, "Computing output bounds")
// ========================================================
// STEP 2 — Compute output bounds and translation shift
// ========================================================
await progress(2, "Computing output bounds")
const imgCorners: Point[] = [
{ x: 0, y: 0 },
{ x: imgW, y: 0 },
{ x: 0, y: imgH },
{ x: imgW, y: imgH },
]
const warped = transformPoints(imgCorners, mFinal)
if (warped.length < 4) {
throw new Error(
"Perspective transform produced invalid bounds",
)
}
const warped = projectPoints(H, imgCorners)
let xMin = Infinity,
yMin = Infinity,
xMax = -Infinity,
yMax = -Infinity
let xMin = Infinity
let yMin = Infinity
let xMax = -Infinity
let yMax = -Infinity
for (const c of warped) {
xMin = Math.min(xMin, c.x)
yMin = Math.min(yMin, c.y)
@ -376,10 +159,10 @@ export async function deskewImage(
let outW = Math.ceil(xMax - xMin)
let outH = Math.ceil(yMax - yMin)
log("step5", `bounds: x=[${xMin.toFixed(1)}, ${xMax.toFixed(1)}], y=[${yMin.toFixed(1)}, ${yMax.toFixed(1)}]`)
log("step5", `raw output: ${String(outW)}×${String(outH)} px`)
// Guard against absurd output sizes that crash WASM
log(
"bounds",
`x=[${xMin.toFixed(1)},${xMax.toFixed(1)}], y=[${yMin.toFixed(1)},${yMax.toFixed(1)}]`,
)
if (outW <= 0 || outH <= 0) {
throw new Error(
`Invalid output dimensions: ${String(outW)}×${String(outH)}`,
@ -388,30 +171,31 @@ export async function deskewImage(
let downscale = 1
if (outW > MAX_OUTPUT_DIM || outH > MAX_OUTPUT_DIM) {
downscale = MAX_OUTPUT_DIM / Math.max(outW, outH)
log("step5", `CLAMPING from ${String(outW)}×${String(outH)} by factor ${downscale.toFixed(4)}`)
outW = Math.ceil(outW * downscale)
outH = Math.ceil(outH * downscale)
log("bounds", `clamped by ${downscale.toFixed(4)}${String(outW)}×${String(outH)}`)
}
log("step5", `final output: ${String(outW)}×${String(outH)} px (${String(Math.round(outW * outH * 4 / 1024 / 1024))} MB RGBA)`)
const mData: number[] = readMat3x3(mFinal)
// Translate so the top-left warped corner is at (0,0),
// then scale down if we clamped the output size.
const tShift: number[] = [
downscale, 0, -xMin * downscale,
0, downscale, -yMin * downscale,
0, 0, 1,
// Compose a shift (translation + optional downscale) with H so the
// top-left corner of the warped image lands at (0, 0).
const tShift: Mat3 = [
downscale,
0,
-xMin * downscale,
0,
downscale,
-yMin * downscale,
0,
0,
1,
]
const mOutData: number[] = mul3x3(tShift, mData)
const mOut = track(
cv.matFromArray(3, 3, cv.CV_64FC1, mOutData),
)
const mOutData = mul3x3(tShift, H)
const mOut = track(cv.matFromArray(3, 3, cv.CV_64FC1, mOutData))
// ============================================================
// STEP 6 — Warp
// ============================================================
await progress(6, "Warping image (this may take a moment)")
log("step6", "calling warpPerspective...")
// ========================================================
// STEP 3 — Warp
// ========================================================
await progress(3, "Warping image")
const dstMat = track(new cv.Mat())
cv.warpPerspective(
src,
@ -423,36 +207,33 @@ export async function deskewImage(
new cv.Scalar(0, 0, 0, 0),
)
log("step6", `warpPerspective done, dstMat: ${String(dstMat.cols)}×${String(dstMat.rows)}, type=${String(dstMat.type())}`)
log("export", "cv.imshow to canvas")
// ========================================================
// STEP 4 — Export
// ========================================================
await progress(4, "Encoding output")
const outCanvas = document.createElement("canvas")
outCanvas.width = outW
outCanvas.height = outH
cv.imshow(outCanvas, dstMat)
log("export", "canvas.toBlob (PNG)")
const blob = await canvasToBlob(outCanvas, "image/png", 0.95)
log("export", `blob size: ${String(Math.round(blob.size / 1024))} KB`)
log("export", `blob ${String(Math.round(blob.size / 1024))} KB`)
const diagnostics: DeskewDiagnostics = {
primaryDatum: primary.label,
xCorrection: xCorr,
yCorrection: yCorr,
perDatum: reports,
primaryDatum: solved.primaryLabel,
iterations: solved.iterations,
finalRMSPercent: solved.rmsPercent,
perDatum: solved.reports,
outputWidthPx: outW,
outputHeightPx: outH,
}
log("done", "success")
return { correctedImageBlob: blob, diagnostics }
} finally {
// Always clean up all OpenCV mats, even on error
for (const m of mats) {
try {
m.delete()
} catch {
// already deleted or invalid — ignore
// already deleted — ignore
}
}
}
@ -464,31 +245,22 @@ let cvReady = false
/** Wait for OpenCV WASM to initialize. Call once at app startup. */
export function waitForOpenCV(): Promise<void> {
log("opencv", "waitForOpenCV called, cvReady=" + String(cvReady))
return new Promise<void>((resolve) => {
if (cvReady) {
log("opencv", "already ready")
resolve()
return
}
// Test if WASM is actually functional by trying to create a mat
try {
log("opencv", "probing cv.Mat()...")
const test = new cv.Mat()
test.delete()
cvReady = true
log("opencv", "probe succeeded, WASM ready")
resolve()
return
} catch {
log("opencv", "probe failed, waiting for onRuntimeInitialized")
// Not ready yet, wait for callback
// Runtime not ready yet
}
cv.onRuntimeInitialized = () => {
cvReady = true
log("opencv", "onRuntimeInitialized fired, WASM ready")
resolve()
}
})

843
src/lib/solver.ts Normal file
View File

@ -0,0 +1,843 @@
/**
* solver.ts alternating-minimization solver around cv.findHomography.
*
* Each datum is converted to point correspondences (src dst) whose dst
* positions are recomputed from the current H and the datum's shape
* constraint on every outer iteration. findHomography then refines H (DLT
* initial estimate + internal Levenberg-Marquardt). We loop until H stops
* changing.
*
* Shape constraints per datum type:
* rectangle (primary) hard-anchored axis-aligned destination
* rectangle (other) Procrustes-fit ideal (w × h) rect to projected corners
* line preserve projected midpoint + direction, rescale to L
* ellipse sample N points; radially snap projections to a circle
* of known diameter centred on the projected
* user-marked centre point
*
* Confidence is the per-datum weight; we realise it as correspondence
* replication (findHomography has no native weighting).
*/
import cv from "@techstark/opencv-js"
import type {
Datum,
DatumReport,
DatumType,
EllipseDatum,
LineDatum,
Point,
RectDatum,
} from "@/types"
// ─── Tunables ───────────────────────────────────────────────────────────────
/** Extra weight on primary-datum anchor correspondences, on top of its
* confidence. Keeps the output gauge (orientation/position) stable across
* iterations while still letting consistent secondary data nudge H. */
const PRIMARY_GAUGE_BOOST = 3
/** Points sampled per ellipse datum (more points = tighter fit but more
* replicated correspondences). 12 gives good angular coverage. */
const ELLIPSE_SAMPLES = 12
const MAX_OUTER_ITERS = 30
/** Convergence threshold: max entrywise change in H between successive
* iterations, relative to the largest entry of H. A relative metric
* treats the small perspective entries (h[6], h[7] 1e-4) and the O(1)
* affine entries on the same footing. */
const CONVERGENCE_TOL = 1e-6
// ─── 3×3 matrix helpers ─────────────────────────────────────────────────────
export type Mat3 = [number, number, number, number, number, number, number, number, number]
function readMat3x3(M: InstanceType<typeof cv.Mat>): Mat3 {
const d: number[] = []
for (let r = 0; r < 3; r++) {
for (let c = 0; c < 3; c++) {
d.push(M.doubleAt(r, c))
}
}
return d as Mat3
}
function projectPoint(h: Mat3, p: Point): Point {
const w = h[6] * p.x + h[7] * p.y + h[8]
return {
x: (h[0] * p.x + h[1] * p.y + h[2]) / w,
y: (h[3] * p.x + h[4] * p.y + h[5]) / w,
}
}
function normalized(h: Mat3): Mat3 {
const s = h[8] !== 0 ? h[8] : 1
return h.map((v) => v / s) as Mat3
}
/** Relative max-entry change between two homographies, normalised by the
* larger-magnitude entry in either matrix. Returns a dimensionless fraction
* so a single convergence threshold is meaningful across affine and
* perspective parameters. */
function relativeMaxDiff(a: Mat3, b: Mat3): number {
let diff = 0
let scale = 0
for (let i = 0; i < 9; i++) {
const av = Math.abs(a[i] ?? 0)
const bv = Math.abs(b[i] ?? 0)
if (av > scale) scale = av
if (bv > scale) scale = bv
const d = Math.abs((a[i] ?? 0) - (b[i] ?? 0))
if (d > diff) diff = d
}
return scale > 0 ? diff / scale : diff
}
// ─── Geometric helpers ──────────────────────────────────────────────────────
function dist(a: Point, b: Point): number {
return Math.hypot(b.x - a.x, b.y - a.y)
}
function centroid(pts: Point[]): Point {
let sx = 0
let sy = 0
for (const p of pts) {
sx += p.x
sy += p.y
}
return { x: sx / pts.length, y: sy / pts.length }
}
/**
* Best rigid transform (rotation + translation, no scale) aligning src to dst.
* Closed-form 2D Procrustes. Returns (R, t) with R @ src_i + t dst_i.
*/
function procrustes2D(
src: Point[],
dst: Point[],
): { cos: number; sin: number; tx: number; ty: number } {
const cs = centroid(src)
const cd = centroid(dst)
let hxx = 0
let hxy = 0
let hyx = 0
let hyy = 0
for (let i = 0; i < src.length; i++) {
const s = src[i]
const d = dst[i]
if (!s || !d) continue
const sx = s.x - cs.x
const sy = s.y - cs.y
const dx = d.x - cd.x
const dy = d.y - cd.y
hxx += sx * dx
hxy += sx * dy
hyx += sy * dx
hyy += sy * dy
}
// argmax over θ of tr(R · H^T) = cos·(hxx+hyy) + sin·(hyxhxy)
const theta = Math.atan2(hyx - hxy, hxx + hyy)
const cos = Math.cos(theta)
const sin = Math.sin(theta)
const tx = cd.x - (cos * cs.x - sin * cs.y)
const ty = cd.y - (sin * cs.x + cos * cs.y)
return { cos, sin, tx, ty }
}
function applyRT(
p: Point,
R: { cos: number; sin: number; tx: number; ty: number },
): Point {
return {
x: R.cos * p.x - R.sin * p.y + R.tx,
y: R.sin * p.x + R.cos * p.y + R.ty,
}
}
/**
* Compute the 3×3 symmetric conic matrix E for an ellipse parameterised by
* center and two (not necessarily orthogonal) conjugate semi-axes vA, vB.
* Parametrically: p(t) = center + vA cos t + vB sin t. The ellipse's
* quadratic form is (p c)^T Q (p c) = 1 with Q = (M M^T)^{-1} where
* M = [vA | vB] (2×2). The homogeneous conic matrix is then assembled so
* that [x y 1] E [x y 1]^T = 0 on the ellipse.
*/
function ellipseMatrix(
center: Point,
axisEndA: Point,
axisEndB: Point,
): number[][] {
const ax = axisEndA.x - center.x
const ay = axisEndA.y - center.y
const bx = axisEndB.x - center.x
const by = axisEndB.y - center.y
// M M^T = [[ax²+bx², ax·ay+bx·by], [·, ay²+by²]]
const m00 = ax * ax + bx * bx
const m01 = ax * ay + bx * by
const m11 = ay * ay + by * by
const det = m00 * m11 - m01 * m01
if (Math.abs(det) < 1e-12) {
throw new Error("Ellipse is degenerate (axes are collinear).")
}
// Q = (MM^T)^{-1}
const q00 = m11 / det
const q01 = -m01 / det
const q11 = m00 / det
const cx = center.x
const cy = center.y
// E (homogeneous): p^T E p = 0 with p = (x, y, 1)
const e02 = -(q00 * cx + q01 * cy)
const e12 = -(q01 * cx + q11 * cy)
const e22 = q00 * cx * cx + 2 * q01 * cx * cy + q11 * cy * cy - 1
return [
[q00, q01, e02],
[q01, q11, e12],
[e02, e12, e22],
]
}
/** Sample N image-space points along the user-drawn ellipse. */
function sampleEllipse(
center: Point,
axisEndA: Point,
axisEndB: Point,
n: number,
): Point[] {
const vAx = axisEndA.x - center.x
const vAy = axisEndA.y - center.y
const vBx = axisEndB.x - center.x
const vBy = axisEndB.y - center.y
const out: Point[] = []
for (let i = 0; i < n; i++) {
const t = (2 * Math.PI * i) / n
const c = Math.cos(t)
const s = Math.sin(t)
out.push({
x: center.x + vAx * c + vBx * s,
y: center.y + vAy * c + vBy * s,
})
}
return out
}
// ─── Primary selection (gauge) ──────────────────────────────────────────────
/** The "primary" datum fixes the output gauge (rotation, translation, scale)
* and provides the 4-point warm-start for the homography. We prefer
* rectangles (strongest gauge: 4 corners, aspect ratio), then ellipses
* (4 conjugate-axis samples fully pin H), then lines (weakest: only 2 real
* correspondences + synthetic perpendiculars). Within a type class, higher
* confidence and larger image size break ties. */
type Primary =
| { kind: "rect"; datum: RectDatum }
| { kind: "line"; datum: LineDatum }
| { kind: "ellipse"; datum: EllipseDatum }
function pickPrimary(datums: Datum[]): Primary {
if (datums.length === 0) throw new Error("No datums provided.")
const typeRank = (d: Datum): number =>
d.type === "rectangle" ? 0 : d.type === "ellipse" ? 1 : 2
const sizeKey = (d: Datum): number => {
if (d.type === "rectangle")
return (
dist(d.corners[0], d.corners[1]) *
dist(d.corners[0], d.corners[3])
)
if (d.type === "line") return dist(d.endpoints[0], d.endpoints[1])
return dist(d.center, d.axisEndA) * dist(d.center, d.axisEndB)
}
const sorted = [...datums].sort((a, b) => {
const tr = typeRank(a) - typeRank(b)
if (tr !== 0) return tr
if (b.confidence !== a.confidence)
return b.confidence - a.confidence
return sizeKey(b) - sizeKey(a)
})
const best = sorted[0] as Datum
if (best.type === "rectangle") return { kind: "rect", datum: best }
if (best.type === "line") return { kind: "line", datum: best }
return { kind: "ellipse", datum: best }
}
function primaryLabel(primary: Primary): string {
return primary.datum.label
}
// ─── Correspondence builders per datum type ─────────────────────────────────
interface Correspondence {
src: Point
dst: Point
weight: number // integer replication count
}
function primaryRectCorrespondences(
rect: RectDatum,
scale: number,
): Correspondence[] {
const w = rect.widthMm * scale
const h = rect.heightMm * scale
// Corner order TL, TR, BR, BL matches the RectDatum contract
const targets: [Point, Point, Point, Point] = [
{ x: 0, y: 0 },
{ x: w, y: 0 },
{ x: w, y: h },
{ x: 0, y: h },
]
const weight = Math.max(
1,
Math.round(rect.confidence * PRIMARY_GAUGE_BOOST),
)
return rect.corners.map((src, i) => ({
src,
dst: targets[i] as Point,
weight,
}))
}
/** Line primary: 2 real endpoints + 2 synthetic perpendicular points at
* the same image-space distance. Fixes the along-line scale exactly and
* assumes isotropic image scale perpendicular to the line (good warm-start;
* LM refines if other datums contradict it). */
function primaryLineCorrespondences(
line: LineDatum,
scale: number,
): Correspondence[] {
const L = line.lengthMm * scale
const p0 = line.endpoints[0]
const p1 = line.endpoints[1]
const dx = p1.x - p0.x
const dy = p1.y - p0.y
// 90° left-rotated copy of the line, same image length
const p2: Point = { x: p0.x - dy, y: p0.y + dx }
const p3: Point = { x: p1.x - dy, y: p1.y + dx }
const srcPts: [Point, Point, Point, Point] = [p0, p1, p2, p3]
const targets: [Point, Point, Point, Point] = [
{ x: 0, y: 0 },
{ x: L, y: 0 },
{ x: 0, y: L },
{ x: L, y: L },
]
const weight = Math.max(
1,
Math.round(line.confidence * PRIMARY_GAUGE_BOOST),
)
return srcPts.map((src, i) => ({
src,
dst: targets[i] as Point,
weight,
}))
}
/** Ellipse primary: 4 points on the user-drawn ellipse at t = 0, π/2, π,
* 3π/2 (= center ± vA, center ± vB). Anchored to a world-circle of the
* known diameter. Fixes translation + rotation + scale via the axisEndA
* direction convention. */
function primaryEllipseCorrespondences(
ellipse: EllipseDatum,
scale: number,
): Correspondence[] {
const r = (ellipse.diameterMm * scale) / 2
const c = ellipse.center
const vAx = ellipse.axisEndA.x - c.x
const vAy = ellipse.axisEndA.y - c.y
const vBx = ellipse.axisEndB.x - c.x
const vBy = ellipse.axisEndB.y - c.y
// Axes must not be (near-)collinear — otherwise the 4 anchor points are
// collinear and getPerspectiveTransform gives a garbage homography.
const cross = vAx * vBy - vAy * vBx
if (Math.abs(cross) < 1e-6) {
throw new Error(
`Ellipse "${ellipse.label}" has collinear axes; drag its handles apart before solving.`,
)
}
const srcPts: [Point, Point, Point, Point] = [
{ x: c.x + vAx, y: c.y + vAy }, // t = 0 → (+r, 0)
{ x: c.x + vBx, y: c.y + vBy }, // t = π/2 → (0, +r)
{ x: c.x - vAx, y: c.y - vAy }, // t = π → (r, 0)
{ x: c.x - vBx, y: c.y - vBy }, // t = 3π/2 → (0, r)
]
const targets: [Point, Point, Point, Point] = [
{ x: r, y: 0 },
{ x: 0, y: r },
{ x: -r, y: 0 },
{ x: 0, y: -r },
]
const weight = Math.max(
1,
Math.round(ellipse.confidence * PRIMARY_GAUGE_BOOST),
)
return srcPts.map((src, i) => ({
src,
dst: targets[i] as Point,
weight,
}))
}
function primaryAnchors(primary: Primary, scale: number): Correspondence[] {
if (primary.kind === "rect")
return primaryRectCorrespondences(primary.datum, scale)
if (primary.kind === "line")
return primaryLineCorrespondences(primary.datum, scale)
return primaryEllipseCorrespondences(primary.datum, scale)
}
function secondaryRectCorrespondences(
rect: RectDatum,
H: Mat3,
scale: number,
): Correspondence[] {
const w = rect.widthMm * scale
const h = rect.heightMm * scale
// Ideal (w × h) rect in local frame, same corner order
const ideal: Point[] = [
{ x: 0, y: 0 },
{ x: w, y: 0 },
{ x: w, y: h },
{ x: 0, y: h },
]
// Project the user corners through current H to output space
const projected = rect.corners.map((c) => projectPoint(H, c))
// Best rigid placement of the ideal rect to those projections
const rt = procrustes2D(ideal, projected)
const targets = ideal.map((p) => applyRT(p, rt))
const weight = Math.max(1, rect.confidence)
return rect.corners.map((src, i) => ({
src,
dst: targets[i] as Point,
weight,
}))
}
function lineCorrespondences(
line: LineDatum,
H: Mat3,
scale: number,
): Correspondence[] {
const expected = line.lengthMm * scale
const p0 = projectPoint(H, line.endpoints[0])
const p1 = projectPoint(H, line.endpoints[1])
const measured = dist(p0, p1)
if (measured < 1e-6) {
// Degenerate line, no useful correspondence this iteration
return []
}
const mx = (p0.x + p1.x) / 2
const my = (p0.y + p1.y) / 2
const ux = (p1.x - p0.x) / measured
const uy = (p1.y - p0.y) / measured
const halfL = expected / 2
const target0: Point = { x: mx - ux * halfL, y: my - uy * halfL }
const target1: Point = { x: mx + ux * halfL, y: my + uy * halfL }
const weight = Math.max(1, line.confidence)
return [
{ src: line.endpoints[0], dst: target0, weight },
{ src: line.endpoints[1], dst: target1, weight },
]
}
function ellipseCorrespondences(
ellipse: EllipseDatum,
H: Mat3,
scale: number,
): Correspondence[] {
const r = (ellipse.diameterMm * scale) / 2
const samples = sampleEllipse(
ellipse.center,
ellipse.axisEndA,
ellipse.axisEndB,
ELLIPSE_SAMPLES,
)
const projected = samples.map((p) => projectPoint(H, p))
// Snap radially around the projected center of the world circle.
// Under general perspective the centroid of boundary-point projections
// is NOT the image of the center (it drifts with the foreshortening),
// so we use the image of the user-marked center directly.
const c = projectPoint(H, ellipse.center)
const weight = Math.max(1, ellipse.confidence)
const out: Correspondence[] = []
for (let i = 0; i < samples.length; i++) {
const src = samples[i]
const q = projected[i]
if (!src || !q) continue
const dx = q.x - c.x
const dy = q.y - c.y
const d = Math.hypot(dx, dy)
if (d < 1e-6) continue
out.push({
src,
dst: { x: c.x + (dx / d) * r, y: c.y + (dy / d) * r },
weight,
})
}
return out
}
function buildCorrespondences(
datums: Datum[],
primary: Primary,
H: Mat3,
scale: number,
): Correspondence[] {
const all: Correspondence[] = []
for (const d of datums) {
if (d === primary.datum) {
all.push(...primaryAnchors(primary, scale))
continue
}
if (d.type === "rectangle") {
all.push(...secondaryRectCorrespondences(d, H, scale))
} else if (d.type === "line") {
all.push(...lineCorrespondences(d, H, scale))
} else {
all.push(...ellipseCorrespondences(d, H, scale))
}
}
return all
}
// ─── findHomography wrapper ─────────────────────────────────────────────────
function solveHomography(
correspondences: Correspondence[],
): Mat3 | null {
// Replicate by weight to emulate per-correspondence weighting
const src: number[] = []
const dst: number[] = []
let n = 0
for (const c of correspondences) {
for (let i = 0; i < c.weight; i++) {
src.push(c.src.x, c.src.y)
dst.push(c.dst.x, c.dst.y)
n++
}
}
if (n < 4) return null
const srcMat = cv.matFromArray(n, 1, cv.CV_32FC2, src)
const dstMat = cv.matFromArray(n, 1, cv.CV_32FC2, dst)
let H: InstanceType<typeof cv.Mat> | null = null
try {
H = cv.findHomography(srcMat, dstMat, 0)
if (H.rows !== 3 || H.cols !== 3) return null
return normalized(readMat3x3(H))
} finally {
srcMat.delete()
dstMat.delete()
if (H) H.delete()
}
}
// ─── Post-fit residual reporting ────────────────────────────────────────────
interface RawReport {
label: string
type: DatumType
expectedMm: number
measuredMm: number
residuals: number[] // dimensionless fractions
details: string
}
function residualForLine(
line: LineDatum,
H: Mat3,
scale: number,
isPrimary: boolean,
): RawReport {
const p0 = projectPoint(H, line.endpoints[0])
const p1 = projectPoint(H, line.endpoints[1])
const measuredMm = dist(p0, p1) / scale
const residual =
line.lengthMm > 0
? (measuredMm - line.lengthMm) / line.lengthMm
: 0
const prefix = isPrimary ? "primary · " : ""
return {
label: line.label,
type: "line",
expectedMm: line.lengthMm,
measuredMm,
residuals: [residual],
details: `${prefix}length ${(residual * 100).toFixed(2)}%`,
}
}
function residualForRect(
rect: RectDatum,
H: Mat3,
scale: number,
isPrimary: boolean,
): RawReport {
const p = rect.corners.map((c) => projectPoint(H, c))
const w = rect.widthMm
const h = rect.heightMm
const sides = [
{ got: dist(p[0] as Point, p[1] as Point) / scale, exp: w }, // TL-TR
{ got: dist(p[1] as Point, p[2] as Point) / scale, exp: h }, // TR-BR
{ got: dist(p[2] as Point, p[3] as Point) / scale, exp: w }, // BR-BL
{ got: dist(p[3] as Point, p[0] as Point) / scale, exp: h }, // BL-TL
]
const edgeRes = sides.map((s) => (s.exp > 0 ? (s.got - s.exp) / s.exp : 0))
// Perpendicularity at TL and TR: cosine between adjacent edges
const e0x = (p[1] as Point).x - (p[0] as Point).x
const e0y = (p[1] as Point).y - (p[0] as Point).y
const e1x = (p[3] as Point).x - (p[0] as Point).x
const e1y = (p[3] as Point).y - (p[0] as Point).y
const e2x = (p[2] as Point).x - (p[1] as Point).x
const e2y = (p[2] as Point).y - (p[1] as Point).y
const cosTL =
(e0x * e1x + e0y * e1y) /
(Math.hypot(e0x, e0y) * Math.hypot(e1x, e1y) + 1e-12)
const cosTR =
(-e0x * e2x + -e0y * e2y) /
(Math.hypot(e0x, e0y) * Math.hypot(e2x, e2y) + 1e-12)
const residuals = [...edgeRes, cosTL, cosTR]
const avgEdge =
(Math.abs(edgeRes[0] ?? 0) +
Math.abs(edgeRes[1] ?? 0) +
Math.abs(edgeRes[2] ?? 0) +
Math.abs(edgeRes[3] ?? 0)) /
4
// |cos θ| ≈ |90° θ| in radians for small deviations; convert to degrees
const perpDeg =
((Math.asin(Math.min(1, Math.abs(cosTL))) +
Math.asin(Math.min(1, Math.abs(cosTR)))) /
2) *
(180 / Math.PI)
const measuredWidth =
((sides[0]?.got ?? 0) + (sides[2]?.got ?? 0)) / 2
const prefix = isPrimary ? "primary · " : ""
return {
label: rect.label,
type: "rectangle",
expectedMm: w,
measuredMm: measuredWidth,
residuals,
details: `${prefix}edge ${(avgEdge * 100).toFixed(2)}%, perp Δ${perpDeg.toFixed(2)}°`,
}
}
function residualForEllipse(
ellipse: EllipseDatum,
H: Mat3,
scale: number,
isPrimary: boolean,
): RawReport {
// Pull the image ellipse back to world space via C = H^T E H
const E = ellipseMatrix(ellipse.center, ellipse.axisEndA, ellipse.axisEndB)
const HT = [
[H[0], H[3], H[6]],
[H[1], H[4], H[7]],
[H[2], H[5], H[8]],
]
const Hm = [
[H[0], H[1], H[2]],
[H[3], H[4], H[5]],
[H[6], H[7], H[8]],
]
// C = HT · E · Hm
const EH: number[][] = [
[0, 0, 0],
[0, 0, 0],
[0, 0, 0],
]
for (let i = 0; i < 3; i++) {
for (let j = 0; j < 3; j++) {
let s = 0
for (let k = 0; k < 3; k++) {
s += (E[i]?.[k] ?? 0) * (Hm[k]?.[j] ?? 0)
}
;(EH[i] as number[])[j] = s
}
}
const C: number[][] = [
[0, 0, 0],
[0, 0, 0],
[0, 0, 0],
]
for (let i = 0; i < 3; i++) {
for (let j = 0; j < 3; j++) {
let s = 0
for (let k = 0; k < 3; k++) {
s += (HT[i]?.[k] ?? 0) * (EH[k]?.[j] ?? 0)
}
;(C[i] as number[])[j] = s
}
}
const a = C[0]?.[0] ?? 0
const b = C[0]?.[1] ?? 0
const c = C[1]?.[1] ?? 0
const d = C[0]?.[2] ?? 0
const e = C[1]?.[2] ?? 0
const f = C[2]?.[2] ?? 0
const sum = a + c
const iso = sum !== 0 ? (a - c) / sum : 0
const skew = sum !== 0 ? (2 * b) / sum : 0
const rExp = (ellipse.diameterMm * scale) / 2
// Geometric-mean radius from the general (possibly non-circular) conic.
// After centring, the conic is u^T A u = K with A = [[a, b], [b, c]] and
// K = a·x0² + 2b·x0·y0 + c·y0² f, (x0, y0) = A^{-1}(d, e).
// Semi-axes are √(K/λ_±), so √(semi_major · semi_minor) = √(K / √det A).
// This coincides with the circle radius when a = c and b = 0, and stays
// meaningful (= area-equivalent radius) while the solver drives the
// conic toward being a circle.
const det = a * c - b * b
let rMeasured = 0
if (det > 0) {
const x0 = (b * e - c * d) / det
const y0 = (b * d - a * e) / det
const K = a * x0 * x0 + 2 * b * x0 * y0 + c * y0 * y0 - f
if (K > 0) rMeasured = Math.sqrt(K / Math.sqrt(det))
}
// The dia residual drives the solver; we divide by rExp for scale-free.
// When the conic is still clearly non-circular the per-axis semi-axes
// differ, so we penalise by the geometric-mean radius — which is what
// we want to land at rExp once iso/skew have been driven to zero.
const diaRes = rExp > 0 ? (rMeasured - rExp) / rExp : 0
const prefix = isPrimary ? "primary · " : ""
return {
label: ellipse.label,
type: "ellipse",
expectedMm: ellipse.diameterMm,
measuredMm: (rMeasured * 2) / scale,
residuals: [iso, skew, diaRes],
details: `${prefix}iso ${(iso * 100).toFixed(2)}%, skew ${(skew * 100).toFixed(2)}%, dia ${(diaRes * 100).toFixed(2)}%`,
}
}
function buildReports(
datums: Datum[],
primary: Primary,
H: Mat3,
scale: number,
): { reports: DatumReport[]; rmsPercent: number } {
const raw: RawReport[] = datums.map((d) => {
const isPrimary = d === primary.datum
if (d.type === "line")
return residualForLine(d, H, scale, isPrimary)
if (d.type === "rectangle")
return residualForRect(d, H, scale, isPrimary)
return residualForEllipse(d, H, scale, isPrimary)
})
const reports: DatumReport[] = raw.map((r) => {
const rms =
Math.sqrt(
r.residuals.reduce((s, x) => s + x * x, 0) /
Math.max(1, r.residuals.length),
) * 100
return {
label: r.label,
type: r.type,
expectedMm: r.expectedMm,
measuredMm: r.measuredMm,
errorPercent: rms,
details: r.details,
}
})
let sumSq = 0
let n = 0
for (const r of raw) {
for (const x of r.residuals) {
sumSq += x * x
n++
}
}
const rmsPercent = n > 0 ? Math.sqrt(sumSq / n) * 100 : 0
return { reports, rmsPercent }
}
// ─── Public entry point ─────────────────────────────────────────────────────
interface SolverResult {
/** 3×3 homography (row-major) mapping source-image px → output px. */
H: Mat3
/** Label of the datum used as the gauge reference (useful for UI). */
primaryLabel: string
/** Type of the primary, so callers can report it meaningfully. */
primaryType: DatumType
iterations: number
reports: DatumReport[]
rmsPercent: number
}
function warmStartH(primary: Primary, scale: number): Mat3 {
const anchors = primaryAnchors(primary, scale)
// Guaranteed 4 anchors for any primary kind
const src = cv.matFromArray(
4,
1,
cv.CV_32FC2,
anchors.flatMap((c) => [c.src.x, c.src.y]),
)
const dst = cv.matFromArray(
4,
1,
cv.CV_32FC2,
anchors.flatMap((c) => [c.dst.x, c.dst.y]),
)
try {
const M = cv.getPerspectiveTransform(src, dst)
const h = normalized(readMat3x3(M))
M.delete()
return h
} finally {
src.delete()
dst.delete()
}
}
export function solveHomographyForDatums(
datums: Datum[],
scale: number,
): SolverResult {
const primary = pickPrimary(datums)
let H = warmStartH(primary, scale)
let iterations = 0
let Hprev: Mat3 | null = null
let oscillationWarned = false
for (let iter = 0; iter < MAX_OUTER_ITERS; iter++) {
const corrs = buildCorrespondences(datums, primary, H, scale)
const Hnew = solveHomography(corrs)
iterations = iter + 1
if (!Hnew) break
const delta = relativeMaxDiff(H, Hnew)
// Period-2 oscillation detection: if we're closer to two steps ago
// than to one step ago, the outer loop is cycling between two H
// values rather than converging. Alternating minimisation gives no
// monotone-decrease guarantee (no coherent global objective), so
// this can happen with adversarial datum combinations.
if (
!oscillationWarned &&
Hprev &&
iter >= 3 &&
delta > CONVERGENCE_TOL
) {
const deltaSkip = relativeMaxDiff(Hprev, Hnew)
if (deltaSkip < delta * 0.25) {
console.warn(
`[solver] outer loop appears to be oscillating (iter=${String(iter + 1)}, δ=${delta.toExponential(2)}, δ_skip=${deltaSkip.toExponential(2)}). Result may not be optimal.`,
)
oscillationWarned = true
}
}
Hprev = H
H = Hnew
if (delta < CONVERGENCE_TOL) break
}
const { reports, rmsPercent } = buildReports(datums, primary, H, scale)
return {
H,
primaryLabel: primaryLabel(primary),
primaryType: primary.datum.type,
iterations,
reports,
rmsPercent,
}
}

View File

@ -29,7 +29,8 @@ export const useAppStore = defineStore("app", () => {
if (!canProceedToStep3.value || datums.value.length === 0) return false
return datums.value.every((d) => {
if (d.type === "rectangle") return d.widthMm > 0 && d.heightMm > 0
return d.lengthMm > 0
if (d.type === "line") return d.lengthMm > 0
return d.diameterMm > 0
})
})

View File

@ -22,10 +22,27 @@ export interface LineDatum {
label: string
}
export type Datum = RectDatum | LineDatum
export interface EllipseDatum {
id: string
type: "ellipse"
/** Image-space ellipse as 3 free points: center + two conjugate
* semi-axis endpoints. axisEndA/axisEndB don't need to be perpendicular;
* together with center they give a full 5-DoF ellipse matrix. */
center: Point
axisEndA: Point
axisEndB: Point
/** Known real-world diameter of the circle being drawn. */
diameterMm: number
confidence: 1 | 2 | 3 | 4 | 5
label: string
}
export type Datum = RectDatum | LineDatum | EllipseDatum
export type ConfidenceScore = 1 | 2 | 3 | 4 | 5
export type DatumType = Datum["type"]
export interface ExifData {
make?: string
model?: string
@ -53,24 +70,26 @@ export interface DeskewInput {
onProgress?: (step: number, total: number, label: string) => void
}
export interface AxisCorrection {
ratio: number
totalWeight: number
}
export interface DatumReport {
label: string
type: "rectangle" | "line"
measuredMm: number
type: DatumType
/** Representative expected dimension in mm (widthMm / lengthMm / diameterMm). */
expectedMm: number
/** Representative measured dimension in mm under the solved H. */
measuredMm: number
/** Overall residual magnitude expressed as a percentage. */
errorPercent: number
axisContribution: "x" | "y" | "both"
/** Free-form breakdown for debugging (e.g. "iso 0.2%, skew 0.1%, dia 0.8%"). */
details: string
}
export interface DeskewDiagnostics {
/** Label of the rectangle used to fix the output gauge. */
primaryDatum: string
xCorrection: AxisCorrection
yCorrection: AxisCorrection
/** Number of outer alternating-minimization iterations the solver ran. */
iterations: number
/** Final weighted RMS residual across all datums, as a percentage. */
finalRMSPercent: number
perDatum: DatumReport[]
outputWidthPx: number
outputHeightPx: number
@ -91,3 +110,8 @@ export interface RectPreset {
widthMm: number
heightMm: number
}
export interface CirclePreset {
label: string
diameterMm: number
}