Compare commits

..

No commits in common. "e94a814335476afb457adf05b7b2560565bb9844" and "a71c8c73ef6ab6923bcaace69f868c3de1319dc1" have entirely different histories.

12 changed files with 681 additions and 2957 deletions

File diff suppressed because it is too large Load Diff

View File

@ -50,36 +50,23 @@ 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 = datumPoints(datum) const points = datum.type === "rectangle" ? datum.corners : datum.endpoints
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: radius: visualRadius,
datum.type === "ellipse" && pIdx === 0 fill: color,
? visualRadius * 1.4
: visualRadius,
fill: datum.type === "ellipse" && pIdx === 0 ? "transparent" : color,
stroke: isSelected ? "#fff" : color, stroke: isSelected ? "#fff" : color,
strokeWidth: (datum.type === "ellipse" && pIdx === 0 ? 2.5 : 1.5) / strokeWidth: 1.5 / scale.value,
scale.value,
draggable: true, draggable: true,
_datumId: datum.id, _datumId: datum.id,
_pointIndex: pIdx, _pointIndex: pIdx,
@ -87,30 +74,9 @@ 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 [
@ -122,55 +88,22 @@ function getLineConfigs(datum: Datum, dIdx: number) {
datum.endpoints[1].y, datum.endpoints[1].y,
], ],
stroke: color, stroke: color,
strokeWidth, strokeWidth: (isSelected ? 3 : 2) / scale.value,
dash, dash: isSelected ? [] : [8 / scale.value, 4 / scale.value],
}, },
] ]
} }
if (datum.type === "rectangle") { // Rectangle: draw 4 edges
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, strokeWidth: (isSelected ? 3 : 2) / scale.value,
closed: true, closed: true,
dash, dash: isSelected ? [] : [8 / scale.value, 4 / scale.value],
},
]
}
// 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,
}, },
] ]
} }
@ -184,18 +117,13 @@ 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 if (datum.type === "line") { } else {
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 {
@ -227,25 +155,10 @@ 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 if (datum.type === "line") { } else {
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,8 +33,7 @@ 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
if (d.type === "line") return d.lengthMm <= 0 return d.lengthMm <= 0
return d.diameterMm <= 0
}), }),
) )

View File

