Compare commits

..

5 Commits

Author SHA1 Message Date
Samuel Prevost
e94a814335 fix(measurements): row is a div, not a nested button
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled
The side-panel measurement rows were <button> with a nested <button> for
delete, which HTML doesn't allow and Vite was warning about. Demote the
row to a focusable div with role=button + tabindex=0 + Enter/Space
handlers; keep the inner delete <button> with type=button. Same
keyboard a11y, no nesting violation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:44:10 +02:00
Samuel Prevost
497e71d63c feat(measurements): larger canvas, always-visible handles, fewer clicks
- Canvas fills the viewport (h-[calc(100vh-12rem)] on desktop, -14rem on
  mobile), matching the datum-editor precedent. The side panel tracks
  the same height so both read as equal-height siblings.
- Handles are now visible on every measurement, not just the selected
  one. Unselected: small (3px), low-alpha, faint white ring. Selected
  endpoints: 6.5px with a thick ring. Selected primary handles (ellipse
  center / angle vertex): 8px. The invisible grab radius is 14px so the
  tiny unselected dots are still easy to target.
- Selected handles keep their palette color (previously they went white
  along with the lines, so a selected handle disappeared on light
  backgrounds). Matches DatumCanvas's look.
- Hit-test priority is explicit: handles beat geometry, so a precision
  grab on an endpoint always wins over a line-body drag — including on
  an unselected measurement, which promotes to select-and-drag in a
  single gesture.
- Removed the on-canvas delete button next to the selected label. The
  side-panel row × and Delete/Backspace still work. HitResult.kind
  drops its "delete" variant; the matching draw + dispatch blocks and
  the dedicated hit region are gone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:22:56 +02:00
Samuel Prevost
e07ee9d204 fix(solver): ellipse diagnostic used wrong H direction
residualForEllipse computed C = H^T·E·H, but H maps image → output, so
the correct output-space conic is C = H^{-T}·E·H^{-1}. The forward form
represents a geometrically meaningless conic and produced nonsensical
numbers in the per-datum table (a 210mm circle was reported as 2046mm).
The deskewed image itself was correct — only the diagnostic was wrong,
because these residuals are display-only and don't feed back into the
solver. Now invert H once and compute the conic in output space.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:22:42 +02:00
Samuel Prevost
da5be3851d feat: world-axis selector, 8-point circle, annotated measurement tool
Datum editor (step 3):
- Add world-axis role to rectangles (isAxisReference) and lines
  (axisRole: "x"|"y"). Exclusive via a new store action that clears
  any other axis flag on write. The solver's pickPrimary now honors an
  explicit user flag ahead of the type-priority fallback; line-primary
  correspondences target world +x or +y depending on the flag.
- Panel UI: checkbox on rect cards, three-way button row on line cards,
  and an axis badge in each card header.
- Ellipse datum switches to 8 user-placed points on the circle contour.
  New src/lib/ellipse-fit.ts does an algebraic LSQ conic fit (data-
  normalised, 5x5 Gaussian solve, f=-1 constraint) and returns the
  geometric center + perpendicular conjugate semi-axes, which we cache
  on the datum for the solver and renderer. Dragging any handle
  refits; an extra center handle translates all 8 points together.
  datum-cache migrates legacy 3-handle storage by synthesising 8
  samples from the old parametric form.
- ResultViewer auto-scale now floors to an int to match the integer-
  only scale input (step=1).

Measurement tool (step 4) — CorrectedImageViewer.vue:
- Three measurement tools: line (length), ellipse (semi-axes + area),
  angle (0-180 degrees between two rays).
- Persistent, multi-measurement state. Each has id, colorIndex, and
  type-specific geometry; colors cycle via the existing getDatumColor
  palette with a monotonic counter so deletion doesn't recolor.
- Selection model with hit-testing on handles, geometry, and labels.
  Selected draws on top in white; others render dashed with 0.8/0.5
  alpha so the active measurement pops.
- Dragging geometry or label moves the whole measurement; dragging a
  handle reshapes just that handle. 3px mouse threshold distinguishes
  click from drag.
- Side panel lists measurements with color chip, type, value, and a
  delete button; clicking selects on canvas. Delete/Backspace deletes
  the selected measurement. Escape cancels in-progress placement.
- Live placement preview + inline hint strip describes what the next
  click does. Pinch-zoom and single-finger pan still work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:10:22 +02:00
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
12 changed files with 2955 additions and 679 deletions

File diff suppressed because it is too large Load Diff

View File

