diff --git a/src/components/ResultViewer.vue b/src/components/ResultViewer.vue
index 64f44b7..b3125d9 100644
--- a/src/components/ResultViewer.vue
+++ b/src/components/ResultViewer.vue
@@ -63,13 +63,24 @@ const MAX_AUTO_SCALE_DIM = 8192
* best datum by type priority (rect > line > ellipse) and then confidence.
* Returns null if no datum gives a usable scale. */
function pickScaleRef(): { srcPxPerMm: number } | null {
- const best = [...store.datums].sort((a, b) => {
- const rank = (d: Datum) =>
- d.type === "rectangle" ? 0 : d.type === "ellipse" ? 1 : 2
- const r = rank(a) - rank(b)
- if (r !== 0) return r
- return b.confidence - a.confidence
- })[0]
+ const axisFlagged = store.datums.find(
+ (d) =>
+ (d.type === "rectangle" && d.isAxisReference) ||
+ (d.type === "line" && d.axisRole),
+ )
+ const best =
+ axisFlagged ??
+ [...store.datums].sort((a, b) => {
+ const rank = (d: Datum) =>
+ d.type === "rectangle"
+ ? 0
+ : d.type === "ellipse"
+ ? 1
+ : 2
+ const r = rank(a) - rank(b)
+ if (r !== 0) return r
+ return b.confidence - a.confidence
+ })[0]
if (!best) return null
if (best.type === "rectangle") {
if (best.widthMm <= 0 || best.heightMm <= 0) return null
@@ -120,7 +131,8 @@ function computeAutoScale(): number {
autoScale *= MAX_AUTO_SCALE_DIM / estMax
}
- return Math.max(1, Math.round(autoScale * 10) / 10)
+ // The scale input is integer-only; floor so the shown value round-trips.
+ return Math.max(1, Math.floor(autoScale))
}
onMounted(() => {
@@ -383,6 +395,7 @@ async function download() {
:model-value="scaleInput"
type="number"
min="1"
+ step="1"
class="w-28 font-mono"
:class="
scaleValid
diff --git a/src/lib/datum-cache.ts b/src/lib/datum-cache.ts
index 2694604..f5d1269 100644
--- a/src/lib/datum-cache.ts
+++ b/src/lib/datum-cache.ts
@@ -1,4 +1,4 @@
-import type { Datum } from "@/types"
+import type { Datum, EllipseDatum, Point } from "@/types"
const KEY_PREFIX = "skwik-datums-"
@@ -17,12 +17,38 @@ export function loadDatums(hash: string): Datum[] | null {
try {
const raw = localStorage.getItem(KEY_PREFIX + hash)
if (!raw) return null
- return JSON.parse(raw) as Datum[]
+ const parsed = JSON.parse(raw) as Datum[]
+ return parsed.map(migrateDatum)
} catch {
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 {
const toRemove: string[] = []
for (let i = 0; i < localStorage.length; i++) {
diff --git a/src/lib/datums.ts b/src/lib/datums.ts
index 7115c05..f410d4a 100644
--- a/src/lib/datums.ts
+++ b/src/lib/datums.ts
@@ -89,15 +89,26 @@ export function createLineDatum(center: Point, index: number): LineDatum {
}
}
+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 },
diff --git a/src/lib/ellipse-fit.ts b/src/lib/ellipse-fit.ts
new file mode 100644
index 0000000..7d30990
--- /dev/null
+++ b/src/lib/ellipse-fit.ts
@@ -0,0 +1,193 @@
+/**
+ * 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)
+}
diff --git a/src/lib/solver.ts b/src/lib/solver.ts
index 0cce662..a2353a4 100644
--- a/src/lib/solver.ts
+++ b/src/lib/solver.ts
@@ -237,6 +237,17 @@ type Primary =
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 => {
@@ -314,12 +325,23 @@ function primaryLineCorrespondences(
const p2: Point = { x: p0.x - dy, y: p0.y + dx }
const p3: Point = { x: p1.x - dy, y: p1.y + dx }
const srcPts: [Point, Point, Point, Point] = [p0, p1, p2, p3]
- const targets: [Point, Point, Point, Point] = [
- { x: 0, y: 0 },
- { x: L, y: 0 },
- { x: 0, y: L },
- { x: L, y: L },
- ]
+
+ // 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),
diff --git a/src/stores/app.ts b/src/stores/app.ts
index e5f77f3..325cd3e 100644
--- a/src/stores/app.ts
+++ b/src/stores/app.ts
@@ -1,8 +1,9 @@
import { defineStore } from "pinia"
import { ref, computed } from "vue"
-import type { AppStep, Datum, DeskewResult, ExifData } from "@/types"
+import type { AppStep, Datum, DeskewResult, ExifData, Point } from "@/types"
import { DEFAULT_SCALE_PX_PER_MM } from "@/types"
import { loadSettings } from "@/lib/settings-cache"
+import { fitEllipse } from "@/lib/ellipse-fit"
export const useAppStore = defineStore("app", () => {
const cached = loadSettings()
@@ -66,6 +67,73 @@ export const useAppStore = defineStore("app", () => {
}
}
+ /** Update an ellipse datum's points and refresh the cached best-fit
+ * ellipse (`center`/`axisEndA`/`axisEndB`). If the fit degenerates
+ * (fewer than 5 usable points, collinear, or the system is singular),
+ * we keep the previous cached axes so downstream consumers never see
+ * a garbage fit. */
+ function updateEllipsePoints(id: string, points: Point[]) {
+ const idx = datums.value.findIndex((d) => d.id === id)
+ const existing = datums.value[idx]
+ if (!existing || existing.type !== "ellipse") return
+ const fit = fitEllipse(points)
+ if (!fit) {
+ datums.value[idx] = { ...existing, points }
+ return
+ }
+ datums.value[idx] = {
+ ...existing,
+ points,
+ center: fit.center,
+ axisEndA: {
+ x: fit.center.x + fit.semiMajor.x,
+ y: fit.center.y + fit.semiMajor.y,
+ },
+ axisEndB: {
+ x: fit.center.x + fit.semiMinor.x,
+ y: fit.center.y + fit.semiMinor.y,
+ },
+ }
+ }
+
+ /** Set (or clear) the world-axis role on a datum, enforcing that at
+ * most one datum holds the role at a time.
+ * `role`: "rect" → rectangle.isAxisReference = true
+ * "x"/"y" → line.axisRole = "x"|"y"
+ * null → clear the role on `id` (no-op if it wasn't set). */
+ function setAxisRole(
+ id: string,
+ role: "rect" | "x" | "y" | null,
+ ) {
+ // Clear any existing flag on other datums.
+ for (let i = 0; i < datums.value.length; i++) {
+ const d = datums.value[i]
+ if (!d || d.id === id) continue
+ if (d.type === "rectangle" && d.isAxisReference) {
+ datums.value[i] = { ...d, isAxisReference: false }
+ } else if (d.type === "line" && d.axisRole) {
+ datums.value[i] = { ...d, axisRole: null }
+ }
+ }
+ const idx = datums.value.findIndex((d) => d.id === id)
+ if (idx === -1) return
+ const target = datums.value[idx]
+ if (!target) return
+ if (role === null) {
+ if (target.type === "rectangle") {
+ datums.value[idx] = { ...target, isAxisReference: false }
+ } else if (target.type === "line") {
+ datums.value[idx] = { ...target, axisRole: null }
+ }
+ return
+ }
+ if (role === "rect" && target.type === "rectangle") {
+ datums.value[idx] = { ...target, isAxisReference: true }
+ } else if ((role === "x" || role === "y") && target.type === "line") {
+ datums.value[idx] = { ...target, axisRole: role }
+ }
+ }
+
function removeDatum(id: string) {
datums.value = datums.value.filter((d) => d.id !== id)
if (selectedDatumId.value === id) {
@@ -119,6 +187,8 @@ export const useAppStore = defineStore("app", () => {
goToStep,
addDatum,
updateDatum,
+ updateEllipsePoints,
+ setAxisRole,
removeDatum,
setResult,
setFileHash,
diff --git a/src/types/index.ts b/src/types/index.ts
index 5aed621..4e53d76 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -11,8 +11,13 @@ export interface RectDatum {
heightMm: number
confidence: 1 | 2 | 3 | 4 | 5
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 {
id: string
type: "line"
@@ -20,16 +25,25 @@ export interface LineDatum {
lengthMm: number
confidence: 1 | 2 | 3 | 4 | 5
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 {
id: string
type: "ellipse"
- /** Image-space ellipse as 3 free points: center + two conjugate
- * semi-axis endpoints. axisEndA/axisEndB don't need to be perpendicular;
- * together with center they give a full 5-DoF ellipse matrix. */
+ /** 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