@ -2,10 +2,8 @@
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"
@ -33,10 +31,6 @@ 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)
@ -48,15 +42,6 @@ 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 })
} }
@ -80,24 +65,8 @@ 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>
@ -108,14 +77,14 @@ function axisBadge(datum: Datum): string | null {
<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-3 gap-2"> <div class="grid grid-cols-2 gap-2">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
class="w-full" class="w-full"
@click="addRect()" @click="addRect()"
> >
+ Rect + Rectangle
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@ -125,14 +94,6 @@ function axisBadge(datum: Datum): string | null {
> >
+ 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
@ -145,16 +106,6 @@ function axisBadge(datum: Datum): string | null {
> >
{{ 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>
@ -196,14 +147,9 @@ function axisBadge(datum: Datum): string | null {
:style="{ backgroundColor: getDatumColor(idx) }" :style="{ backgroundColor: getDatumColor(idx) }"
/> />
<Badge variant="outline" class="text-xs"> <Badge variant="outline" class="text-xs">
{{ typeBadge(datum) }} {{
</Badge> datum.type === "rectangle" ? "Rect" : "Line"
<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)
@ -316,7 +262,7 @@ function axisBadge(datum: Datum): string | null {
/> />
</div> </div>
</div> </div>
<div v-else-if="datum.type === 'line'"> <div v-else>
<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)"
@ -330,83 +276,6 @@ function axisBadge(datum: Datum): string | null {
@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 { Datum } from "@/types" import type { RectDatum } 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,80 +59,40 @@ watch(scaleInput, (v) => {
const MAX_AUTO_SCALE_DIM = 8192 const MAX_AUTO_SCALE_DIM = 8192
/** Estimate the image-pixels-per-mm implied by a single datum. Picks the function computeAutoScale(): number {
* best datum by type priority (rect > line > ellipse) and then confidence. const img = store.loadedImage
* Returns null if no datum gives a usable scale. */ const primary = store.datums.find(
function pickScaleRef(): { srcPxPerMm: number } | null { (d): d is RectDatum => d.type === "rectangle",
const axisFlagged = store.datums.find(
(d) =>
(d.type === "rectangle" && d.isAxisReference) ||
(d.type === "line" && d.axisRole),
) )
const best = if (!img || !primary) return DEFAULT_SCALE_PX_PER_MM
axisFlagged ??
[...store.datums].sort((a, b) => { // Approximate source-pixel size of the datum
const rank = (d: Datum) => const c = primary.corners
d.type === "rectangle" const datumSrcW = Math.max(
? 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 srcH = Math.max( const datumSrcH = 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
const sy = srcH / best.heightMm
return { srcPxPerMm: Math.max(sx, sy) }
}
if (best.type === "line") {
if (best.lengthMm <= 0) return null
const L = Math.hypot(
best.endpoints[1].x - best.endpoints[0].x,
best.endpoints[1].y - best.endpoints[0].y,
)
return { srcPxPerMm: L / best.lengthMm }
}
if (best.diameterMm <= 0) return null
// Approximate the ellipse's "diameter" as max of the two semi-axis lengths × 2
const vA = Math.hypot(
best.axisEndA.x - best.center.x,
best.axisEndA.y - best.center.y,
)
const vB = Math.hypot(
best.axisEndB.x - best.center.x,
best.axisEndB.y - best.center.y,
)
return { srcPxPerMm: (2 * Math.max(vA, vB)) / best.diameterMm }
}
function computeAutoScale(): number { // Scale that would make the datum the same pixel size as in source
const img = store.loadedImage const sx =
const ref = pickScaleRef() datumSrcW > 0 ? datumSrcW / primary.widthMm : 0
if (!img || !ref || ref.srcPxPerMm <= 0) return DEFAULT_SCALE_PX_PER_MM const sy =
datumSrcH > 0 ? datumSrcH / primary.heightMm : 0
let autoScale = ref.srcPxPerMm let autoScale = Math.max(sx, sy)
// Clamp so the full output doesn't exceed MAX_AUTO_SCALE_DIM // Clamp so the full output doesn't exceed MAX_AUTO_SCALE_DIM
const estMax = Math.max(img.naturalWidth, img.naturalHeight) const estW = img.naturalWidth * autoScale / Math.max(datumSrcW / primary.widthMm, 0.001)
if (estMax > MAX_AUTO_SCALE_DIM) { const estH = img.naturalHeight * autoScale / Math.max(datumSrcH / primary.heightMm, 0.001)
autoScale *= MAX_AUTO_SCALE_DIM / estMax if (estW > MAX_AUTO_SCALE_DIM || estH > MAX_AUTO_SCALE_DIM) {
autoScale *= MAX_AUTO_SCALE_DIM / Math.max(estW, estH)
} }
// The scale input is integer-only; floor so the shown value round-trips. // Round to a clean number
return Math.max(1, Math.floor(autoScale)) return Math.max(1, Math.round(autoScale * 10) / 10)
} }
onMounted(() => { onMounted(() => {
@ -174,18 +134,39 @@ 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 ref = pickScaleRef() const primary = store.datums.find(
(d): d is RectDatum => d.type === "rectangle",
)
const img = store.loadedImage const img = store.loadedImage
if (!ref || !img || store.scalePxPerMm <= 0 || ref.srcPxPerMm <= 0) if (!primary || !img || store.scalePxPerMm <= 0) return null
return null
// source-pixels-per-mm implied by the datum vs. requested output px/mm // Datum dimensions in output pixels
const avgScale = store.scalePxPerMm / ref.srcPxPerMm const datumOutW = primary.widthMm * store.scalePxPerMm
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 } return { w, h, mb, datumW: Math.round(datumOutW), datumH: Math.round(datumOutH) }
}) })
const tooLarge = computed( const tooLarge = computed(
@ -362,6 +343,9 @@ async function download() {
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} }
function hasRects(): boolean {
return store.datums.some((d) => d.type === "rectangle")
}
</script> </script>
<template> <template>
@ -395,7 +379,6 @@ async function download() {
: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
@ -468,9 +451,7 @@ async function download() {
<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.type === "line" : `${datum.lengthMm}mm`
? `${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
@ -478,11 +459,11 @@ async function download() {
</Badge> </Badge>
</div> </div>
<p <p
v-if="store.datums.length === 0" v-if="!hasRects()"
class="mt-3 text-sm text-destructive" class="mt-3 text-sm text-destructive"
> >
Add at least one datum (rectangle, line, or circle) to run At least one rectangle datum is required for perspective
the correction. correction.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@ -493,7 +474,7 @@ async function download() {
size="lg" size="lg"
:disabled=" :disabled="
store.isProcessing || store.isProcessing ||
store.datums.length === 0 || !hasRects() ||
tooLarge || tooLarge ||
!scaleValid !scaleValid
" "
@ -604,7 +585,7 @@ async function download() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent class="space-y-4"> <CardContent class="space-y-4">
<!-- Solver summary --> <!-- Axis corrections -->
<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"
@ -612,19 +593,24 @@ async function download() {
<p <p
class="text-xs font-medium uppercase tracking-wide text-muted-foreground" class="text-xs font-medium uppercase tracking-wide text-muted-foreground"
> >
Residual (RMS) X-axis correction
</p> </p>
<p class="font-mono text-lg font-semibold"> <p class="font-mono text-lg font-semibold">
{{ {{
store.deskewResult.diagnostics.finalRMSPercent.toFixed( (
3, store.deskewResult.diagnostics
) .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"
> >
across all datums w={{
store.deskewResult.diagnostics.xCorrection.totalWeight.toFixed(
1,
)
}}
</p> </p>
</div> </div>
<div <div
@ -633,17 +619,24 @@ async function download() {
<p <p
class="text-xs font-medium uppercase tracking-wide text-muted-foreground" class="text-xs font-medium uppercase tracking-wide text-muted-foreground"
> >
Iterations Y-axis correction
</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"
> >
outer alternating passes w={{
store.deskewResult.diagnostics.yCorrection.totalWeight.toFixed(
1,
)
}}
</p> </p>
</div> </div>
</div> </div>
@ -668,7 +661,7 @@ async function download() {
<TableHead class="text-right" <TableHead class="text-right"
>Error</TableHead >Error</TableHead
> >
<TableHead>Residual breakdown</TableHead> <TableHead>Axis</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@ -688,28 +681,24 @@ async function download() {
> >
</TableCell> </TableCell>
<TableCell class="font-mono text-right">{{ <TableCell class="font-mono text-right">{{
report.expectedMm.toFixed(2) report.expectedMm.toFixed(1)
}}</TableCell> }}</TableCell>
<TableCell class="font-mono text-right">{{ <TableCell class="font-mono text-right">{{
report.measuredMm.toFixed(2) report.measuredMm.toFixed(1)
}}</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(2) }}% {{ report.errorPercent.toFixed(1) }}%
</TableCell>
<TableCell
class="font-mono text-xs text-muted-foreground"
>
{{ report.details }}
</TableCell> </TableCell>
<TableCell>{{
report.axisContribution
}}</TableCell>
</TableRow> </TableRow>
</TableBody> </TableBody>
</Table> </Table>
@ -754,49 +743,43 @@ async function download() {
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 highest-confidence rectangle The primary rectangle datum
gives a closed-form warm start via defines a
<code
class="rounded bg-muted px-1 py-0.5 font-mono text-xs text-foreground"
>cv::getPerspectiveTransform</code
>, fixing the output
orientation.
</li>
<li>
Each datum is turned into
<strong <strong
class="text-foreground/80" class="text-foreground/80"
>shape-based point >homography</strong
correspondences</strong
> >
whose target positions are &mdash; a 3&times;3 projective
recomputed from the current transform mapping the
homography on every outer pass: quadrilateral in the source
Procrustes-fit ideal rectangles, image to a true rectangle at
midpoint-preserving line rescales, the specified real-world
and radially-snapped ellipse dimensions.
samples that force circles to stay
circular.
</li> </li>
<li> <li>
<code Secondary datums (additional
class="rounded bg-muted px-1 py-0.5 font-mono text-xs text-foreground" rectangles or line segments)
>cv::findHomography</code provide
<strong
class="text-foreground/80"
>weighted correction
factors</strong
> >
refines the homography by for the X and Y axes. Each
Levenberg&ndash;Marquardt on those secondary datum's contribution
correspondences; confidence drives is weighted by its confidence
per-datum replication. The loop score, refining the scale in
stops once the homography stops each axis independently.
moving.
</li> </li>
<li> <li>
A single The final correction is applied
as 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
> >
produces the output at the call via OpenCV WASM, producing
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, EllipseDatum, Point } from "@/types" import type { Datum } from "@/types"
const KEY_PREFIX = "skwik-datums-" const KEY_PREFIX = "skwik-datums-"
@ -17,38 +17,12 @@ 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
const parsed = JSON.parse(raw) as Datum[] return 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,27 +1,12 @@
import { nanoid } from "nanoid" import { nanoid } from "nanoid"
import type { import type { LineDatum, Point, RectDatum, RectPreset } from "@/types"
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×10 cm", widthMm: 150, heightMm: 100 }, { label: "15\u00D710 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 = [
@ -88,32 +73,3 @@ 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,51 +1,126 @@
/** /**
* deskew.ts perspective correction pipeline. * deskew.ts Browser-based perspective correction using OpenCV.js (WASM)
* *
* Delegates the homography solve to src/lib/solver.ts (alternating * Accepts N datums (rectangles and/or lines), each with known real-world
* minimization around cv.findHomography, which internally runs * dimensions and a confidence score (15). Minimum: one rectangle.
* Levenberg-Marquardt). This file handles I/O: image loading, *
* output-bounds computation, warp, PNG export, progress reporting. * Algorithm:
* 1. Pick the highest-confidence rectangle as primary reference.
* 2. getPerspectiveTransform from its 4 corners initial correction.
* 3. Project all other datums through that transform and measure them.
* 4. Compute per-axis weighted scale corrections from all secondary datums.
* 5. Fold corrections into the destination rectangle, recompute
* getPerspectiveTransform single clean perspective matrix.
* 6. warpPerspective the image.
*/ */
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
// ─── Small helpers ────────────────────────────────────────────────────────── // ─── OpenCV helpers ──────────────────────────────────────────────────────────
function projectPoints(h: Mat3, pts: Point[]): Point[] { function pointsToMat(points: Point[]): InstanceType<typeof cv.Mat> {
return pts.map((p) => { const flat = points.flatMap((p) => [p.x, p.y])
const w = h[6] * p.x + h[7] * p.y + h[8] return cv.matFromArray(points.length, 1, cv.CV_32FC2, flat)
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 mul3x3(A: Mat3, B: Mat3): Mat3 { function transformPoints(
const R: number[] = Array<number>(9).fill(0) points: Point[],
M: InstanceType<typeof cv.Mat>,
): Point[] {
const src = pointsToMat(points)
const dst = new cv.Mat()
cv.perspectiveTransform(src, dst, M)
const result: Point[] = []
const data = dst.data32F
for (let i = 0; i < points.length; i++) {
const x = data[i * 2]
const y = data[i * 2 + 1]
if (x === undefined || y === undefined) continue
result.push({ x, y })
}
src.delete()
dst.delete()
return result
}
function dist(a: Point, b: Point): number {
return Math.hypot(b.x - a.x, b.y - a.y)
}
function readMat3x3(M: InstanceType<typeof cv.Mat>): number[] {
const d: number[] = []
for (let r = 0; r < 3; r++) {
for (let c = 0; c < 3; c++) {
d.push(M.doubleAt(r, c))
}
}
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 += (A[r * 3 + k] ?? 0) * (B[k * 3 + c] ?? 0) sum +=
(A[r * 3 + k] ?? 0) * (B[k * 3 + c] ?? 0)
} }
R[r * 3 + c] = sum R[r * 3 + c] = sum
} }
} }
return R as Mat3 return R
} }
// ─── 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",
@ -63,32 +138,33 @@ 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( log("start", `${String(datums.length)} datums, scale=${String(scale)} px/mm`)
"start",
`${String(datums.length)} datums, scale=${String(scale)} px/mm`,
)
const TOTAL_STEPS = 5 const TOTAL_STEPS = 7
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.")
// Load source image into a canvas const primary = pickPrimary(datums)
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
@ -97,10 +173,7 @@ 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( log("input", `img ${String(image.naturalWidth)}×${String(image.naturalHeight)}, drawing to canvas`)
"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)
@ -108,6 +181,7 @@ 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)
@ -115,41 +189,184 @@ 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( log("cv.imread", `done: ${String(imgW)}×${String(imgH)}, type=${String(src.type())}, channels=${String(src.channels())}`)
"cv.imread",
`${String(imgW)}×${String(imgH)}, channels=${String(src.channels())}`,
)
// ======================================================== // ============================================================
// STEP 1 — Solve homography (outer loop around findHomography) // STEP 1 — Initial perspective correction from primary rect
// ======================================================== // ============================================================
await progress(1, "Solving homography") await progress(1, "Computing initial homography")
const solved = solveHomographyForDatums(datums, scale) const pw = primary.widthMm * scale
const H = solved.H const ph = primary.heightMm * scale
log( log("step1", `dest rect: ${pw.toFixed(1)}×${ph.toFixed(1)} px`)
"solve",
`primary=${solved.primaryLabel} (${solved.primaryType}), iters=${String(solved.iterations)}, rms=${solved.rmsPercent.toFixed(3)}%`,
)
// ======================================================== const algoCorners = cornersToAlgoOrder(primary.corners)
// STEP 2 — Compute output bounds and translation shift log("step1", `corners (algo order): ${JSON.stringify(algoCorners)}`)
// ======================================================== const srcPts = track(pointsToMat(algoCorners))
await progress(2, "Computing output bounds")
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),
)
log("step1", `mInit type=${String(mInit.type())}, rows=${String(mInit.rows)}, cols=${String(mInit.cols)}`)
// ============================================================
// STEP 2 — Measure secondary datums, accumulate corrections
// ============================================================
await progress(2, "Measuring secondary datums")
let xWSum = 0,
xWTotal = 0
let yWSum = 0,
yWTotal = 0
const reports: DatumReport[] = []
for (const datum of datums) {
const w = datum.confidence
if (datum === primary) {
reports.push({
label: datum.label,
type: "rectangle",
measuredMm: datum.widthMm,
expectedMm: datum.widthMm,
errorPercent: 0,
axisContribution: "both",
})
continue
}
if (datum.type === "line") {
const pts = transformPoints(
datum.endpoints as Point[],
mInit,
)
const s = pts[0]
const e = pts[1]
if (!s || !e) continue
const dx = Math.abs(e.x - s.x)
const dy = Math.abs(e.y - s.y)
const measured = dist(s, e)
const expected = datum.lengthMm * scale
const ratio = expected / measured
const total = dx + dy
if (total > 1e-6) {
const xFrac = dx / total
const yFrac = dy / total
xWSum += ratio * w * xFrac
xWTotal += w * xFrac
yWSum += ratio * w * yFrac
yWTotal += w * yFrac
}
reports.push({
label: datum.label,
type: "line",
measuredMm: measured / scale,
expectedMm: datum.lengthMm,
errorPercent: Math.abs(1 - ratio) * 100,
axisContribution: dx > dy ? "x" : "y",
})
} else {
const ac = cornersToAlgoOrder(datum.corners)
const pts = transformPoints(
[ac[0], ac[1], ac[2]],
mInit,
)
const tl = pts[0]
const tr = pts[1]
const bl = pts[2]
if (!tl || !tr || !bl) continue
const mW = dist(tl, tr)
const mH = dist(tl, bl)
const xR = (datum.widthMm * scale) / mW
const yR = (datum.heightMm * scale) / mH
xWSum += xR * w
xWTotal += w
yWSum += yR * w
yWTotal += w
reports.push({
label: datum.label,
type: "rectangle",
measuredMm: mW / scale,
expectedMm: datum.widthMm,
errorPercent:
(Math.abs(1 - xR) + Math.abs(1 - yR)) * 50,
axisContribution: "both",
})
}
}
// ============================================================
// STEP 3 — Weighted corrections (1.0 = no secondary data)
// ============================================================
await progress(3, "Computing axis corrections")
const xCorr: AxisCorrection = {
ratio: xWTotal > 0 ? xWSum / xWTotal : 1.0,
totalWeight: xWTotal,
}
const yCorr: AxisCorrection = {
ratio: yWTotal > 0 ? yWSum / yWTotal : 1.0,
totalWeight: yWTotal,
}
log("step3", `xCorr=${xCorr.ratio.toFixed(4)} (w=${xCorr.totalWeight.toFixed(1)}), yCorr=${yCorr.ratio.toFixed(4)} (w=${yCorr.totalWeight.toFixed(1)})`)
// ============================================================
// STEP 4 — Fold corrections, recompute transform
// ============================================================
await progress(4, "Recomputing final transform")
const pwFinal = pw * xCorr.ratio
const phFinal = ph * yCorr.ratio
log("step4", `final dest rect: ${pwFinal.toFixed(1)}×${phFinal.toFixed(1)} px`)
const dstFinal = track(
pointsToMat([
{ x: 0, y: 0 },
{ x: pwFinal, y: 0 },
{ x: 0, y: phFinal },
{ x: pwFinal, y: phFinal },
]),
)
log("step4", "calling getPerspectiveTransform (final)")
const mFinal = track(
cv.getPerspectiveTransform(srcPts, dstFinal),
)
log("step4", `mFinal type=${String(mFinal.type())}, rows=${String(mFinal.rows)}, cols=${String(mFinal.cols)}`)
// ============================================================
// STEP 5 — Output bounds + translation shift
// ============================================================
await progress(5, "Computing output bounds")
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 = projectPoints(H, imgCorners) const warped = transformPoints(imgCorners, mFinal)
if (warped.length < 4) {
throw new Error(
"Perspective transform produced invalid bounds",
)
}
let xMin = Infinity let xMin = Infinity,
let yMin = Infinity yMin = Infinity,
let xMax = -Infinity xMax = -Infinity,
let yMax = -Infinity 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)
@ -159,10 +376,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( log("step5", `bounds: x=[${xMin.toFixed(1)}, ${xMax.toFixed(1)}], y=[${yMin.toFixed(1)}, ${yMax.toFixed(1)}]`)
"bounds", log("step5", `raw output: ${String(outW)}×${String(outH)} px`)
`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)}`,
@ -171,31 +388,30 @@ 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)`)
// Compose a shift (translation + optional downscale) with H so the const mData: number[] = readMat3x3(mFinal)
// top-left corner of the warped image lands at (0, 0). // Translate so the top-left warped corner is at (0,0),
const tShift: Mat3 = [ // then scale down if we clamped the output size.
downscale, const tShift: number[] = [
0, downscale, 0, -xMin * downscale,
-xMin * downscale, 0, downscale, -yMin * downscale,
0, 0, 0, 1,
downscale,
-yMin * downscale,
0,
0,
1,
] ]
const mOutData = mul3x3(tShift, H) const mOutData: number[] = mul3x3(tShift, mData)
const mOut = track(cv.matFromArray(3, 3, cv.CV_64FC1, mOutData)) const mOut = track(
cv.matFromArray(3, 3, cv.CV_64FC1, mOutData),
)
// ======================================================== // ============================================================
// STEP 3 — Warp // STEP 6 — Warp
// ======================================================== // ============================================================
await progress(3, "Warping image") await progress(6, "Warping image (this may take a moment)")
log("step6", "calling warpPerspective...")
const dstMat = track(new cv.Mat()) const dstMat = track(new cv.Mat())
cv.warpPerspective( cv.warpPerspective(
src, src,
@ -207,33 +423,36 @@ 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 ${String(Math.round(blob.size / 1024))} KB`) log("export", `blob size: ${String(Math.round(blob.size / 1024))} KB`)
const diagnostics: DeskewDiagnostics = { const diagnostics: DeskewDiagnostics = {
primaryDatum: solved.primaryLabel, primaryDatum: primary.label,
iterations: solved.iterations, xCorrection: xCorr,
finalRMSPercent: solved.rmsPercent, yCorrection: yCorr,
perDatum: solved.reports, perDatum: 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 — ignore // already deleted or invalid — ignore
} }
} }
} }
@ -245,22 +464,31 @@ 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 {
// Runtime not ready yet log("opencv", "probe failed, waiting for onRuntimeInitialized")
// Not ready yet, wait for callback
} }
cv.onRuntimeInitialized = () => { cv.onRuntimeInitialized = () => {
cvReady = true cvReady = true
log("opencv", "onRuntimeInitialized fired, WASM ready")
resolve() resolve()
} }
}) })

View File

@ -1,193 +0,0 @@
/**
* 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)
}

View File

@ -1,909 +0,0 @@
/**
* 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,9 +1,8 @@
import { defineStore } from "pinia" import { defineStore } from "pinia"
import { ref, computed } from "vue" import { ref, computed } from "vue"
import type { AppStep, Datum, DeskewResult, ExifData, Point } from "@/types" import type { AppStep, Datum, DeskewResult, ExifData } 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()
@ -30,8 +29,7 @@ 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
if (d.type === "line") return d.lengthMm > 0 return d.lengthMm > 0
return d.diameterMm > 0
}) })
}) })
@ -67,73 +65,6 @@ 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) {
@ -187,8 +118,6 @@ export const useAppStore = defineStore("app", () => {
goToStep, goToStep,
addDatum, addDatum,
updateDatum, updateDatum,
updateEllipsePoints,
setAxisRole,
removeDatum, removeDatum,
setResult, setResult,
setFileHash, setFileHash,

View File

@ -11,13 +11,8 @@ 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"
@ -25,38 +20,12 @@ 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 interface EllipseDatum { export type Datum = RectDatum | LineDatum
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
@ -84,26 +53,24 @@ 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: DatumType type: "rectangle" | "line"
/** Representative expected dimension in mm (widthMm / lengthMm / diameterMm). */
expectedMm: number
/** Representative measured dimension in mm under the solved H. */
measuredMm: number measuredMm: number
/** Overall residual magnitude expressed as a percentage. */ expectedMm: number
errorPercent: number errorPercent: number
/** Free-form breakdown for debugging (e.g. "iso 0.2%, skew 0.1%, dia 0.8%"). */ axisContribution: "x" | "y" | "both"
details: string
} }
export interface DeskewDiagnostics { export interface DeskewDiagnostics {
/** Label of the rectangle used to fix the output gauge. */
primaryDatum: string primaryDatum: string
/** Number of outer alternating-minimization iterations the solver ran. */ xCorrection: AxisCorrection
iterations: number yCorrection: AxisCorrection
/** Final weighted RMS residual across all datums, as a percentage. */
finalRMSPercent: number
perDatum: DatumReport[] perDatum: DatumReport[]
outputWidthPx: number outputWidthPx: number
outputHeightPx: number outputHeightPx: number
@ -124,8 +91,3 @@ export interface RectPreset {
widthMm: number widthMm: number
heightMm: number heightMm: number
} }
export interface CirclePreset {
label: string
diameterMm: number
}