Compare commits
No commits in common. "e94a814335476afb457adf05b7b2560565bb9844" and "a71c8c73ef6ab6923bcaace69f868c3de1319dc1" have entirely different histories.
e94a814335
...
a71c8c73ef
File diff suppressed because it is too large
Load Diff
@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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,23 +65,7 @@ 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>
|
||||||
|
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
— a 3×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–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>
|
||||||
|
|||||||
@ -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++) {
|
||||||
|
|||||||
@ -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)}`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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 (1–5). 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()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,193 +0,0 @@
|
|||||||
/**
|
|
||||||
* ellipse-fit.ts — algebraic least-squares ellipse fit from N≥5 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 5–20-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)
|
|
||||||
}
|
|
||||||
@ -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·(hyx−hxy)
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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: TL→TR is +x,
|
|
||||||
* TL→BL 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
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user