@ -50,23 +50,36 @@ function datumIndex(datum: Datum): number {
return store.datums.findIndex((d) => d.id === datum.id) 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[]
// Index 0 is the center (translate all); 1..N are the on-curve points
// the user drags to reshape the fitted ellipse.
return [datum.center, ...datum.points]
}
function getPointConfigs(datum: Datum, dIdx: number) { function getPointConfigs(datum: Datum, dIdx: number) {
const color = getDatumColor(dIdx) const color = getDatumColor(dIdx)
const isSelected = store.selectedDatumId === datum.id 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 baseRadius = isSelected ? 6 : 4
const visualRadius = Math.max( const visualRadius = Math.max(
baseRadius / scale.value, baseRadius / scale.value,
baseRadius * 0.5 baseRadius * 0.5,
) )
return points.map((pt, pIdx) => ({ return points.map((pt, pIdx) => ({
// Ellipse center (index 0) is visually bigger + hollow to distinguish it
x: pt.x, x: pt.x,
y: pt.y, y: pt.y,
radius: visualRadius, radius:
fill: color, datum.type === "ellipse" && pIdx === 0
? visualRadius * 1.4
: visualRadius,
fill: datum.type === "ellipse" && pIdx === 0 ? "transparent" : color,
stroke: isSelected ? "#fff" : color, stroke: isSelected ? "#fff" : color,
strokeWidth: 1.5 / scale.value, strokeWidth: (datum.type === "ellipse" && pIdx === 0 ? 2.5 : 1.5) /
scale.value,
draggable: true, draggable: true,
_datumId: datum.id, _datumId: datum.id,
_pointIndex: pIdx, _pointIndex: pIdx,
@ -74,9 +87,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) { function getLineConfigs(datum: Datum, dIdx: number) {
const color = getDatumColor(dIdx) const color = getDatumColor(dIdx)
const isSelected = store.selectedDatumId === datum.id 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") { if (datum.type === "line") {
return [ return [
@ -88,22 +122,55 @@ function getLineConfigs(datum: Datum, dIdx: number) {
datum.endpoints[1].y, datum.endpoints[1].y,
], ],
stroke: color, stroke: color,
strokeWidth: (isSelected ? 3 : 2) / scale.value, strokeWidth,
dash: isSelected ? [] : [8 / scale.value, 4 / scale.value], dash,
}, },
] ]
} }
// Rectangle: draw 4 edges if (datum.type === "rectangle") {
const c = datum.corners const c = datum.corners
const pts = [c[0], c[1], c[2], c[3], c[0]].flatMap((p) => [p.x, p.y]) const pts = [c[0], c[1], c[2], c[3], c[0]].flatMap((p) => [p.x, p.y])
return [ return [
{ {
points: pts, points: pts,
stroke: color, stroke: color,
strokeWidth: (isSelected ? 3 : 2) / scale.value, strokeWidth,
closed: true, closed: true,
dash: isSelected ? [] : [8 / scale.value, 4 / scale.value], dash,
},
]
}
// Ellipse: sampled curve + two thin axis lines for visual reference
return [
{
points: ellipseCurvePoints(datum),
stroke: color,
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 +184,18 @@ function getLabelConfig(datum: Datum, dIdx: number) {
x: (datum.corners[0].x + datum.corners[2].x) / 2, x: (datum.corners[0].x + datum.corners[2].x) / 2,
y: (datum.corners[0].y + datum.corners[2].y) / 2, y: (datum.corners[0].y + datum.corners[2].y) / 2,
} }
} else { } else if (datum.type === "line") {
pos = { pos = {
x: (datum.endpoints[0].x + datum.endpoints[1].x) / 2, x: (datum.endpoints[0].x + datum.endpoints[1].x) / 2,
y: y:
(datum.endpoints[0].y + datum.endpoints[1].y) / 2 - (datum.endpoints[0].y + datum.endpoints[1].y) / 2 -
20 / scale.value, 20 / scale.value,
} }
} else {
pos = {
x: datum.center.x,
y: datum.center.y - 20 / scale.value,
}
} }
return { return {
@ -155,10 +227,25 @@ function onPointDragMove(e: {
const newCorners = [...datum.corners] as [Point, Point, Point, Point] const newCorners = [...datum.corners] as [Point, Point, Point, Point]
newCorners[_pointIndex] = newPos newCorners[_pointIndex] = newPos
store.updateDatum(_datumId, { corners: newCorners }) store.updateDatum(_datumId, { corners: newCorners })
} else { } else if (datum.type === "line") {
const newEndpoints = [...datum.endpoints] as [Point, Point] const newEndpoints = [...datum.endpoints] as [Point, Point]
newEndpoints[_pointIndex] = newPos newEndpoints[_pointIndex] = newPos
store.updateDatum(_datumId, { endpoints: newEndpoints }) store.updateDatum(_datumId, { endpoints: newEndpoints })
} else if (_pointIndex === 0) {
// Ellipse center translate all on-curve points by the same delta
// and let the store refit.
const dx = newPos.x - datum.center.x
const dy = newPos.y - datum.center.y
const translated = datum.points.map((p) => ({
x: p.x + dx,
y: p.y + dy,
}))
store.updateEllipsePoints(_datumId, translated)
} else {
const newPoints = datum.points.map((p, i) =>
i === _pointIndex - 1 ? newPos : p,
)
store.updateEllipsePoints(_datumId, newPoints)
} }
} }

View File

@ -33,7 +33,8 @@ const canvasHeight = computed(() =>
const incompleteDatums = computed(() => const incompleteDatums = computed(() =>
store.datums.filter((d) => { store.datums.filter((d) => {
if (d.type === "rectangle") return d.widthMm <= 0 || d.heightMm <= 0 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 { useAppStore } from "@/stores/app"
import { import {
RECT_PRESETS, RECT_PRESETS,
CIRCLE_PRESETS,
createRectDatum, createRectDatum,
createLineDatum, createLineDatum,
createEllipseDatum,
getDatumColor, getDatumColor,
} from "@/lib/datums" } from "@/lib/datums"
import type { ConfidenceScore, Datum, RectDatum } from "@/types" import type { ConfidenceScore, Datum, RectDatum } from "@/types"
@ -31,6 +33,10 @@ function nextLineIndex(): number {
return store.datums.filter((d) => d.type === "line").length + 1 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) { function addRect(presetLabel?: string) {
const preset = presetLabel const preset = presetLabel
? RECT_PRESETS.find((p) => p.label === presetLabel) ? RECT_PRESETS.find((p) => p.label === presetLabel)
@ -42,6 +48,15 @@ function addLine() {
store.addDatum(createLineDatum(imageCenter(), nextLineIndex())) 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) { function updateField(datum: Datum, field: string, value: string | number) {
store.updateDatum(datum.id, { [field]: value }) store.updateDatum(datum.id, { [field]: value })
} }
@ -65,8 +80,24 @@ function formatDimensions(datum: Datum): string {
if (datum.type === "rectangle") { if (datum.type === "rectangle") {
return `${String(datum.widthMm)} \u00D7 ${String(datum.heightMm)} mm` return `${String(datum.widthMm)} \u00D7 ${String(datum.heightMm)} mm`
} }
if (datum.type === "line") {
return `${String(datum.lengthMm)} mm` 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"
}
function axisBadge(datum: Datum): string | null {
if (datum.type === "rectangle" && datum.isAxisReference) return "axis"
if (datum.type === "line" && datum.axisRole === "x") return "+x"
if (datum.type === "line" && datum.axisRole === "y") return "+y"
return null
}
</script> </script>
<template> <template>
@ -77,14 +108,14 @@ function formatDimensions(datum: Datum): string {
<CardTitle class="text-sm">Add Datum</CardTitle> <CardTitle class="text-sm">Add Datum</CardTitle>
</CardHeader> </CardHeader>
<CardContent class="space-y-3"> <CardContent class="space-y-3">
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-3 gap-2">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
class="w-full" class="w-full"
@click="addRect()" @click="addRect()"
> >
+ Rectangle + Rect
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@ -94,6 +125,14 @@ function formatDimensions(datum: Datum): string {
> >
+ Line + Line
</Button> </Button>
<Button
variant="outline"
size="sm"
class="w-full"
@click="addCircle()"
>
+ Circle
</Button>
</div> </div>
<div class="flex flex-wrap gap-1.5"> <div class="flex flex-wrap gap-1.5">
<Button <Button
@ -106,6 +145,16 @@ function formatDimensions(datum: Datum): string {
> >
{{ preset.label }} {{ preset.label }}
</Button> </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> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -147,9 +196,14 @@ function formatDimensions(datum: Datum): string {
:style="{ backgroundColor: getDatumColor(idx) }" :style="{ backgroundColor: getDatumColor(idx) }"
/> />
<Badge variant="outline" class="text-xs"> <Badge variant="outline" class="text-xs">
{{ {{ typeBadge(datum) }}
datum.type === "rectangle" ? "Rect" : "Line" </Badge>
}} <Badge
v-if="axisBadge(datum)"
variant="default"
class="text-xs"
>
{{ axisBadge(datum) }}
</Badge> </Badge>
<span class="text-xs text-muted-foreground">{{ <span class="text-xs text-muted-foreground">{{
formatDimensions(datum) formatDimensions(datum)
@ -262,7 +316,7 @@ function formatDimensions(datum: Datum): string {
/> />
</div> </div>
</div> </div>
<div v-else> <div v-else-if="datum.type === 'line'">
<Label class="text-xs">Length (mm)</Label> <Label class="text-xs">Length (mm)</Label>
<Input <Input
:model-value="String(datum.lengthMm)" :model-value="String(datum.lengthMm)"
@ -276,6 +330,83 @@ function formatDimensions(datum: Datum): string {
@click.stop @click.stop
/> />
</div> </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>
<!-- World-axis role -->
<div
v-if="datum.type === 'rectangle'"
class="flex items-center justify-between"
>
<Label class="text-xs">World axis reference</Label>
<label
class="flex cursor-pointer items-center gap-1.5"
@click.stop
>
<input
type="checkbox"
class="accent-primary"
:checked="datum.isAxisReference ?? false"
@change="
(e) =>
store.setAxisRole(
datum.id,
(e.target as HTMLInputElement)
.checked
? 'rect'
: null,
)
"
/>
<span class="text-xs text-muted-foreground"
>Use</span
>
</label>
</div>
<div v-else-if="datum.type === 'line'">
<Label class="text-xs">World axis</Label>
<div class="mt-1 grid grid-cols-3 gap-1">
<button
v-for="opt in [
{ value: null, label: 'None' },
{ value: 'x', label: 'X' },
{ value: 'y', label: 'Y' },
]"
:key="String(opt.value)"
type="button"
class="h-7 rounded-md border text-xs font-medium transition-colors"
:class="
(datum.axisRole ?? null) === opt.value
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:bg-accent'
"
@click.stop="
store.setAxisRole(
datum.id,
opt.value as
| 'x'
| 'y'
| null,
)
"
>
{{ opt.label }}
</button>
</div>
</div>
<!-- Confidence --> <!-- Confidence -->
<div> <div>

View File

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

View File

@ -1,4 +1,4 @@
import type { Datum } from "@/types" import type { Datum, EllipseDatum, Point } from "@/types"
const KEY_PREFIX = "skwik-datums-" const KEY_PREFIX = "skwik-datums-"
@ -17,12 +17,38 @@ export function loadDatums(hash: string): Datum[] | null {
try { try {
const raw = localStorage.getItem(KEY_PREFIX + hash) const raw = localStorage.getItem(KEY_PREFIX + hash)
if (!raw) return null if (!raw) return null
return JSON.parse(raw) as Datum[] const parsed = JSON.parse(raw) as Datum[]
return parsed.map(migrateDatum)
} catch { } catch {
return null return null
} }
} }
/** Legacy ellipse datums stored only center+axisEndA+axisEndB (pre 8-point
* fit). On load, synthesize 8 sample points from those axes so the new
* fit-based pipeline has something to work with. */
function migrateDatum(d: Datum): Datum {
if (d.type !== "ellipse") return d
const e = d as EllipseDatum
if (Array.isArray(e.points) && e.points.length >= 5) return e
const vAx = e.axisEndA.x - e.center.x
const vAy = e.axisEndA.y - e.center.y
const vBx = e.axisEndB.x - e.center.x
const vBy = e.axisEndB.y - e.center.y
const N = 8
const points: Point[] = []
for (let i = 0; i < N; i++) {
const t = (2 * Math.PI * i) / N
const cs = Math.cos(t)
const sn = Math.sin(t)
points.push({
x: e.center.x + vAx * cs + vBx * sn,
y: e.center.y + vAy * cs + vBy * sn,
})
}
return { ...e, points }
}
export function clearCache(): void { export function clearCache(): void {
const toRemove: string[] = [] const toRemove: string[] = []
for (let i = 0; i < localStorage.length; i++) { for (let i = 0; i < localStorage.length; i++) {

View File

@ -1,12 +1,27 @@
import { nanoid } from "nanoid" 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[] = [ export const RECT_PRESETS: RectPreset[] = [
{ label: "A3", widthMm: 297, heightMm: 420 }, { label: "A3", widthMm: 297, heightMm: 420 },
{ label: "A4", widthMm: 210, heightMm: 297 }, { label: "A4", widthMm: 210, heightMm: 297 },
{ label: "A5", widthMm: 148, heightMm: 210 }, { label: "A5", widthMm: 148, heightMm: 210 },
{ label: "A6", widthMm: 105, heightMm: 148 }, { 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 = [ const DATUM_COLORS = [
@ -73,3 +88,32 @@ export function createLineDatum(center: Point, index: number): LineDatum {
label: `Line ${String(index)}`, label: `Line ${String(index)}`,
} }
} }
const DEFAULT_ELLIPSE_POINT_COUNT = 8
export function createEllipseDatum(
center: Point,
index: number,
preset?: CirclePreset,
): EllipseDatum {
const spread = 80
const points: Point[] = []
for (let i = 0; i < DEFAULT_ELLIPSE_POINT_COUNT; i++) {
const t = (2 * Math.PI * i) / DEFAULT_ELLIPSE_POINT_COUNT
points.push({
x: center.x + spread * Math.cos(t),
y: center.y + spread * Math.sin(t),
})
}
return {
id: nanoid(),
type: "ellipse",
points,
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 * Delegates the homography solve to src/lib/solver.ts (alternating
* dimensions and a confidence score (15). Minimum: one rectangle. * minimization around cv.findHomography, which internally runs
* * Levenberg-Marquardt). This file handles I/O: image loading,
* Algorithm: * output-bounds computation, warp, PNG export, progress reporting.
* 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.
*/ */
import cv from "@techstark/opencv-js" import cv from "@techstark/opencv-js"
import type { import type {
AxisCorrection,
Datum,
DatumReport,
DeskewDiagnostics, DeskewDiagnostics,
DeskewInput, DeskewInput,
DeskewResult, DeskewResult,
Point, Point,
RectDatum,
} from "@/types" } from "@/types"
import { solveHomographyForDatums, type Mat3 } from "@/lib/solver"
// Max output dimension in pixels to avoid WASM OOM // Max output dimension in pixels to avoid WASM OOM
// 12288 = ~576MB RGBA at square, but actual images are rarely square // 12288 = ~576MB RGBA at square, but actual images are rarely square
const MAX_OUTPUT_DIM = 12288 const MAX_OUTPUT_DIM = 12288
// ─── OpenCV helpers ────────────────────────────────────────────────────────── // ─── Small helpers ──────────────────────────────────────────────────────────
function pointsToMat(points: Point[]): InstanceType<typeof cv.Mat> { function projectPoints(h: Mat3, pts: Point[]): Point[] {
const flat = points.flatMap((p) => [p.x, p.y]) return pts.map((p) => {
return cv.matFromArray(points.length, 1, cv.CV_32FC2, flat) 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 transformPoints( function mul3x3(A: Mat3, B: Mat3): Mat3 {
points: Point[], const R: number[] = Array<number>(9).fill(0)
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))
}
}
return d
}
/** Row-major 3x3 matrix multiply */
function mul3x3(A: number[], B: number[]): number[] {
const R = Array<number>(9).fill(0)
for (let r = 0; r < 3; r++) { for (let r = 0; r < 3; r++) {
for (let c = 0; c < 3; c++) { for (let c = 0; c < 3; c++) {
let sum = 0 let sum = 0
for (let k = 0; k < 3; k++) { for (let k = 0; k < 3; k++) {
sum += sum += (A[r * 3 + k] ?? 0) * (B[k * 3 + c] ?? 0)
(A[r * 3 + k] ?? 0) * (B[k * 3 + c] ?? 0)
} }
R[r * 3 + c] = sum 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( function canvasToBlob(
canvas: HTMLCanvasElement, canvas: HTMLCanvasElement,
type = "image/png", type = "image/png",
@ -138,33 +63,32 @@ function canvasToBlob(
}) })
} }
// ─── Core ────────────────────────────────────────────────────────────────────
const log = (tag: string, ...args: unknown[]) => { const log = (tag: string, ...args: unknown[]) => {
console.log(`[deskew:${tag}]`, ...args) console.log(`[deskew:${tag}]`, ...args)
} }
// ─── Core ───────────────────────────────────────────────────────────────────
export async function deskewImage( export async function deskewImage(
input: DeskewInput, input: DeskewInput,
): Promise<DeskewResult> { ): Promise<DeskewResult> {
const { image, datums, scalePxPerMm: scale, onProgress } = input 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) => { 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) onProgress?.(step, TOTAL_STEPS, label)
// Yield to let the browser repaint
await new Promise((r) => { await new Promise((r) => {
requestAnimationFrame(r) requestAnimationFrame(r)
}) })
} }
if (datums.length === 0) throw new Error("No datums provided.") if (datums.length === 0) throw new Error("No datums provided.")
const primary = pickPrimary(datums) // Load source image into a canvas
log("primary", primary.label, `${String(primary.widthMm)}×${String(primary.heightMm)}mm`, `conf=${String(primary.confidence)}`)
// Load source image into OpenCV
let srcCanvas: HTMLCanvasElement let srcCanvas: HTMLCanvasElement
if (image instanceof HTMLCanvasElement) { if (image instanceof HTMLCanvasElement) {
srcCanvas = image srcCanvas = image
@ -173,7 +97,10 @@ export async function deskewImage(
srcCanvas = document.createElement("canvas") srcCanvas = document.createElement("canvas")
srcCanvas.width = image.naturalWidth srcCanvas.width = image.naturalWidth
srcCanvas.height = image.naturalHeight 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") const ctx = srcCanvas.getContext("2d")
if (!ctx) throw new Error("Failed to get 2d context") if (!ctx) throw new Error("Failed to get 2d context")
ctx.drawImage(image, 0, 0) ctx.drawImage(image, 0, 0)
@ -181,7 +108,6 @@ export async function deskewImage(
await progress(0, "Loading image into OpenCV") await progress(0, "Loading image into OpenCV")
// All OpenCV mats to clean up
const mats: InstanceType<typeof cv.Mat>[] = [] const mats: InstanceType<typeof cv.Mat>[] = []
const track = <T extends InstanceType<typeof cv.Mat>>(m: T): T => { const track = <T extends InstanceType<typeof cv.Mat>>(m: T): T => {
mats.push(m) mats.push(m)
@ -189,184 +115,41 @@ export async function deskewImage(
} }
try { try {
log("cv.imread", "reading source canvas into cv.Mat")
const src = track(cv.imread(srcCanvas)) const src = track(cv.imread(srcCanvas))
const imgW = src.cols const imgW = src.cols
const imgH = src.rows const imgH = src.rows
log("cv.imread", `done: ${String(imgW)}×${String(imgH)}, type=${String(src.type())}, channels=${String(src.channels())}`) log(
"cv.imread",
// ============================================================ `${String(imgW)}×${String(imgH)}, 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("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 // STEP 2 — Compute output bounds and translation shift
// ============================================================ // ========================================================
await progress(2, "Measuring secondary datums") await progress(2, "Computing output bounds")
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")
const imgCorners: Point[] = [ const imgCorners: Point[] = [
{ x: 0, y: 0 }, { x: 0, y: 0 },
{ x: imgW, y: 0 }, { x: imgW, y: 0 },
{ x: 0, y: imgH }, { x: 0, y: imgH },
{ x: imgW, y: imgH }, { x: imgW, y: imgH },
] ]
const warped = transformPoints(imgCorners, mFinal) const warped = projectPoints(H, imgCorners)
if (warped.length < 4) {
throw new Error(
"Perspective transform produced invalid bounds",
)
}
let xMin = Infinity, let xMin = Infinity
yMin = Infinity, let yMin = Infinity
xMax = -Infinity, let xMax = -Infinity
yMax = -Infinity let yMax = -Infinity
for (const c of warped) { for (const c of warped) {
xMin = Math.min(xMin, c.x) xMin = Math.min(xMin, c.x)
yMin = Math.min(yMin, c.y) yMin = Math.min(yMin, c.y)
@ -376,10 +159,10 @@ export async function deskewImage(
let outW = Math.ceil(xMax - xMin) let outW = Math.ceil(xMax - xMin)
let outH = Math.ceil(yMax - yMin) let outH = Math.ceil(yMax - yMin)
log("step5", `bounds: x=[${xMin.toFixed(1)}, ${xMax.toFixed(1)}], y=[${yMin.toFixed(1)}, ${yMax.toFixed(1)}]`) log(
log("step5", `raw output: ${String(outW)}×${String(outH)} px`) "bounds",
`x=[${xMin.toFixed(1)},${xMax.toFixed(1)}], y=[${yMin.toFixed(1)},${yMax.toFixed(1)}]`,
// Guard against absurd output sizes that crash WASM )
if (outW <= 0 || outH <= 0) { if (outW <= 0 || outH <= 0) {
throw new Error( throw new Error(
`Invalid output dimensions: ${String(outW)}×${String(outH)}`, `Invalid output dimensions: ${String(outW)}×${String(outH)}`,
@ -388,30 +171,31 @@ export async function deskewImage(
let downscale = 1 let downscale = 1
if (outW > MAX_OUTPUT_DIM || outH > MAX_OUTPUT_DIM) { if (outW > MAX_OUTPUT_DIM || outH > MAX_OUTPUT_DIM) {
downscale = MAX_OUTPUT_DIM / Math.max(outW, outH) 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) outW = Math.ceil(outW * downscale)
outH = Math.ceil(outH * 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) // Compose a shift (translation + optional downscale) with H so the
// Translate so the top-left warped corner is at (0,0), // top-left corner of the warped image lands at (0, 0).
// then scale down if we clamped the output size. const tShift: Mat3 = [
const tShift: number[] = [ downscale,
downscale, 0, -xMin * downscale, 0,
0, downscale, -yMin * downscale, -xMin * downscale,
0, 0, 1, 0,
downscale,
-yMin * downscale,
0,
0,
1,
] ]
const mOutData: number[] = mul3x3(tShift, mData) const mOutData = mul3x3(tShift, H)
const mOut = track( const mOut = track(cv.matFromArray(3, 3, cv.CV_64FC1, mOutData))
cv.matFromArray(3, 3, cv.CV_64FC1, mOutData),
)
// ============================================================ // ========================================================
// STEP 6 — Warp // STEP 3 — Warp
// ============================================================ // ========================================================
await progress(6, "Warping image (this may take a moment)") await progress(3, "Warping image")
log("step6", "calling warpPerspective...")
const dstMat = track(new cv.Mat()) const dstMat = track(new cv.Mat())
cv.warpPerspective( cv.warpPerspective(
src, src,
@ -423,36 +207,33 @@ export async function deskewImage(
new cv.Scalar(0, 0, 0, 0), new cv.Scalar(0, 0, 0, 0),
) )
log("step6", `warpPerspective done, dstMat: ${String(dstMat.cols)}×${String(dstMat.rows)}, type=${String(dstMat.type())}`) // ========================================================
// STEP 4 — Export
log("export", "cv.imshow to canvas") // ========================================================
await progress(4, "Encoding output")
const outCanvas = document.createElement("canvas") const outCanvas = document.createElement("canvas")
outCanvas.width = outW outCanvas.width = outW
outCanvas.height = outH outCanvas.height = outH
cv.imshow(outCanvas, dstMat) cv.imshow(outCanvas, dstMat)
log("export", "canvas.toBlob (PNG)")
const blob = await canvasToBlob(outCanvas, "image/png", 0.95) 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 = { const diagnostics: DeskewDiagnostics = {
primaryDatum: primary.label, primaryDatum: solved.primaryLabel,
xCorrection: xCorr, iterations: solved.iterations,
yCorrection: yCorr, finalRMSPercent: solved.rmsPercent,
perDatum: reports, perDatum: solved.reports,
outputWidthPx: outW, outputWidthPx: outW,
outputHeightPx: outH, outputHeightPx: outH,
} }
log("done", "success")
return { correctedImageBlob: blob, diagnostics } return { correctedImageBlob: blob, diagnostics }
} finally { } finally {
// Always clean up all OpenCV mats, even on error
for (const m of mats) { for (const m of mats) {
try { try {
m.delete() m.delete()
} catch { } 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. */ /** Wait for OpenCV WASM to initialize. Call once at app startup. */
export function waitForOpenCV(): Promise<void> { export function waitForOpenCV(): Promise<void> {
log("opencv", "waitForOpenCV called, cvReady=" + String(cvReady))
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
if (cvReady) { if (cvReady) {
log("opencv", "already ready")
resolve() resolve()
return return
} }
// Test if WASM is actually functional by trying to create a mat
try { try {
log("opencv", "probing cv.Mat()...")
const test = new cv.Mat() const test = new cv.Mat()
test.delete() test.delete()
cvReady = true cvReady = true
log("opencv", "probe succeeded, WASM ready")
resolve() resolve()
return return
} catch { } catch {
log("opencv", "probe failed, waiting for onRuntimeInitialized") // Runtime not ready yet
// Not ready yet, wait for callback
} }
cv.onRuntimeInitialized = () => { cv.onRuntimeInitialized = () => {
cvReady = true cvReady = true
log("opencv", "onRuntimeInitialized fired, WASM ready")
resolve() resolve()
} }
}) })

193
src/lib/ellipse-fit.ts Normal file
View File

@ -0,0 +1,193 @@
/**
* ellipse-fit.ts algebraic least-squares ellipse fit from N5 points.
*
* Solves the conic `a·x² + b·xy + c·y² + d·x + e·y + f = 0` by fixing
* `f = 1` (i.e. solving `a·x² + b·xy + c·y² + d·x + e·y = 1`) after
* data-normalising points to centroid = 0 / RMS distance = 1. This is
* numerically well-behaved for the 520-point user-placed case we need,
* and does not require SVD. Fit can silently degenerate to a hyperbola
* if the user's points don't look like an ellipse we detect that and
* return null.
*/
import type { Point } from "@/types"
interface EllipseFit {
center: Point
/** Offset vector from center to the semi-major axis endpoint. */
semiMajor: Point
/** Offset vector from center to the semi-minor axis endpoint
* (perpendicular to semiMajor). */
semiMinor: Point
}
export function fitEllipse(points: Point[]): EllipseFit | null {
if (points.length < 5) return null
// ── Data normalisation: centroid to origin, mean distance to 1. ─────
let sx = 0
let sy = 0
for (const p of points) {
sx += p.x
sy += p.y
}
const cx = sx / points.length
const cy = sy / points.length
let meanDist = 0
for (const p of points) {
meanDist += Math.hypot(p.x - cx, p.y - cy)
}
meanDist /= points.length
if (meanDist < 1e-9) return null
const s = 1 / meanDist
// ── 5×5 normal equations: Σ rᵢ·rᵢᵀ · p = Σ rᵢ, where rᵢ is the
// row [x², xy, y², x, y] in normalised coords and the RHS is 1
// (the f term we fixed). ──────────────────────────────────────
const M: number[][] = [
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
]
const v: number[] = [0, 0, 0, 0, 0]
for (const p of points) {
const nx = (p.x - cx) * s
const ny = (p.y - cy) * s
const r = [nx * nx, nx * ny, ny * ny, nx, ny]
for (let i = 0; i < 5; i++) {
v[i] = (v[i] ?? 0) + (r[i] ?? 0)
for (let j = 0; j < 5; j++) {
const row = M[i] as number[]
row[j] = (row[j] ?? 0) + (r[i] ?? 0) * (r[j] ?? 0)
}
}
}
const sol = solve5(M, v)
if (!sol) return null
const aN = sol[0] ?? 0
const bN = sol[1] ?? 0
const cN = sol[2] ?? 0
const dN = sol[3] ?? 0
const eN = sol[4] ?? 0
// ── Un-normalise back to original image coords. ─────────────────────
// x' = (x cx)·s ; y' = (y cy)·s ; the conic
// aN·x'² + bN·x'y' + cN·y'² + dN·x' + eN·y' 1 = 0
// expands to `a x² + b xy + c y² + d x + e y + f = 0` with
const s2 = s * s
const a = aN * s2
const b = bN * s2
const c = cN * s2
const d = dN * s - 2 * aN * s2 * cx - bN * s2 * cy
const e = eN * s - bN * s2 * cx - 2 * cN * s2 * cy
const f =
aN * s2 * cx * cx +
bN * s2 * cx * cy +
cN * s2 * cy * cy -
dN * s * cx -
eN * s * cy -
1
// ── Geometric extraction. ───────────────────────────────────────────
// Quadratic-form matrix is [[a, b/2], [b/2, c]] because the xy coef
// is split symmetrically; det = ac b²/4. An ellipse needs det > 0.
const det = a * c - (b * b) / 4
if (det <= 0) return null
// Center from ∇F = 0:
// 2a·x₀ + b·y₀ + d = 0
// b·x₀ + 2c·y₀ + e = 0
const denom = 4 * a * c - b * b
if (Math.abs(denom) < 1e-20) return null
const x0 = (b * e - 2 * c * d) / denom
const y0 = (b * d - 2 * a * e) / denom
// Constant after centering: F(x₀, y₀) = f (a·x₀² + b·x₀·y₀ + c·y₀²),
// so the centered form is a·u² + b·uv + c·v² = K where K = -F(x₀, y₀).
const K = a * x0 * x0 + b * x0 * y0 + c * y0 * y0 - f
if (K <= 0) return null
// Eigen-decompose the quadratic-form matrix to get semi-axis directions.
// λ₁ ≥ λ₂ ≥ 0 ; semi-axis length = √(K / λ). Smaller λ → semi-major.
const trace = a + c
const diff = a - c
const disc = Math.sqrt(diff * diff + b * b)
const lMax = (trace + disc) / 2
const lMin = (trace - disc) / 2
if (lMin <= 0) return null
const rMajor = Math.sqrt(K / lMin)
const rMinor = Math.sqrt(K / lMax)
// Eigenvector for lMin (semi-major axis direction):
// (a lMin) vₓ + (b/2) v_y = 0 ⇒ v = (b/2, lMin a)
// If b is ≈ 0 the matrix is already diagonal, so axes are aligned.
let ux = 0
let uy = 0
if (Math.abs(b) > 1e-12) {
ux = b / 2
uy = lMin - a
} else if (a <= c) {
ux = 1
uy = 0
} else {
ux = 0
uy = 1
}
const n = Math.hypot(ux, uy)
if (n < 1e-12) return null
ux /= n
uy /= n
return {
center: { x: x0, y: y0 },
semiMajor: { x: ux * rMajor, y: uy * rMajor },
// 90° rotation gives the perpendicular (semi-minor) direction.
semiMinor: { x: -uy * rMinor, y: ux * rMinor },
}
}
/** 5×5 linear solve via Gauss-Jordan elimination with partial pivoting.
* Returns null if the matrix is (near-)singular. */
function solve5(M: number[][], v: number[]): number[] | null {
const n = 5
const aug: number[][] = []
for (let i = 0; i < n; i++) {
const row = M[i] as number[]
aug.push([row[0] ?? 0, row[1] ?? 0, row[2] ?? 0, row[3] ?? 0, row[4] ?? 0, v[i] ?? 0])
}
for (let col = 0; col < n; col++) {
let pivot = col
let pivotAbs = Math.abs((aug[col] as number[])[col] ?? 0)
for (let r = col + 1; r < n; r++) {
const vv = Math.abs((aug[r] as number[])[col] ?? 0)
if (vv > pivotAbs) {
pivotAbs = vv
pivot = r
}
}
if (pivotAbs < 1e-12) return null
if (pivot !== col) {
const tmp = aug[col]
aug[col] = aug[pivot] as number[]
aug[pivot] = tmp as number[]
}
const pivRow = aug[col] as number[]
const pv = pivRow[col] as number
for (let c2 = col; c2 <= n; c2++) {
pivRow[c2] = (pivRow[c2] as number) / pv
}
for (let r = 0; r < n; r++) {
if (r === col) continue
const rr = aug[r] as number[]
const factor = rr[col] as number
if (factor === 0) continue
for (let c2 = col; c2 <= n; c2++) {
rr[c2] = (rr[c2] as number) - factor * (pivRow[c2] as number)
}
}
}
return aug.map((row) => (row as number[])[n] as number)
}

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

@ -0,0 +1,909 @@
/**
* 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
}
/** Inverse of a 3×3 homography (row-major). Returns null if singular. */
function invertMat3(h: Mat3): Mat3 | null {
const a = h[0]
const b = h[1]
const c = h[2]
const d = h[3]
const e = h[4]
const f = h[5]
const g = h[6]
const hh = h[7]
const i = h[8]
const det =
a * (e * i - f * hh) -
b * (d * i - f * g) +
c * (d * hh - e * g)
if (Math.abs(det) < 1e-20) return null
const invDet = 1 / det
return [
(e * i - f * hh) * invDet,
(c * hh - b * i) * invDet,
(b * f - c * e) * invDet,
(f * g - d * i) * invDet,
(a * i - c * g) * invDet,
(c * d - a * f) * invDet,
(d * hh - e * g) * invDet,
(b * g - a * hh) * invDet,
(a * e - b * d) * invDet,
]
}
// ─── 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.")
// User-flagged world-axis reference wins regardless of type priority.
for (const d of datums) {
if (d.type === "rectangle" && d.isAxisReference) {
return { kind: "rect", datum: d }
}
if (d.type === "line" && d.axisRole) {
return { kind: "line", datum: d }
}
}
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]
// Default: line defines world +x. With axisRole === "y", endpoints land
// along world +y and the synthetic perpendicular lands along +x.
const targets: [Point, Point, Point, Point] =
line.axisRole === "y"
? [
{ x: 0, y: 0 },
{ x: 0, y: L },
{ x: L, y: 0 },
{ x: L, y: L },
]
: [
{ 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 {
// E is the image-space ellipse conic; H maps image → output. The
// output-space conic we want to check for circularity is therefore
// C = H^{-T} · E · H^{-1}
// (points q on C iff H^{-1}·q lands on E). Using H^T·E·H computes the
// wrong conic and produces meaningless diagnostic numbers.
const E = ellipseMatrix(ellipse.center, ellipse.axisEndA, ellipse.axisEndB)
const Hi = invertMat3(H)
const zeroReport: RawReport = {
label: ellipse.label,
type: "ellipse",
expectedMm: ellipse.diameterMm,
measuredMm: 0,
residuals: [0, 0, 0],
details: "(H singular, cannot compute)",
}
if (!Hi) return zeroReport
const HiT = [
[Hi[0], Hi[3], Hi[6]],
[Hi[1], Hi[4], Hi[7]],
[Hi[2], Hi[5], Hi[8]],
]
const Hm = [
[Hi[0], Hi[1], Hi[2]],
[Hi[3], Hi[4], Hi[5]],
[Hi[6], Hi[7], Hi[8]],
]
// C = HiT · E · Hm (= H^{-T} E H^{-1})
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 += (HiT[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

@ -1,8 +1,9 @@
import { defineStore } from "pinia" import { defineStore } from "pinia"
import { ref, computed } from "vue" import { ref, computed } from "vue"
import type { AppStep, Datum, DeskewResult, ExifData } from "@/types" import type { AppStep, Datum, DeskewResult, ExifData, Point } from "@/types"
import { DEFAULT_SCALE_PX_PER_MM } from "@/types" import { DEFAULT_SCALE_PX_PER_MM } from "@/types"
import { loadSettings } from "@/lib/settings-cache" import { loadSettings } from "@/lib/settings-cache"
import { fitEllipse } from "@/lib/ellipse-fit"
export const useAppStore = defineStore("app", () => { export const useAppStore = defineStore("app", () => {
const cached = loadSettings() const cached = loadSettings()
@ -29,7 +30,8 @@ export const useAppStore = defineStore("app", () => {
if (!canProceedToStep3.value || datums.value.length === 0) return false if (!canProceedToStep3.value || datums.value.length === 0) return false
return datums.value.every((d) => { return datums.value.every((d) => {
if (d.type === "rectangle") return d.widthMm > 0 && d.heightMm > 0 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
}) })
}) })
@ -65,6 +67,73 @@ export const useAppStore = defineStore("app", () => {
} }
} }
/** Update an ellipse datum's points and refresh the cached best-fit
* ellipse (`center`/`axisEndA`/`axisEndB`). If the fit degenerates
* (fewer than 5 usable points, collinear, or the system is singular),
* we keep the previous cached axes so downstream consumers never see
* a garbage fit. */
function updateEllipsePoints(id: string, points: Point[]) {
const idx = datums.value.findIndex((d) => d.id === id)
const existing = datums.value[idx]
if (!existing || existing.type !== "ellipse") return
const fit = fitEllipse(points)
if (!fit) {
datums.value[idx] = { ...existing, points }
return
}
datums.value[idx] = {
...existing,
points,
center: fit.center,
axisEndA: {
x: fit.center.x + fit.semiMajor.x,
y: fit.center.y + fit.semiMajor.y,
},
axisEndB: {
x: fit.center.x + fit.semiMinor.x,
y: fit.center.y + fit.semiMinor.y,
},
}
}
/** Set (or clear) the world-axis role on a datum, enforcing that at
* most one datum holds the role at a time.
* `role`: "rect" rectangle.isAxisReference = true
* "x"/"y" line.axisRole = "x"|"y"
* null clear the role on `id` (no-op if it wasn't set). */
function setAxisRole(
id: string,
role: "rect" | "x" | "y" | null,
) {
// Clear any existing flag on other datums.
for (let i = 0; i < datums.value.length; i++) {
const d = datums.value[i]
if (!d || d.id === id) continue
if (d.type === "rectangle" && d.isAxisReference) {
datums.value[i] = { ...d, isAxisReference: false }
} else if (d.type === "line" && d.axisRole) {
datums.value[i] = { ...d, axisRole: null }
}
}
const idx = datums.value.findIndex((d) => d.id === id)
if (idx === -1) return
const target = datums.value[idx]
if (!target) return
if (role === null) {
if (target.type === "rectangle") {
datums.value[idx] = { ...target, isAxisReference: false }
} else if (target.type === "line") {
datums.value[idx] = { ...target, axisRole: null }
}
return
}
if (role === "rect" && target.type === "rectangle") {
datums.value[idx] = { ...target, isAxisReference: true }
} else if ((role === "x" || role === "y") && target.type === "line") {
datums.value[idx] = { ...target, axisRole: role }
}
}
function removeDatum(id: string) { function removeDatum(id: string) {
datums.value = datums.value.filter((d) => d.id !== id) datums.value = datums.value.filter((d) => d.id !== id)
if (selectedDatumId.value === id) { if (selectedDatumId.value === id) {
@ -118,6 +187,8 @@ export const useAppStore = defineStore("app", () => {
goToStep, goToStep,
addDatum, addDatum,
updateDatum, updateDatum,
updateEllipsePoints,
setAxisRole,
removeDatum, removeDatum,
setResult, setResult,
setFileHash, setFileHash,

View File

@ -11,8 +11,13 @@ export interface RectDatum {
heightMm: number heightMm: number
confidence: 1 | 2 | 3 | 4 | 5 confidence: 1 | 2 | 3 | 4 | 5
label: string label: string
/** When true, this rectangle defines the world axes: TLTR is +x,
* TLBL is +y. At most one datum in the set may hold the axis role. */
isAxisReference?: boolean
} }
export type LineAxisRole = "x" | "y" | null
export interface LineDatum { export interface LineDatum {
id: string id: string
type: "line" type: "line"
@ -20,12 +25,38 @@ export interface LineDatum {
lengthMm: number lengthMm: number
confidence: 1 | 2 | 3 | 4 | 5 confidence: 1 | 2 | 3 | 4 | 5
label: string label: string
/** Marks this line as the world axis reference. "x" maps endpoint[0]
* to origin and endpoint[1] to (+L, 0); "y" maps to (0, +L). At most
* one datum in the set may hold the axis role. */
axisRole?: LineAxisRole
} }
export type Datum = RectDatum | LineDatum export interface EllipseDatum {
id: string
type: "ellipse"
/** User-placed points on the circle contour (5, default 8). The
* best-fit ellipse is refitted each time this array changes; the
* `center`/`axisEndA`/`axisEndB` fields below are that fit cached
* on the datum for use by renderers and the solver. */
points: Point[]
center: Point
/** Offset endpoint of the fitted semi-major axis from `center`. */
axisEndA: Point
/** Offset endpoint of the fitted semi-minor axis from `center`
* (perpendicular to axisEndA). */
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 ConfidenceScore = 1 | 2 | 3 | 4 | 5
export type DatumType = Datum["type"]
export interface ExifData { export interface ExifData {
make?: string make?: string
model?: string model?: string
@ -53,24 +84,26 @@ export interface DeskewInput {
onProgress?: (step: number, total: number, label: string) => void onProgress?: (step: number, total: number, label: string) => void
} }
export interface AxisCorrection {
ratio: number
totalWeight: number
}
export interface DatumReport { export interface DatumReport {
label: string label: string
type: "rectangle" | "line" type: DatumType
measuredMm: number /** Representative expected dimension in mm (widthMm / lengthMm / diameterMm). */
expectedMm: number expectedMm: number
/** Representative measured dimension in mm under the solved H. */
measuredMm: number
/** Overall residual magnitude expressed as a percentage. */
errorPercent: number 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 { export interface DeskewDiagnostics {
/** Label of the rectangle used to fix the output gauge. */
primaryDatum: string primaryDatum: string
xCorrection: AxisCorrection /** Number of outer alternating-minimization iterations the solver ran. */
yCorrection: AxisCorrection iterations: number
/** Final weighted RMS residual across all datums, as a percentage. */
finalRMSPercent: number
perDatum: DatumReport[] perDatum: DatumReport[]
outputWidthPx: number outputWidthPx: number
outputHeightPx: number outputHeightPx: number
@ -91,3 +124,8 @@ export interface RectPreset {
widthMm: number widthMm: number
heightMm: number heightMm: number
} }
export interface CirclePreset {
label: string
diameterMm: number
}