diff --git a/src/components/DatumCanvas.vue b/src/components/DatumCanvas.vue index 278244a..51937c5 100644 --- a/src/components/DatumCanvas.vue +++ b/src/components/DatumCanvas.vue @@ -50,23 +50,34 @@ function datumIndex(datum: Datum): number { return store.datums.findIndex((d) => d.id === datum.id) } +function datumPoints(datum: Datum): Point[] { + if (datum.type === "rectangle") return datum.corners as unknown as Point[] + if (datum.type === "line") return datum.endpoints as unknown as Point[] + return [datum.center, datum.axisEndA, datum.axisEndB] +} + function getPointConfigs(datum: Datum, dIdx: number) { const color = getDatumColor(dIdx) const isSelected = store.selectedDatumId === datum.id - const points = datum.type === "rectangle" ? datum.corners : datum.endpoints + const points = datumPoints(datum) const baseRadius = isSelected ? 6 : 4 const visualRadius = Math.max( baseRadius / scale.value, - baseRadius * 0.5 + baseRadius * 0.5, ) return points.map((pt, pIdx) => ({ + // Ellipse center (index 0) is visually bigger + hollow to distinguish it x: pt.x, y: pt.y, - radius: visualRadius, - fill: color, + radius: + datum.type === "ellipse" && pIdx === 0 + ? visualRadius * 1.4 + : visualRadius, + fill: datum.type === "ellipse" && pIdx === 0 ? "transparent" : color, stroke: isSelected ? "#fff" : color, - strokeWidth: 1.5 / scale.value, + strokeWidth: (datum.type === "ellipse" && pIdx === 0 ? 2.5 : 1.5) / + scale.value, draggable: true, _datumId: datum.id, _pointIndex: pIdx, @@ -74,9 +85,30 @@ function getPointConfigs(datum: Datum, dIdx: number) { })) } +function ellipseCurvePoints(datum: Datum & { type: "ellipse" }): number[] { + const vAx = datum.axisEndA.x - datum.center.x + const vAy = datum.axisEndA.y - datum.center.y + const vBx = datum.axisEndB.x - datum.center.x + const vBy = datum.axisEndB.y - datum.center.y + const N = 72 + const pts: number[] = [] + for (let i = 0; i <= N; i++) { + const t = (2 * Math.PI * i) / N + const cs = Math.cos(t) + const sn = Math.sin(t) + pts.push( + datum.center.x + vAx * cs + vBx * sn, + datum.center.y + vAy * cs + vBy * sn, + ) + } + return pts +} + function getLineConfigs(datum: Datum, dIdx: number) { const color = getDatumColor(dIdx) const isSelected = store.selectedDatumId === datum.id + const dash = isSelected ? [] : [8 / scale.value, 4 / scale.value] + const strokeWidth = (isSelected ? 3 : 2) / scale.value if (datum.type === "line") { return [ @@ -88,22 +120,55 @@ function getLineConfigs(datum: Datum, dIdx: number) { datum.endpoints[1].y, ], stroke: color, - strokeWidth: (isSelected ? 3 : 2) / scale.value, - dash: isSelected ? [] : [8 / scale.value, 4 / scale.value], + strokeWidth, + dash, }, ] } - // Rectangle: draw 4 edges - const c = datum.corners - const pts = [c[0], c[1], c[2], c[3], c[0]].flatMap((p) => [p.x, p.y]) + if (datum.type === "rectangle") { + const c = datum.corners + const pts = [c[0], c[1], c[2], c[3], c[0]].flatMap((p) => [p.x, p.y]) + return [ + { + points: pts, + stroke: color, + strokeWidth, + closed: true, + dash, + }, + ] + } + + // Ellipse: sampled curve + two thin axis lines for visual reference return [ { - points: pts, + points: ellipseCurvePoints(datum), stroke: color, - strokeWidth: (isSelected ? 3 : 2) / scale.value, - closed: true, - dash: isSelected ? [] : [8 / scale.value, 4 / scale.value], + strokeWidth, + dash, + }, + { + points: [ + datum.center.x, + datum.center.y, + datum.axisEndA.x, + datum.axisEndA.y, + ], + stroke: color, + strokeWidth: 1 / scale.value, + opacity: 0.5, + }, + { + points: [ + datum.center.x, + datum.center.y, + datum.axisEndB.x, + datum.axisEndB.y, + ], + stroke: color, + strokeWidth: 1 / scale.value, + opacity: 0.5, }, ] } @@ -117,13 +182,18 @@ function getLabelConfig(datum: Datum, dIdx: number) { x: (datum.corners[0].x + datum.corners[2].x) / 2, y: (datum.corners[0].y + datum.corners[2].y) / 2, } - } else { + } else if (datum.type === "line") { pos = { x: (datum.endpoints[0].x + datum.endpoints[1].x) / 2, y: (datum.endpoints[0].y + datum.endpoints[1].y) / 2 - 20 / scale.value, } + } else { + pos = { + x: datum.center.x, + y: datum.center.y - 20 / scale.value, + } } return { @@ -155,10 +225,29 @@ function onPointDragMove(e: { const newCorners = [...datum.corners] as [Point, Point, Point, Point] newCorners[_pointIndex] = newPos store.updateDatum(_datumId, { corners: newCorners }) - } else { + } else if (datum.type === "line") { const newEndpoints = [...datum.endpoints] as [Point, Point] newEndpoints[_pointIndex] = newPos store.updateDatum(_datumId, { endpoints: newEndpoints }) + } else if (_pointIndex === 0) { + // Ellipse center — translate all three handles together + const dx = newPos.x - datum.center.x + const dy = newPos.y - datum.center.y + store.updateDatum(_datumId, { + center: newPos, + axisEndA: { + x: datum.axisEndA.x + dx, + y: datum.axisEndA.y + dy, + }, + axisEndB: { + x: datum.axisEndB.x + dx, + y: datum.axisEndB.y + dy, + }, + }) + } else if (_pointIndex === 1) { + store.updateDatum(_datumId, { axisEndA: newPos }) + } else { + store.updateDatum(_datumId, { axisEndB: newPos }) } } diff --git a/src/components/DatumEditor.vue b/src/components/DatumEditor.vue index 83fe017..e150697 100644 --- a/src/components/DatumEditor.vue +++ b/src/components/DatumEditor.vue @@ -33,7 +33,8 @@ const canvasHeight = computed(() => const incompleteDatums = computed(() => store.datums.filter((d) => { if (d.type === "rectangle") return d.widthMm <= 0 || d.heightMm <= 0 - return d.lengthMm <= 0 + if (d.type === "line") return d.lengthMm <= 0 + return d.diameterMm <= 0 }), ) diff --git a/src/components/DatumPanel.vue b/src/components/DatumPanel.vue index 96c4bcd..1b89cbd 100644 --- a/src/components/DatumPanel.vue +++ b/src/components/DatumPanel.vue @@ -2,8 +2,10 @@ import { useAppStore } from "@/stores/app" import { RECT_PRESETS, + CIRCLE_PRESETS, createRectDatum, createLineDatum, + createEllipseDatum, getDatumColor, } from "@/lib/datums" import type { ConfidenceScore, Datum, RectDatum } from "@/types" @@ -31,6 +33,10 @@ function nextLineIndex(): number { return store.datums.filter((d) => d.type === "line").length + 1 } +function nextEllipseIndex(): number { + return store.datums.filter((d) => d.type === "ellipse").length + 1 +} + function addRect(presetLabel?: string) { const preset = presetLabel ? RECT_PRESETS.find((p) => p.label === presetLabel) @@ -42,6 +48,15 @@ function addLine() { store.addDatum(createLineDatum(imageCenter(), nextLineIndex())) } +function addCircle(presetLabel?: string) { + const preset = presetLabel + ? CIRCLE_PRESETS.find((p) => p.label === presetLabel) + : undefined + store.addDatum( + createEllipseDatum(imageCenter(), nextEllipseIndex(), preset), + ) +} + function updateField(datum: Datum, field: string, value: string | number) { store.updateDatum(datum.id, { [field]: value }) } @@ -65,7 +80,16 @@ function formatDimensions(datum: Datum): string { if (datum.type === "rectangle") { return `${String(datum.widthMm)} \u00D7 ${String(datum.heightMm)} mm` } - return `${String(datum.lengthMm)} mm` + if (datum.type === "line") { + return `${String(datum.lengthMm)} mm` + } + return `⌀ ${String(datum.diameterMm)} mm` +} + +function typeBadge(datum: Datum): string { + if (datum.type === "rectangle") return "Rect" + if (datum.type === "line") return "Line" + return "Circle" } @@ -77,14 +101,14 @@ function formatDimensions(datum: Datum): string { Add Datum -
+
+
+
@@ -147,9 +189,7 @@ function formatDimensions(datum: Datum): string { :style="{ backgroundColor: getDatumColor(idx) }" /> - {{ - datum.type === "rectangle" ? "Rect" : "Line" - }} + {{ typeBadge(datum) }} {{ formatDimensions(datum) @@ -262,7 +302,7 @@ function formatDimensions(datum: Datum): string { />
-
+
+
+ + +
diff --git a/src/components/ResultViewer.vue b/src/components/ResultViewer.vue index a92839e..64f44b7 100644 --- a/src/components/ResultViewer.vue +++ b/src/components/ResultViewer.vue @@ -2,7 +2,7 @@ import { ref, computed, onMounted, watch } from "vue" import { useAppStore } from "@/stores/app" import { deskewImage, waitForOpenCV } from "@/lib/deskew" -import type { RectDatum } from "@/types" +import type { Datum } from "@/types" import { DEFAULT_SCALE_PX_PER_MM } from "@/types" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -59,39 +59,67 @@ watch(scaleInput, (v) => { const MAX_AUTO_SCALE_DIM = 8192 +/** Estimate the image-pixels-per-mm implied by a single datum. Picks the + * best datum by type priority (rect > line > ellipse) and then confidence. + * Returns null if no datum gives a usable scale. */ +function pickScaleRef(): { srcPxPerMm: number } | null { + const best = [...store.datums].sort((a, b) => { + const rank = (d: Datum) => + d.type === "rectangle" ? 0 : d.type === "ellipse" ? 1 : 2 + const r = rank(a) - rank(b) + if (r !== 0) return r + return b.confidence - a.confidence + })[0] + if (!best) return null + if (best.type === "rectangle") { + if (best.widthMm <= 0 || best.heightMm <= 0) return null + const c = best.corners + const srcW = Math.max( + Math.hypot(c[1].x - c[0].x, c[1].y - c[0].y), + Math.hypot(c[2].x - c[3].x, c[2].y - c[3].y), + ) + const srcH = Math.max( + Math.hypot(c[3].x - c[0].x, c[3].y - c[0].y), + Math.hypot(c[2].x - c[1].x, c[2].y - c[1].y), + ) + const sx = srcW / best.widthMm + const sy = srcH / best.heightMm + return { srcPxPerMm: Math.max(sx, sy) } + } + if (best.type === "line") { + if (best.lengthMm <= 0) return null + const L = Math.hypot( + best.endpoints[1].x - best.endpoints[0].x, + best.endpoints[1].y - best.endpoints[0].y, + ) + return { srcPxPerMm: L / best.lengthMm } + } + if (best.diameterMm <= 0) return null + // Approximate the ellipse's "diameter" as max of the two semi-axis lengths × 2 + const vA = Math.hypot( + best.axisEndA.x - best.center.x, + best.axisEndA.y - best.center.y, + ) + const vB = Math.hypot( + best.axisEndB.x - best.center.x, + best.axisEndB.y - best.center.y, + ) + return { srcPxPerMm: (2 * Math.max(vA, vB)) / best.diameterMm } +} + function computeAutoScale(): number { const img = store.loadedImage - const primary = store.datums.find( - (d): d is RectDatum => d.type === "rectangle", - ) - if (!img || !primary) return DEFAULT_SCALE_PX_PER_MM + const ref = pickScaleRef() + if (!img || !ref || ref.srcPxPerMm <= 0) return DEFAULT_SCALE_PX_PER_MM - // Approximate source-pixel size of the datum - const c = primary.corners - const datumSrcW = Math.max( - Math.hypot(c[1].x - c[0].x, c[1].y - c[0].y), - Math.hypot(c[2].x - c[3].x, c[2].y - c[3].y), - ) - const datumSrcH = Math.max( - Math.hypot(c[3].x - c[0].x, c[3].y - c[0].y), - Math.hypot(c[2].x - c[1].x, c[2].y - c[1].y), - ) - - // Scale that would make the datum the same pixel size as in source - const sx = - datumSrcW > 0 ? datumSrcW / primary.widthMm : 0 - const sy = - datumSrcH > 0 ? datumSrcH / primary.heightMm : 0 - let autoScale = Math.max(sx, sy) + let autoScale = ref.srcPxPerMm // Clamp so the full output doesn't exceed MAX_AUTO_SCALE_DIM - const estW = img.naturalWidth * autoScale / Math.max(datumSrcW / primary.widthMm, 0.001) - const estH = img.naturalHeight * autoScale / Math.max(datumSrcH / primary.heightMm, 0.001) - if (estW > MAX_AUTO_SCALE_DIM || estH > MAX_AUTO_SCALE_DIM) { - autoScale *= MAX_AUTO_SCALE_DIM / Math.max(estW, estH) + const estMax = Math.max(img.naturalWidth, img.naturalHeight) + if (estMax > MAX_AUTO_SCALE_DIM) { + autoScale *= MAX_AUTO_SCALE_DIM / estMax } - // Round to a clean number return Math.max(1, Math.round(autoScale * 10) / 10) } @@ -134,39 +162,18 @@ const progressPercent = computed(() => // Estimated output size — accounts for full warped image, not just datum const MAX_RGBA_MB = 512 const estimatedOutput = computed(() => { - const primary = store.datums.find( - (d): d is RectDatum => d.type === "rectangle", - ) + const ref = pickScaleRef() const img = store.loadedImage - if (!primary || !img || store.scalePxPerMm <= 0) return null + if (!ref || !img || store.scalePxPerMm <= 0 || ref.srcPxPerMm <= 0) + return null - // Datum dimensions in output pixels - const datumOutW = primary.widthMm * store.scalePxPerMm - const datumOutH = primary.heightMm * store.scalePxPerMm + // source-pixels-per-mm implied by the datum vs. requested output px/mm + const avgScale = store.scalePxPerMm / ref.srcPxPerMm - // Datum dimensions in source pixels (approximate from corner spread) - const c = primary.corners - const datumSrcW = Math.max( - Math.hypot(c[1].x - c[0].x, c[1].y - c[0].y), - Math.hypot(c[2].x - c[3].x, c[2].y - c[3].y), - ) - const datumSrcH = Math.max( - Math.hypot(c[3].x - c[0].x, c[3].y - c[0].y), - Math.hypot(c[2].x - c[1].x, c[2].y - c[1].y), - ) - - // Scale factor from source to output (per-axis average) - const sx = - datumSrcW > 0 ? datumOutW / datumSrcW : store.scalePxPerMm - const sy = - datumSrcH > 0 ? datumOutH / datumSrcH : store.scalePxPerMm - const avgScale = (sx + sy) / 2 - - // Estimated full warped output = source image scaled const w = Math.round(img.naturalWidth * avgScale) const h = Math.round(img.naturalHeight * avgScale) const mb = (w * h * 4) / (1024 * 1024) - return { w, h, mb, datumW: Math.round(datumOutW), datumH: Math.round(datumOutH) } + return { w, h, mb } }) const tooLarge = computed( @@ -343,9 +350,6 @@ async function download() { URL.revokeObjectURL(url) } -function hasRects(): boolean { - return store.datums.some((d) => d.type === "rectangle") -}