feat(result): add measurement tools, grid overlay, and scale bar export
- New CorrectedImageViewer component with dual-canvas (image + overlay) - Point-to-point measurement tool: click two points, see mm distance - Toggleable grid overlay with configurable spacing and major lines - Scale bar export: appends measurement bar to downloaded PNG - Progress bar with step labels during algorithm execution - Estimated output size shown before running, blocks if > 512MB - Actual output dimensions/filesize shown in diagnostics - Filename changed to originalname-skwik.png - Collapsible algorithm explanation with numbered steps - Process New Image button to reset and start over - Scale bar tooltip explaining the feature - Add shadcn-vue Checkbox component Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
1bc1f46bb8
commit
0cb9009eaa
638
src/components/CorrectedImageViewer.vue
Normal file
638
src/components/CorrectedImageViewer.vue
Normal file
@ -0,0 +1,638 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted, watch } from "vue"
|
||||||
|
import type { Point } from "@/types"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
imageUrl: string
|
||||||
|
scalePxPerMm: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const containerRef = ref<HTMLDivElement | null>(null)
|
||||||
|
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||||
|
const overlayRef = ref<HTMLCanvasElement | null>(null)
|
||||||
|
|
||||||
|
const img = ref<HTMLImageElement | null>(null)
|
||||||
|
const imgLoaded = ref(false)
|
||||||
|
|
||||||
|
// View state
|
||||||
|
const viewScale = ref(1)
|
||||||
|
const viewOffsetX = ref(0)
|
||||||
|
const viewOffsetY = ref(0)
|
||||||
|
|
||||||
|
// Tool state
|
||||||
|
const activeTool = ref<"none" | "measure">("none")
|
||||||
|
const showGrid = ref(false)
|
||||||
|
const gridSpacingMm = ref(10)
|
||||||
|
|
||||||
|
// Measurement state
|
||||||
|
const measurePoints = ref<Point[]>([])
|
||||||
|
const measureHistory = ref<{ a: Point; b: Point; distMm: number }[]>([])
|
||||||
|
|
||||||
|
// Touch/pan state
|
||||||
|
let isPanning = false
|
||||||
|
let panStart = { x: 0, y: 0 }
|
||||||
|
let lastPinchDist = 0
|
||||||
|
|
||||||
|
const measureDistMm = computed(() => {
|
||||||
|
if (measurePoints.value.length < 2) return null
|
||||||
|
const [a, b] = measurePoints.value as [Point, Point]
|
||||||
|
const dxPx = b.x - a.x
|
||||||
|
const dyPx = b.y - a.y
|
||||||
|
const distPx = Math.hypot(dxPx, dyPx)
|
||||||
|
return distPx / props.scalePxPerMm
|
||||||
|
})
|
||||||
|
|
||||||
|
function loadImg() {
|
||||||
|
const image = new Image()
|
||||||
|
image.onload = () => {
|
||||||
|
img.value = image
|
||||||
|
imgLoaded.value = true
|
||||||
|
fitToContainer()
|
||||||
|
redraw()
|
||||||
|
}
|
||||||
|
image.src = props.imageUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitToContainer() {
|
||||||
|
const c = containerRef.value
|
||||||
|
const i = img.value
|
||||||
|
if (!c || !i) return
|
||||||
|
|
||||||
|
const cw = c.clientWidth
|
||||||
|
const ch = c.clientHeight
|
||||||
|
|
||||||
|
if (canvasRef.value) {
|
||||||
|
canvasRef.value.width = cw
|
||||||
|
canvasRef.value.height = ch
|
||||||
|
}
|
||||||
|
if (overlayRef.value) {
|
||||||
|
overlayRef.value.width = cw
|
||||||
|
overlayRef.value.height = ch
|
||||||
|
}
|
||||||
|
|
||||||
|
const fit = Math.min(cw / i.naturalWidth, ch / i.naturalHeight) * 0.95
|
||||||
|
viewScale.value = fit
|
||||||
|
viewOffsetX.value = (cw - i.naturalWidth * fit) / 2
|
||||||
|
viewOffsetY.value = (ch - i.naturalHeight * fit) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
function redraw() {
|
||||||
|
drawImage()
|
||||||
|
drawOverlay()
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawImage() {
|
||||||
|
const canvas = canvasRef.value
|
||||||
|
const image = img.value
|
||||||
|
if (!canvas || !image) return
|
||||||
|
const ctx = canvas.getContext("2d")
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
ctx.save()
|
||||||
|
ctx.translate(viewOffsetX.value, viewOffsetY.value)
|
||||||
|
ctx.scale(viewScale.value, viewScale.value)
|
||||||
|
ctx.drawImage(image, 0, 0)
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawOverlay() {
|
||||||
|
const canvas = overlayRef.value
|
||||||
|
const image = img.value
|
||||||
|
if (!canvas || !image) return
|
||||||
|
const ctx = canvas.getContext("2d")
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
ctx.save()
|
||||||
|
|
||||||
|
// Grid
|
||||||
|
if (showGrid.value) {
|
||||||
|
drawGrid(ctx, image)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Measurement history
|
||||||
|
for (const m of measureHistory.value) {
|
||||||
|
drawMeasureLine(ctx, m.a, m.b, m.distMm, "rgba(100,180,255,0.5)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active measurement
|
||||||
|
if (measurePoints.value.length >= 2) {
|
||||||
|
const [a, b] = measurePoints.value as [Point, Point]
|
||||||
|
drawMeasureLine(ctx, a, b, measureDistMm.value ?? 0, "#3b82f6")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Measurement points
|
||||||
|
for (const pt of measurePoints.value) {
|
||||||
|
drawPoint(ctx, pt, "#3b82f6")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawGrid(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
image: HTMLImageElement,
|
||||||
|
) {
|
||||||
|
const spacingPx = gridSpacingMm.value * props.scalePxPerMm
|
||||||
|
if (spacingPx <= 0) return
|
||||||
|
|
||||||
|
const w = image.naturalWidth
|
||||||
|
const h = image.naturalHeight
|
||||||
|
|
||||||
|
ctx.save()
|
||||||
|
ctx.translate(viewOffsetX.value, viewOffsetY.value)
|
||||||
|
ctx.scale(viewScale.value, viewScale.value)
|
||||||
|
|
||||||
|
ctx.strokeStyle = "rgba(255, 255, 255, 0.15)"
|
||||||
|
ctx.lineWidth = 1 / viewScale.value
|
||||||
|
|
||||||
|
// Vertical lines
|
||||||
|
for (let x = 0; x <= w; x += spacingPx) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(x, 0)
|
||||||
|
ctx.lineTo(x, h)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
// Horizontal lines
|
||||||
|
for (let y = 0; y <= h; y += spacingPx) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(0, y)
|
||||||
|
ctx.lineTo(w, y)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Major lines every 5 intervals
|
||||||
|
ctx.strokeStyle = "rgba(255, 255, 255, 0.35)"
|
||||||
|
ctx.lineWidth = 1.5 / viewScale.value
|
||||||
|
const major = spacingPx * 5
|
||||||
|
for (let x = 0; x <= w; x += major) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(x, 0)
|
||||||
|
ctx.lineTo(x, h)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
for (let y = 0; y <= h; y += major) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(0, y)
|
||||||
|
ctx.lineTo(w, y)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Labels on major lines
|
||||||
|
ctx.fillStyle = "rgba(255, 255, 255, 0.6)"
|
||||||
|
const fontSize = Math.max(10, 12 / viewScale.value)
|
||||||
|
ctx.font = `${String(fontSize)}px monospace`
|
||||||
|
for (let x = major; x <= w; x += major) {
|
||||||
|
const mm = x / props.scalePxPerMm
|
||||||
|
ctx.fillText(mm.toFixed(0), x + 2 / viewScale.value, fontSize + 2 / viewScale.value)
|
||||||
|
}
|
||||||
|
for (let y = major; y <= h; y += major) {
|
||||||
|
const mm = y / props.scalePxPerMm
|
||||||
|
ctx.fillText(mm.toFixed(0), 2 / viewScale.value, y - 2 / viewScale.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
function imgToScreen(pt: Point): Point {
|
||||||
|
return {
|
||||||
|
x: pt.x * viewScale.value + viewOffsetX.value,
|
||||||
|
y: pt.y * viewScale.value + viewOffsetY.value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawMeasureLine(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
a: Point,
|
||||||
|
b: Point,
|
||||||
|
distMm: number,
|
||||||
|
color: string,
|
||||||
|
) {
|
||||||
|
const sa = imgToScreen(a)
|
||||||
|
const sb = imgToScreen(b)
|
||||||
|
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(sa.x, sa.y)
|
||||||
|
ctx.lineTo(sb.x, sb.y)
|
||||||
|
ctx.strokeStyle = color
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.setLineDash([6, 3])
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.setLineDash([])
|
||||||
|
|
||||||
|
// Label
|
||||||
|
const mx = (sa.x + sb.x) / 2
|
||||||
|
const my = (sa.y + sb.y) / 2
|
||||||
|
const label = distMm >= 10
|
||||||
|
? `${distMm.toFixed(1)} mm`
|
||||||
|
: `${distMm.toFixed(2)} mm`
|
||||||
|
|
||||||
|
ctx.font = "bold 13px monospace"
|
||||||
|
const metrics = ctx.measureText(label)
|
||||||
|
const pad = 4
|
||||||
|
const tw = metrics.width + pad * 2
|
||||||
|
const th = 18
|
||||||
|
|
||||||
|
ctx.fillStyle = "rgba(0, 0, 0, 0.75)"
|
||||||
|
ctx.fillRect(mx - tw / 2, my - th / 2 - 10, tw, th)
|
||||||
|
ctx.fillStyle = "#fff"
|
||||||
|
ctx.textAlign = "center"
|
||||||
|
ctx.textBaseline = "middle"
|
||||||
|
ctx.fillText(label, mx, my - 10)
|
||||||
|
ctx.textAlign = "start"
|
||||||
|
ctx.textBaseline = "alphabetic"
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPoint(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
pt: Point,
|
||||||
|
color: string,
|
||||||
|
) {
|
||||||
|
const s = imgToScreen(pt)
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(s.x, s.y, 5, 0, Math.PI * 2)
|
||||||
|
ctx.fillStyle = color
|
||||||
|
ctx.fill()
|
||||||
|
ctx.strokeStyle = "#fff"
|
||||||
|
ctx.lineWidth = 1.5
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert screen coords to image coords
|
||||||
|
function screenToImg(sx: number, sy: number): Point {
|
||||||
|
return {
|
||||||
|
x: (sx - viewOffsetX.value) / viewScale.value,
|
||||||
|
y: (sy - viewOffsetY.value) / viewScale.value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCanvasXY(e: MouseEvent | Touch): { x: number; y: number } {
|
||||||
|
const rect = overlayRef.value?.getBoundingClientRect()
|
||||||
|
if (!rect) return { x: 0, y: 0 }
|
||||||
|
return { x: e.clientX - rect.left, y: e.clientY - rect.top }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCanvasClick(e: MouseEvent) {
|
||||||
|
if (activeTool.value !== "measure") return
|
||||||
|
|
||||||
|
const { x, y } = getCanvasXY(e)
|
||||||
|
const imgPt = screenToImg(x, y)
|
||||||
|
|
||||||
|
if (measurePoints.value.length < 2) {
|
||||||
|
measurePoints.value.push(imgPt)
|
||||||
|
} else {
|
||||||
|
// Save previous measurement and start new
|
||||||
|
const [a, b] = measurePoints.value as [Point, Point]
|
||||||
|
measureHistory.value.push({
|
||||||
|
a,
|
||||||
|
b,
|
||||||
|
distMm: measureDistMm.value ?? 0,
|
||||||
|
})
|
||||||
|
measurePoints.value = [imgPt]
|
||||||
|
}
|
||||||
|
drawOverlay()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWheel(e: WheelEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
const scaleBy = 1.08
|
||||||
|
const oldScale = viewScale.value
|
||||||
|
const newScale = e.deltaY < 0
|
||||||
|
? oldScale * scaleBy
|
||||||
|
: oldScale / scaleBy
|
||||||
|
const clamped = Math.max(0.05, Math.min(20, newScale))
|
||||||
|
|
||||||
|
const { x: px, y: py } = getCanvasXY(e)
|
||||||
|
const imgPt = screenToImg(px, py)
|
||||||
|
|
||||||
|
viewScale.value = clamped
|
||||||
|
viewOffsetX.value = px - imgPt.x * clamped
|
||||||
|
viewOffsetY.value = py - imgPt.y * clamped
|
||||||
|
redraw()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseDown(e: MouseEvent) {
|
||||||
|
if (activeTool.value === "measure") return
|
||||||
|
isPanning = true
|
||||||
|
panStart = { x: e.clientX - viewOffsetX.value, y: e.clientY - viewOffsetY.value }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseMove(e: MouseEvent) {
|
||||||
|
if (!isPanning) return
|
||||||
|
viewOffsetX.value = e.clientX - panStart.x
|
||||||
|
viewOffsetY.value = e.clientY - panStart.y
|
||||||
|
redraw()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseUp() {
|
||||||
|
isPanning = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchStart(e: TouchEvent) {
|
||||||
|
const t0 = e.touches[0]
|
||||||
|
const t1 = e.touches[1]
|
||||||
|
if (e.touches.length === 2 && t0 && t1) {
|
||||||
|
e.preventDefault()
|
||||||
|
lastPinchDist = Math.hypot(
|
||||||
|
t1.clientX - t0.clientX,
|
||||||
|
t1.clientY - t0.clientY,
|
||||||
|
)
|
||||||
|
} else if (e.touches.length === 1 && t0 && activeTool.value !== "measure") {
|
||||||
|
isPanning = true
|
||||||
|
panStart = {
|
||||||
|
x: t0.clientX - viewOffsetX.value,
|
||||||
|
y: t0.clientY - viewOffsetY.value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchMove(e: TouchEvent) {
|
||||||
|
const t0 = e.touches[0]
|
||||||
|
const t1 = e.touches[1]
|
||||||
|
if (e.touches.length === 2 && t0 && t1) {
|
||||||
|
e.preventDefault()
|
||||||
|
const dist = Math.hypot(
|
||||||
|
t1.clientX - t0.clientX,
|
||||||
|
t1.clientY - t0.clientY,
|
||||||
|
)
|
||||||
|
const factor = dist / lastPinchDist
|
||||||
|
const oldScale = viewScale.value
|
||||||
|
const newScale = Math.max(0.05, Math.min(20, oldScale * factor))
|
||||||
|
|
||||||
|
const rect = overlayRef.value?.getBoundingClientRect()
|
||||||
|
if (!rect) return
|
||||||
|
const cx = (t0.clientX + t1.clientX) / 2 - rect.left
|
||||||
|
const cy = (t0.clientY + t1.clientY) / 2 - rect.top
|
||||||
|
const imgPt = screenToImg(cx, cy)
|
||||||
|
|
||||||
|
viewScale.value = newScale
|
||||||
|
viewOffsetX.value = cx - imgPt.x * newScale
|
||||||
|
viewOffsetY.value = cy - imgPt.y * newScale
|
||||||
|
|
||||||
|
lastPinchDist = dist
|
||||||
|
redraw()
|
||||||
|
} else if (e.touches.length === 1 && t0 && isPanning) {
|
||||||
|
viewOffsetX.value = t0.clientX - panStart.x
|
||||||
|
viewOffsetY.value = t0.clientY - panStart.y
|
||||||
|
redraw()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchEnd() {
|
||||||
|
isPanning = false
|
||||||
|
lastPinchDist = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMeasure() {
|
||||||
|
if (activeTool.value === "measure") {
|
||||||
|
activeTool.value = "none"
|
||||||
|
} else {
|
||||||
|
activeTool.value = "measure"
|
||||||
|
measurePoints.value = []
|
||||||
|
}
|
||||||
|
drawOverlay()
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearMeasurements() {
|
||||||
|
measurePoints.value = []
|
||||||
|
measureHistory.value = []
|
||||||
|
drawOverlay()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale bar export
|
||||||
|
function exportWithScaleBar(): Promise<Blob> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const image = img.value
|
||||||
|
if (!image) {
|
||||||
|
console.error("[scale-bar] img.value is null")
|
||||||
|
reject(new Error("No image loaded for scale bar export"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const iw = image.naturalWidth
|
||||||
|
const ih = image.naturalHeight
|
||||||
|
console.log(`[scale-bar] image ${String(iw)}×${String(ih)}, scale=${String(props.scalePxPerMm)} px/mm`)
|
||||||
|
|
||||||
|
// Scale font/bar sizes relative to image width
|
||||||
|
const unit = Math.max(iw / 100, 8)
|
||||||
|
const barHeightPx = Math.round(unit * 5)
|
||||||
|
const canvas = document.createElement("canvas")
|
||||||
|
canvas.width = iw
|
||||||
|
canvas.height = ih + barHeightPx
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d")
|
||||||
|
if (!ctx) {
|
||||||
|
reject(new Error("No 2D context"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw image
|
||||||
|
ctx.drawImage(image, 0, 0)
|
||||||
|
|
||||||
|
// Draw bar background
|
||||||
|
ctx.fillStyle = "#000"
|
||||||
|
ctx.fillRect(0, ih, iw, barHeightPx)
|
||||||
|
|
||||||
|
// Determine a nice scale bar length (~20% of image width)
|
||||||
|
const imgWidthMm = iw / props.scalePxPerMm
|
||||||
|
const niceSteps = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000]
|
||||||
|
const targetMm = imgWidthMm * 0.2
|
||||||
|
let barMm = niceSteps[0] ?? 10
|
||||||
|
for (const s of niceSteps) {
|
||||||
|
barMm = s
|
||||||
|
if (s >= targetMm) break
|
||||||
|
}
|
||||||
|
const barWidthPx = barMm * props.scalePxPerMm
|
||||||
|
|
||||||
|
const margin = Math.round(unit * 2)
|
||||||
|
const barX = margin
|
||||||
|
const barY = ih + barHeightPx / 2
|
||||||
|
const barThick = Math.max(Math.round(unit * 0.6), 4)
|
||||||
|
const tickH = Math.round(unit * 1.5)
|
||||||
|
|
||||||
|
console.log(`[scale-bar] barMm=${String(barMm)}, barWidthPx=${barWidthPx.toFixed(0)}, barHeight=${String(barHeightPx)}, unit=${unit.toFixed(1)}`)
|
||||||
|
|
||||||
|
// Draw bar
|
||||||
|
ctx.fillStyle = "#fff"
|
||||||
|
ctx.fillRect(barX, barY - barThick / 2, barWidthPx, barThick)
|
||||||
|
|
||||||
|
// End ticks
|
||||||
|
ctx.fillRect(barX, barY - tickH / 2, Math.max(2, unit * 0.15), tickH)
|
||||||
|
ctx.fillRect(barX + barWidthPx - Math.max(2, unit * 0.15), barY - tickH / 2, Math.max(2, unit * 0.15), tickH)
|
||||||
|
|
||||||
|
// Label above bar
|
||||||
|
const fontSize = Math.round(unit * 1.4)
|
||||||
|
const label = `${String(barMm)} mm`
|
||||||
|
ctx.font = `bold ${String(fontSize)}px monospace`
|
||||||
|
ctx.fillStyle = "#fff"
|
||||||
|
ctx.textAlign = "center"
|
||||||
|
ctx.textBaseline = "bottom"
|
||||||
|
ctx.fillText(label, barX + barWidthPx / 2, barY - tickH / 2 - Math.round(unit * 0.3))
|
||||||
|
|
||||||
|
// Scale info on the right
|
||||||
|
const smallFont = Math.round(unit * 1)
|
||||||
|
ctx.textAlign = "right"
|
||||||
|
ctx.textBaseline = "middle"
|
||||||
|
ctx.font = `${String(smallFont)}px monospace`
|
||||||
|
ctx.fillStyle = "rgba(255,255,255,0.6)"
|
||||||
|
ctx.fillText(
|
||||||
|
`${String(props.scalePxPerMm)} px/mm`,
|
||||||
|
iw - margin,
|
||||||
|
barY,
|
||||||
|
)
|
||||||
|
|
||||||
|
canvas.toBlob((b) => {
|
||||||
|
console.log(`[scale-bar] blob: ${b ? String(Math.round(b.size / 1024)) + " KB" : "NULL"}`)
|
||||||
|
if (b) resolve(b)
|
||||||
|
else reject(new Error("toBlob failed"))
|
||||||
|
}, "image/png")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ exportWithScaleBar })
|
||||||
|
|
||||||
|
let resizeObs: ResizeObserver | null = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadImg()
|
||||||
|
if (containerRef.value) {
|
||||||
|
resizeObs = new ResizeObserver(() => {
|
||||||
|
const c = containerRef.value
|
||||||
|
if (!c || c.clientWidth === 0) return
|
||||||
|
fitToContainer()
|
||||||
|
redraw()
|
||||||
|
})
|
||||||
|
resizeObs.observe(containerRef.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
resizeObs?.disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.imageUrl, loadImg)
|
||||||
|
watch(showGrid, () => { drawOverlay() })
|
||||||
|
watch(gridSpacingMm, () => { drawOverlay() })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors"
|
||||||
|
:class="
|
||||||
|
activeTool === 'measure'
|
||||||
|
? 'border-primary bg-primary/10 text-primary'
|
||||||
|
: 'border-border text-muted-foreground hover:text-foreground'
|
||||||
|
"
|
||||||
|
@click="toggleMeasure"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M21.3 15.3a2.4 2.4 0 0 1 0 3.4l-2.6 2.6a2.4 2.4 0 0 1-3.4 0L2.7 8.7a2.41 2.41 0 0 1 0-3.4l2.6-2.6a2.41 2.41 0 0 1 3.4 0Z" />
|
||||||
|
<path d="m14.5 12.5 2-2" />
|
||||||
|
<path d="m11.5 9.5 2-2" />
|
||||||
|
<path d="m8.5 6.5 2-2" />
|
||||||
|
<path d="m17.5 15.5 2-2" />
|
||||||
|
</svg>
|
||||||
|
Measure
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="measureHistory.length > 0 || measurePoints.length > 0"
|
||||||
|
class="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1.5 text-xs text-muted-foreground hover:text-destructive"
|
||||||
|
@click="clearMeasurements"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="mx-1 h-4 w-px bg-border" />
|
||||||
|
|
||||||
|
<label
|
||||||
|
class="inline-flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="showGrid"
|
||||||
|
type="checkbox"
|
||||||
|
class="accent-primary"
|
||||||
|
/>
|
||||||
|
Grid
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
v-if="showGrid"
|
||||||
|
v-model.number="gridSpacingMm"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="1000"
|
||||||
|
class="w-16 rounded-md border border-border bg-transparent px-2 py-1 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-if="showGrid"
|
||||||
|
class="font-mono text-xs text-muted-foreground"
|
||||||
|
>mm</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Measurement readout -->
|
||||||
|
<div
|
||||||
|
v-if="activeTool === 'measure'"
|
||||||
|
class="rounded-md border border-primary/30 bg-primary/5 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<template v-if="measurePoints.length === 0">
|
||||||
|
Click two points on the image to measure distance.
|
||||||
|
</template>
|
||||||
|
<template v-else-if="measurePoints.length === 1">
|
||||||
|
Click a second point.
|
||||||
|
</template>
|
||||||
|
<template v-else-if="measureDistMm != null">
|
||||||
|
Distance:
|
||||||
|
<span class="font-mono font-semibold">
|
||||||
|
{{ measureDistMm >= 10
|
||||||
|
? measureDistMm.toFixed(1)
|
||||||
|
: measureDistMm.toFixed(2)
|
||||||
|
}} mm
|
||||||
|
</span>
|
||||||
|
<span class="ml-2 text-muted-foreground">
|
||||||
|
(click to start a new measurement)
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Canvas area -->
|
||||||
|
<div
|
||||||
|
ref="containerRef"
|
||||||
|
class="relative h-[500px] overflow-hidden rounded-lg border border-border bg-muted"
|
||||||
|
:class="activeTool === 'measure' ? 'cursor-crosshair' : 'cursor-grab'"
|
||||||
|
>
|
||||||
|
<canvas
|
||||||
|
ref="canvasRef"
|
||||||
|
class="absolute inset-0"
|
||||||
|
/>
|
||||||
|
<canvas
|
||||||
|
ref="overlayRef"
|
||||||
|
class="absolute inset-0"
|
||||||
|
@click="onCanvasClick"
|
||||||
|
@wheel.prevent="onWheel"
|
||||||
|
@mousedown="onMouseDown"
|
||||||
|
@mousemove="onMouseMove"
|
||||||
|
@mouseup="onMouseUp"
|
||||||
|
@mouseleave="onMouseUp"
|
||||||
|
@touchstart="onTouchStart"
|
||||||
|
@touchmove="onTouchMove"
|
||||||
|
@touchend="onTouchEnd"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -1,10 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue"
|
import { ref, computed, onMounted, watch } from "vue"
|
||||||
import { useAppStore } from "@/stores/app"
|
import { useAppStore } from "@/stores/app"
|
||||||
import { deskewImage, waitForOpenCV } from "@/lib/deskew"
|
import { deskewImage, waitForOpenCV } from "@/lib/deskew"
|
||||||
|
import type { RectDatum } from "@/types"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import { Progress } from "@/components/ui/progress"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip"
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@ -21,6 +30,11 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} from "@/components/ui/table"
|
||||||
|
import CorrectedImageViewer from "@/components/CorrectedImageViewer.vue"
|
||||||
|
import {
|
||||||
|
loadSettings,
|
||||||
|
saveSettings,
|
||||||
|
} from "@/lib/settings-cache"
|
||||||
|
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
const resultUrl = ref<string | null>(null)
|
const resultUrl = ref<string | null>(null)
|
||||||
@ -28,6 +42,77 @@ const error = ref("")
|
|||||||
const hasRun = ref(false)
|
const hasRun = ref(false)
|
||||||
const cvReady = ref(false)
|
const cvReady = ref(false)
|
||||||
const cvLoading = ref(false)
|
const cvLoading = ref(false)
|
||||||
|
const showAlgoDetails = ref(false)
|
||||||
|
const includeScaleBar = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const cached = loadSettings()
|
||||||
|
if (cached) {
|
||||||
|
includeScaleBar.value = cached.includeScaleBar
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[() => store.scalePxPerMm, includeScaleBar],
|
||||||
|
() => {
|
||||||
|
saveSettings({
|
||||||
|
scalePxPerMm: store.scalePxPerMm,
|
||||||
|
includeScaleBar: includeScaleBar.value,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Progress tracking
|
||||||
|
const progressStep = ref(0)
|
||||||
|
const progressTotal = ref(7)
|
||||||
|
const progressLabel = ref("")
|
||||||
|
const progressPercent = computed(() =>
|
||||||
|
progressTotal.value > 0
|
||||||
|
? Math.round((progressStep.value / progressTotal.value) * 100)
|
||||||
|
: 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 img = store.loadedImage
|
||||||
|
if (!primary || !img || store.scalePxPerMm <= 0) return null
|
||||||
|
|
||||||
|
// Datum dimensions in output pixels
|
||||||
|
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 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) }
|
||||||
|
})
|
||||||
|
|
||||||
|
const tooLarge = computed(
|
||||||
|
() => (estimatedOutput.value?.mb ?? 0) > MAX_RGBA_MB,
|
||||||
|
)
|
||||||
|
|
||||||
async function ensureOpenCV() {
|
async function ensureOpenCV() {
|
||||||
if (cvReady.value) return
|
if (cvReady.value) return
|
||||||
@ -49,12 +134,27 @@ async function runDeskew() {
|
|||||||
await ensureOpenCV()
|
await ensureOpenCV()
|
||||||
|
|
||||||
store.processingStatus = "Running perspective correction..."
|
store.processingStatus = "Running perspective correction..."
|
||||||
|
progressStep.value = 0
|
||||||
|
progressLabel.value = "Starting..."
|
||||||
|
// Yield to let the browser repaint the spinner before heavy work
|
||||||
|
await new Promise((r) => {
|
||||||
|
requestAnimationFrame(r)
|
||||||
|
})
|
||||||
|
await new Promise((r) => {
|
||||||
|
requestAnimationFrame(r)
|
||||||
|
})
|
||||||
|
|
||||||
const result = await deskewImage({
|
const result = await deskewImage({
|
||||||
image: store.loadedImage,
|
image: store.loadedImage,
|
||||||
datums: store.datums,
|
datums: store.datums,
|
||||||
exif: store.exifData,
|
exif: store.exifData,
|
||||||
scalePxPerMm: store.scalePxPerMm,
|
scalePxPerMm: store.scalePxPerMm,
|
||||||
|
onProgress: (step, total, label) => {
|
||||||
|
progressStep.value = step
|
||||||
|
progressTotal.value = total
|
||||||
|
progressLabel.value = label
|
||||||
|
store.processingStatus = label
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
store.setResult(result)
|
store.setResult(result)
|
||||||
@ -69,12 +169,118 @@ async function runDeskew() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function download() {
|
function addScaleBar(image: HTMLImageElement): Promise<Blob> {
|
||||||
if (!resultUrl.value) return
|
return new Promise((resolve, reject) => {
|
||||||
|
const iw = image.naturalWidth
|
||||||
|
const ih = image.naturalHeight
|
||||||
|
const scale = store.scalePxPerMm
|
||||||
|
|
||||||
|
const unit = Math.max(iw / 100, 8)
|
||||||
|
const barHeightPx = Math.round(unit * 5)
|
||||||
|
const canvas = document.createElement("canvas")
|
||||||
|
canvas.width = iw
|
||||||
|
canvas.height = ih + barHeightPx
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d")
|
||||||
|
if (!ctx) {
|
||||||
|
reject(new Error("No 2D context"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.drawImage(image, 0, 0)
|
||||||
|
|
||||||
|
ctx.fillStyle = "#000"
|
||||||
|
ctx.fillRect(0, ih, iw, barHeightPx)
|
||||||
|
|
||||||
|
const imgWidthMm = iw / scale
|
||||||
|
const niceSteps = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000]
|
||||||
|
const targetMm = imgWidthMm * 0.2
|
||||||
|
let barMm = niceSteps[0] ?? 10
|
||||||
|
for (const s of niceSteps) {
|
||||||
|
barMm = s
|
||||||
|
if (s >= targetMm) break
|
||||||
|
}
|
||||||
|
const barWidthPx = barMm * scale
|
||||||
|
|
||||||
|
const margin = Math.round(unit * 2)
|
||||||
|
const barX = margin
|
||||||
|
const barY = ih + barHeightPx / 2
|
||||||
|
const barThick = Math.max(Math.round(unit * 0.6), 4)
|
||||||
|
const tickH = Math.round(unit * 1.5)
|
||||||
|
const tickW = Math.max(2, Math.round(unit * 0.15))
|
||||||
|
|
||||||
|
ctx.fillStyle = "#fff"
|
||||||
|
ctx.fillRect(barX, barY - barThick / 2, barWidthPx, barThick)
|
||||||
|
ctx.fillRect(barX, barY - tickH / 2, tickW, tickH)
|
||||||
|
ctx.fillRect(
|
||||||
|
barX + barWidthPx - tickW,
|
||||||
|
barY - tickH / 2,
|
||||||
|
tickW,
|
||||||
|
tickH,
|
||||||
|
)
|
||||||
|
|
||||||
|
const fontSize = Math.round(unit * 1.4)
|
||||||
|
ctx.font = `bold ${String(fontSize)}px monospace`
|
||||||
|
ctx.fillStyle = "#fff"
|
||||||
|
ctx.textAlign = "center"
|
||||||
|
ctx.textBaseline = "bottom"
|
||||||
|
ctx.fillText(
|
||||||
|
`${String(barMm)} mm`,
|
||||||
|
barX + barWidthPx / 2,
|
||||||
|
barY - tickH / 2 - Math.round(unit * 0.3),
|
||||||
|
)
|
||||||
|
|
||||||
|
const smallFont = Math.round(unit * 1)
|
||||||
|
ctx.textAlign = "right"
|
||||||
|
ctx.textBaseline = "middle"
|
||||||
|
ctx.font = `${String(smallFont)}px monospace`
|
||||||
|
ctx.fillStyle = "rgba(255,255,255,0.6)"
|
||||||
|
ctx.fillText(`${String(scale)} px/mm`, iw - margin, barY)
|
||||||
|
|
||||||
|
canvas.toBlob((b) => {
|
||||||
|
if (b) resolve(b)
|
||||||
|
else reject(new Error("toBlob failed"))
|
||||||
|
}, "image/png")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function download() {
|
||||||
|
if (!store.deskewResult) return
|
||||||
|
|
||||||
|
let blob: Blob = store.deskewResult.correctedImageBlob
|
||||||
|
|
||||||
|
if (includeScaleBar.value) {
|
||||||
|
// Load the corrected image into an HTMLImageElement for drawing
|
||||||
|
const imgUrl = URL.createObjectURL(
|
||||||
|
store.deskewResult.correctedImageBlob,
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
const image = await new Promise<HTMLImageElement>(
|
||||||
|
(resolve, reject) => {
|
||||||
|
const el = new Image()
|
||||||
|
el.onload = () => {
|
||||||
|
resolve(el)
|
||||||
|
}
|
||||||
|
el.onerror = () => {
|
||||||
|
reject(new Error("Failed to load image"))
|
||||||
|
}
|
||||||
|
el.src = imgUrl
|
||||||
|
},
|
||||||
|
)
|
||||||
|
blob = await addScaleBar(image)
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(imgUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement("a")
|
const a = document.createElement("a")
|
||||||
a.href = resultUrl.value
|
a.href = url
|
||||||
a.download = `skwik-${store.originalFile?.name ?? "output"}.png`
|
const baseName =
|
||||||
|
store.originalFile?.name.replace(/\.[^.]+$/, "") ?? "output"
|
||||||
|
a.download = `${baseName}-skwik.png`
|
||||||
a.click()
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasRects(): boolean {
|
function hasRects(): boolean {
|
||||||
@ -92,7 +298,9 @@ function hasRects(): boolean {
|
|||||||
download.
|
download.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" @click="store.goToStep(3)">Back</Button>
|
<Button variant="outline" @click="store.goToStep(3)"
|
||||||
|
>Back</Button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scale setting -->
|
<!-- Scale setting -->
|
||||||
@ -100,8 +308,8 @@ function hasRects(): boolean {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle class="text-base">Output Scale</CardTitle>
|
<CardTitle class="text-base">Output Scale</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Pixels per millimeter in the corrected output image. Higher
|
Pixels per millimeter in the corrected output image.
|
||||||
= larger output.
|
Higher = larger output.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@ -111,13 +319,45 @@ function hasRects(): boolean {
|
|||||||
:model-value="String(store.scalePxPerMm)"
|
:model-value="String(store.scalePxPerMm)"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
class="w-28"
|
class="w-28 font-mono"
|
||||||
@update:model-value="
|
@update:model-value="
|
||||||
(v: string | number) =>
|
(v: string | number) =>
|
||||||
(store.scalePxPerMm = Number(v) || 10)
|
(store.scalePxPerMm = Number(v) || 10)
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-muted-foreground">px / mm</span>
|
<span class="font-mono text-sm text-muted-foreground"
|
||||||
|
>px/mm</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<!-- Estimated output size -->
|
||||||
|
<div
|
||||||
|
v-if="estimatedOutput"
|
||||||
|
class="mt-3 space-y-1 text-sm"
|
||||||
|
:class="
|
||||||
|
tooLarge
|
||||||
|
? 'text-destructive'
|
||||||
|
: 'text-muted-foreground'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Est. output:
|
||||||
|
<span class="font-mono"
|
||||||
|
>~{{ estimatedOutput.w }}×{{
|
||||||
|
estimatedOutput.h
|
||||||
|
}}px</span
|
||||||
|
>
|
||||||
|
 — 
|
||||||
|
<span class="font-mono"
|
||||||
|
>~{{
|
||||||
|
estimatedOutput.mb.toFixed(0)
|
||||||
|
}} MB</span
|
||||||
|
>
|
||||||
|
RAM
|
||||||
|
</p>
|
||||||
|
<p v-if="tooLarge" class="font-medium">
|
||||||
|
Exceeds {{ MAX_RGBA_MB }} MB limit —
|
||||||
|
lower the scale or use a smaller source image.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -137,27 +377,36 @@ function hasRects(): boolean {
|
|||||||
v-for="datum in store.datums"
|
v-for="datum in store.datums"
|
||||||
:key="datum.id"
|
:key="datum.id"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
class="font-normal"
|
||||||
>
|
>
|
||||||
{{ datum.label }}
|
{{ datum.label }}
|
||||||
({{
|
<span class="ml-1 font-mono text-xs">{{
|
||||||
datum.type === "rectangle"
|
datum.type === "rectangle"
|
||||||
? `${datum.widthMm}\u00D7${datum.heightMm}mm`
|
? `${datum.widthMm}\u00D7${datum.heightMm}mm`
|
||||||
: `${datum.lengthMm}mm`
|
: `${datum.lengthMm}mm`
|
||||||
}}) — confidence {{ datum.confidence }}/5
|
}}</span>
|
||||||
|
<span class="ml-1 text-muted-foreground"
|
||||||
|
>conf {{ datum.confidence }}/5</span
|
||||||
|
>
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!hasRects()" class="mt-3 text-sm text-destructive">
|
<p
|
||||||
|
v-if="!hasRects()"
|
||||||
|
class="mt-3 text-sm text-destructive"
|
||||||
|
>
|
||||||
At least one rectangle datum is required for perspective
|
At least one rectangle datum is required for perspective
|
||||||
correction.
|
correction.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Run button -->
|
<!-- Run button + progress -->
|
||||||
<div class="flex flex-col items-center gap-3">
|
<div class="flex flex-col items-center gap-3">
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
:disabled="store.isProcessing || !hasRects()"
|
:disabled="
|
||||||
|
store.isProcessing || !hasRects() || tooLarge
|
||||||
|
"
|
||||||
@click="runDeskew"
|
@click="runDeskew"
|
||||||
>
|
>
|
||||||
<template v-if="store.isProcessing">
|
<template v-if="store.isProcessing">
|
||||||
@ -191,6 +440,20 @@ function hasRects(): boolean {
|
|||||||
}}
|
}}
|
||||||
</template>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<!-- Progress bar -->
|
||||||
|
<div
|
||||||
|
v-if="store.isProcessing"
|
||||||
|
class="w-full max-w-sm space-y-1.5"
|
||||||
|
>
|
||||||
|
<Progress :model-value="progressPercent" class="h-2" />
|
||||||
|
<p
|
||||||
|
class="text-center font-mono text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
[{{ progressStep + 1 }}/{{ progressTotal }}]
|
||||||
|
{{ progressLabel }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="error" class="text-center text-sm text-destructive">
|
<p v-if="error" class="text-center text-sm text-destructive">
|
||||||
@ -198,7 +461,7 @@ function hasRects(): boolean {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Result -->
|
<!-- Result -->
|
||||||
<template v-if="store.deskewResult">
|
<template v-if="store.deskewResult && resultUrl">
|
||||||
<!-- Diagnostics -->
|
<!-- Diagnostics -->
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@ -208,24 +471,60 @@ function hasRects(): boolean {
|
|||||||
<strong>{{
|
<strong>{{
|
||||||
store.deskewResult.diagnostics.primaryDatum
|
store.deskewResult.diagnostics.primaryDatum
|
||||||
}}</strong>
|
}}</strong>
|
||||||
 •  Output:
|
<span class="mx-1 text-muted-foreground/50"
|
||||||
{{
|
>|</span
|
||||||
store.deskewResult.diagnostics.outputWidthPx
|
>
|
||||||
}}×{{
|
Output:
|
||||||
store.deskewResult.diagnostics.outputHeightPx
|
<span class="font-mono"
|
||||||
}}px
|
>{{
|
||||||
|
store.deskewResult.diagnostics.outputWidthPx
|
||||||
|
}}×{{
|
||||||
|
store.deskewResult.diagnostics.outputHeightPx
|
||||||
|
}}px</span
|
||||||
|
>
|
||||||
|
<span class="mx-1 text-muted-foreground/50"
|
||||||
|
>|</span
|
||||||
|
>
|
||||||
|
<span class="font-mono"
|
||||||
|
>{{
|
||||||
|
(
|
||||||
|
store.deskewResult.diagnostics
|
||||||
|
.outputWidthPx / store.scalePxPerMm
|
||||||
|
).toFixed(1)
|
||||||
|
}}×{{
|
||||||
|
(
|
||||||
|
store.deskewResult.diagnostics
|
||||||
|
.outputHeightPx / store.scalePxPerMm
|
||||||
|
).toFixed(1)
|
||||||
|
}}mm</span
|
||||||
|
>
|
||||||
|
<span class="mx-1 text-muted-foreground/50"
|
||||||
|
>|</span
|
||||||
|
>
|
||||||
|
<span class="font-mono"
|
||||||
|
>{{
|
||||||
|
(
|
||||||
|
store.deskewResult.correctedImageBlob
|
||||||
|
.size /
|
||||||
|
1024 /
|
||||||
|
1024
|
||||||
|
).toFixed(1)
|
||||||
|
}} MB</span
|
||||||
|
>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-4">
|
<CardContent class="space-y-4">
|
||||||
<!-- Axis corrections -->
|
<!-- Axis corrections -->
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div class="rounded-md border p-3">
|
<div
|
||||||
|
class="rounded-md border border-border/50 p-3"
|
||||||
|
>
|
||||||
<p
|
<p
|
||||||
class="text-xs font-medium text-muted-foreground"
|
class="text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
||||||
>
|
>
|
||||||
X-axis correction
|
X-axis correction
|
||||||
</p>
|
</p>
|
||||||
<p class="text-lg font-semibold">
|
<p class="font-mono text-lg font-semibold">
|
||||||
{{
|
{{
|
||||||
(
|
(
|
||||||
store.deskewResult.diagnostics
|
store.deskewResult.diagnostics
|
||||||
@ -233,22 +532,25 @@ function hasRects(): boolean {
|
|||||||
).toFixed(2)
|
).toFixed(2)
|
||||||
}}%
|
}}%
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-muted-foreground">
|
<p
|
||||||
weight:
|
class="font-mono text-xs text-muted-foreground"
|
||||||
{{
|
>
|
||||||
|
w={{
|
||||||
store.deskewResult.diagnostics.xCorrection.totalWeight.toFixed(
|
store.deskewResult.diagnostics.xCorrection.totalWeight.toFixed(
|
||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-md border p-3">
|
<div
|
||||||
|
class="rounded-md border border-border/50 p-3"
|
||||||
|
>
|
||||||
<p
|
<p
|
||||||
class="text-xs font-medium text-muted-foreground"
|
class="text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
||||||
>
|
>
|
||||||
Y-axis correction
|
Y-axis correction
|
||||||
</p>
|
</p>
|
||||||
<p class="text-lg font-semibold">
|
<p class="font-mono text-lg font-semibold">
|
||||||
{{
|
{{
|
||||||
(
|
(
|
||||||
store.deskewResult.diagnostics
|
store.deskewResult.diagnostics
|
||||||
@ -256,9 +558,10 @@ function hasRects(): boolean {
|
|||||||
).toFixed(2)
|
).toFixed(2)
|
||||||
}}%
|
}}%
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-muted-foreground">
|
<p
|
||||||
weight:
|
class="font-mono text-xs text-muted-foreground"
|
||||||
{{
|
>
|
||||||
|
w={{
|
||||||
store.deskewResult.diagnostics.yCorrection.totalWeight.toFixed(
|
store.deskewResult.diagnostics.yCorrection.totalWeight.toFixed(
|
||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
@ -270,7 +573,8 @@ function hasRects(): boolean {
|
|||||||
<!-- Per-datum table -->
|
<!-- Per-datum table -->
|
||||||
<Table
|
<Table
|
||||||
v-if="
|
v-if="
|
||||||
store.deskewResult.diagnostics.perDatum.length > 0
|
store.deskewResult.diagnostics.perDatum.length >
|
||||||
|
0
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@ -283,32 +587,36 @@ function hasRects(): boolean {
|
|||||||
<TableHead class="text-right"
|
<TableHead class="text-right"
|
||||||
>Measured (mm)</TableHead
|
>Measured (mm)</TableHead
|
||||||
>
|
>
|
||||||
<TableHead class="text-right">Error</TableHead>
|
<TableHead class="text-right"
|
||||||
|
>Error</TableHead
|
||||||
|
>
|
||||||
<TableHead>Axis</TableHead>
|
<TableHead>Axis</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<TableRow
|
<TableRow
|
||||||
v-for="report in store.deskewResult.diagnostics
|
v-for="report in store.deskewResult
|
||||||
.perDatum"
|
.diagnostics.perDatum"
|
||||||
:key="report.label"
|
:key="report.label"
|
||||||
>
|
>
|
||||||
<TableCell class="font-medium">{{
|
<TableCell class="font-medium">{{
|
||||||
report.label
|
report.label
|
||||||
}}</TableCell>
|
}}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="outline" class="text-xs">{{
|
<Badge
|
||||||
report.type
|
variant="outline"
|
||||||
}}</Badge>
|
class="text-xs"
|
||||||
|
>{{ report.type }}</Badge
|
||||||
|
>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="text-right">{{
|
<TableCell class="font-mono text-right">{{
|
||||||
report.expectedMm.toFixed(1)
|
report.expectedMm.toFixed(1)
|
||||||
}}</TableCell>
|
}}</TableCell>
|
||||||
<TableCell class="text-right">{{
|
<TableCell class="font-mono text-right">{{
|
||||||
report.measuredMm.toFixed(1)
|
report.measuredMm.toFixed(1)
|
||||||
}}</TableCell>
|
}}</TableCell>
|
||||||
<TableCell
|
<TableCell
|
||||||
class="text-right"
|
class="font-mono text-right"
|
||||||
:class="
|
:class="
|
||||||
report.errorPercent > 5
|
report.errorPercent > 5
|
||||||
? 'text-destructive'
|
? 'text-destructive'
|
||||||
@ -326,45 +634,185 @@ function hasRects(): boolean {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Corrected image -->
|
<!-- Algorithm explanation -->
|
||||||
<Card>
|
<Card class="border-border/40">
|
||||||
<CardHeader>
|
<CardContent class="pb-5 pt-5">
|
||||||
<CardTitle class="text-base">Corrected Image</CardTitle>
|
<button
|
||||||
</CardHeader>
|
class="flex w-full items-center gap-2 text-left text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||||
<CardContent>
|
@click="
|
||||||
<div
|
showAlgoDetails = !showAlgoDetails
|
||||||
class="flex items-center justify-center overflow-hidden rounded-md bg-muted"
|
"
|
||||||
>
|
>
|
||||||
<img
|
<svg
|
||||||
v-if="resultUrl"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
:src="resultUrl"
|
width="14"
|
||||||
alt="Corrected image"
|
height="14"
|
||||||
class="max-h-[500px] w-full object-contain"
|
viewBox="0 0 24 24"
|
||||||
/>
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="shrink-0 transition-transform duration-200"
|
||||||
|
:class="
|
||||||
|
showAlgoDetails
|
||||||
|
? 'rotate-90'
|
||||||
|
: ''
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<path d="m9 18 6-6-6-6" />
|
||||||
|
</svg>
|
||||||
|
How the algorithm works
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-show="showAlgoDetails"
|
||||||
|
class="mt-4 pl-6"
|
||||||
|
>
|
||||||
|
<ol
|
||||||
|
class="list-decimal space-y-2 text-sm leading-relaxed text-muted-foreground marker:font-semibold marker:text-foreground/60"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
The primary rectangle datum
|
||||||
|
defines a
|
||||||
|
<strong
|
||||||
|
class="text-foreground/80"
|
||||||
|
>homography</strong
|
||||||
|
>
|
||||||
|
— a 3×3 projective
|
||||||
|
transform mapping the
|
||||||
|
quadrilateral in the source
|
||||||
|
image to a true rectangle at
|
||||||
|
the specified real-world
|
||||||
|
dimensions.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Secondary datums (additional
|
||||||
|
rectangles or line segments)
|
||||||
|
provide
|
||||||
|
<strong
|
||||||
|
class="text-foreground/80"
|
||||||
|
>weighted correction
|
||||||
|
factors</strong
|
||||||
|
>
|
||||||
|
for the X and Y axes. Each
|
||||||
|
secondary datum's contribution
|
||||||
|
is weighted by its confidence
|
||||||
|
score, refining the scale in
|
||||||
|
each axis independently.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
The final correction is applied
|
||||||
|
as a single
|
||||||
|
<code
|
||||||
|
class="rounded bg-muted px-1 py-0.5 font-mono text-xs text-foreground"
|
||||||
|
>cv::warpPerspective</code
|
||||||
|
>
|
||||||
|
call via OpenCV WASM, producing
|
||||||
|
the output image at the
|
||||||
|
requested px/mm scale.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div class="flex justify-center pb-8">
|
<!-- Corrected image with tools -->
|
||||||
<Button size="lg" @click="download">
|
<Card>
|
||||||
<svg
|
<CardHeader>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<CardTitle class="text-base"
|
||||||
width="18"
|
>Corrected Image</CardTitle
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
class="mr-2"
|
|
||||||
>
|
>
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
</CardHeader>
|
||||||
<polyline points="7 10 12 15 17 10" />
|
<CardContent>
|
||||||
<line x1="12" x2="12" y1="15" y2="3" />
|
<CorrectedImageViewer
|
||||||
</svg>
|
:image-url="resultUrl"
|
||||||
Download PNG
|
:scale-px-per-mm="store.scalePxPerMm"
|
||||||
</Button>
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Download -->
|
||||||
|
<div class="flex flex-col items-center gap-3 pb-8">
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<label
|
||||||
|
class="flex cursor-pointer items-center gap-2"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="scale-bar-check"
|
||||||
|
:checked="includeScaleBar"
|
||||||
|
@update:checked="
|
||||||
|
(v: boolean) =>
|
||||||
|
(includeScaleBar = v)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
for="scale-bar-check"
|
||||||
|
class="cursor-pointer text-sm text-muted-foreground"
|
||||||
|
>Include scale bar in export</Label
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" class="max-w-xs">
|
||||||
|
Appends a black bar at the bottom of the
|
||||||
|
exported image with a measurement scale and
|
||||||
|
px/mm annotation.
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Button size="lg" @click="download">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="mr-2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"
|
||||||
|
/>
|
||||||
|
<polyline points="7 10 12 15 17 10" />
|
||||||
|
<line
|
||||||
|
x1="12"
|
||||||
|
x2="12"
|
||||||
|
y1="15"
|
||||||
|
y2="3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Download PNG
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
variant="outline"
|
||||||
|
@click="store.reset()"
|
||||||
|
>
|
||||||
|
Process New Image
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="font-mono text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
store.deskewResult.diagnostics.outputWidthPx
|
||||||
|
}}×{{
|
||||||
|
store.deskewResult.diagnostics.outputHeightPx
|
||||||
|
}} px —
|
||||||
|
{{
|
||||||
|
(
|
||||||
|
store.deskewResult
|
||||||
|
.correctedImageBlob.size /
|
||||||
|
1024 /
|
||||||
|
1024
|
||||||
|
).toFixed(1)
|
||||||
|
}} MB
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
34
src/components/ui/checkbox/Checkbox.vue
Normal file
34
src/components/ui/checkbox/Checkbox.vue
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { CheckboxRootEmits, CheckboxRootProps } from 'reka-ui'
|
||||||
|
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { CheckIcon } from 'lucide-vue-next'
|
||||||
|
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
const emits = defineEmits<CheckboxRootEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CheckboxRoot
|
||||||
|
v-slot="slotProps"
|
||||||
|
data-slot="checkbox"
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="cn('border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[4px] border transition-colors group-has-disabled/field:opacity-50 focus-visible:ring-3 aria-invalid:ring-3 peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50', props.class)"
|
||||||
|
>
|
||||||
|
<CheckboxIndicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
class="[&>svg]:size-3.5 grid place-content-center text-current transition-none"
|
||||||
|
>
|
||||||
|
<slot v-bind="slotProps">
|
||||||
|
<CheckIcon />
|
||||||
|
</slot>
|
||||||
|
</CheckboxIndicator>
|
||||||
|
</CheckboxRoot>
|
||||||
|
</template>
|
||||||
1
src/components/ui/checkbox/index.ts
Normal file
1
src/components/ui/checkbox/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default as Checkbox } from './Checkbox.vue'
|
||||||
Loading…
x
Reference in New Issue
Block a user