Compare commits

..

No commits in common. "98c6fc9a357be8cd4a0ad6d19441ed433984adf2" and "3e0284da4cce4dca1968f25d6ca285763753c20a" have entirely different histories.

19 changed files with 337 additions and 2376 deletions

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@ -6,44 +6,20 @@ import ExifViewer from "@/components/ExifViewer.vue"
import DatumEditor from "@/components/DatumEditor.vue" import DatumEditor from "@/components/DatumEditor.vue"
import ResultViewer from "@/components/ResultViewer.vue" import ResultViewer from "@/components/ResultViewer.vue"
import ThemeToggle from "@/components/ThemeToggle.vue" import ThemeToggle from "@/components/ThemeToggle.vue"
import SkwikLogo from "@/components/SkwikLogo.vue"
const store = useAppStore() const store = useAppStore()
</script> </script>
<template> <template>
<div class="min-h-screen bg-background text-foreground"> <div class="min-h-screen bg-background text-foreground">
<!-- Gitea fork ribbon top-left, desktop only -->
<a
href="https://serv.e1n.sh/git/sam1902/skwik"
target="_blank"
rel="noopener"
class="github-fork-ribbon fixed left-0 top-0 z-[100] hidden md:block"
data-ribbon="Fork me on Gitea"
title="Fork me on Gitea"
>Fork me on Gitea</a
>
<header <header
class="sticky top-0 z-50 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60" class="sticky top-0 z-50 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
> >
<div <div
class="mx-auto grid h-14 max-w-7xl grid-cols-3 items-center px-4" class="mx-auto flex h-14 max-w-7xl items-center justify-between px-4"
> >
<div><!-- spacer for ribbon --></div> <h1 class="text-lg font-semibold tracking-tight">Skwik</h1>
<div class="flex items-center justify-center gap-2"> <div class="flex items-center gap-4">
<SkwikLogo :size="28" />
<h1
class="font-mono text-lg font-semibold tracking-tight"
>
Skwik
</h1>
<span
class="hidden text-[10px] font-medium uppercase tracking-widest text-muted-foreground sm:inline"
>Perspective Correction</span
>
</div>
<div class="flex items-center justify-end gap-4">
<StepIndicator /> <StepIndicator />
<ThemeToggle /> <ThemeToggle />
</div> </div>
@ -56,18 +32,5 @@ const store = useAppStore()
<DatumEditor v-else-if="store.currentStep === 3" /> <DatumEditor v-else-if="store.currentStep === 3" />
<ResultViewer v-else-if="store.currentStep === 4" /> <ResultViewer v-else-if="store.currentStep === 4" />
</main> </main>
<footer
class="border-t border-border/50 py-4 text-center text-xs text-muted-foreground"
>
Made by
<a
href="https://github.com/usr-ein"
target="_blank"
rel="noopener"
class="underline underline-offset-2 transition-colors hover:text-foreground"
>Samuel Prevost</a
>
</footer>
</div> </div>
</template> </template>

View File

@ -1,5 +1,4 @@
@import url("https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap");
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap');
@import "tailwindcss"; @import "tailwindcss";
@ -10,8 +9,7 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@theme inline { @theme inline {
--font-sans: "Geist", "Geist Variable", ui-sans-serif, sans-serif; --font-sans: "Geist Variable", sans-serif;
--font-mono: "Geist Mono", ui-monospace, monospace;
--font-heading: var(--font-sans); --font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
@ -86,10 +84,10 @@
} }
.dark { .dark {
--background: oklch(0.13 0 0); --background: oklch(0.145 0 0);
--foreground: oklch(0.95 0 0); --foreground: oklch(0.985 0 0);
--card: oklch(0.175 0 0); --card: oklch(0.205 0 0);
--card-foreground: oklch(0.95 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0); --popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0); --primary: oklch(0.922 0 0);
@ -101,7 +99,7 @@
--accent: oklch(0.269 0 0); --accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 12%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0); --ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0); --chart-1: oklch(0.87 0 0);
@ -128,63 +126,3 @@
@apply font-sans; @apply font-sans;
} }
} }
/* Fork-me ribbon — adapted from github-fork-ribbon-css */
.github-fork-ribbon {
width: 12.1em;
height: 12.1em;
overflow: hidden;
position: fixed;
top: 0;
left: 0;
z-index: 100;
pointer-events: none;
text-decoration: none;
text-indent: -999em;
font-size: 0.85em;
}
.github-fork-ribbon::before,
.github-fork-ribbon::after {
position: absolute;
display: block;
width: 15.38em;
height: 1.54em;
top: 3.23em;
right: 0.5em;
box-sizing: content-box;
transform: rotate(-45deg);
}
.github-fork-ribbon::before {
content: "";
padding: 0.38em 0;
background-color: #2d7a2e;
background-image: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.05),
rgba(0, 0, 0, 0.15)
);
box-shadow:
0 0.15em 0.23em 0 rgba(0, 0, 0, 0.5),
0 0 0 1px #5cb85c,
inset 0 1px 0 rgba(255, 255, 255, 0.2),
inset 0 -1px 0 rgba(0, 0, 0, 0.2);
border-top: 1px dashed rgba(255, 255, 255, 0.25);
border-bottom: 1px dashed rgba(255, 255, 255, 0.25);
pointer-events: auto;
}
.github-fork-ribbon::after {
content: attr(data-ribbon);
color: #fff;
font: 700 0.9em "Helvetica Neue", Helvetica, Arial, sans-serif;
line-height: 1.54em;
text-decoration: none;
text-align: center;
text-indent: 0;
text-shadow:
0 -1px 0 rgba(0, 0, 0, 0.5),
0 1px 2px rgba(0, 0, 0, 0.3);
pointer-events: auto;
}

View File

@ -1,638 +0,0 @@
<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>

View File

@ -49,23 +49,19 @@ 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 = datum.type === "rectangle" ? datum.corners : datum.endpoints const points = datum.type === "rectangle" ? datum.corners : datum.endpoints
const baseRadius = isSelected ? 6 : 4 const radius = isSelected ? 10 : 7
const visualRadius = Math.max(
baseRadius / scale.value,
baseRadius * 0.5
)
return points.map((pt, pIdx) => ({ return points.map((pt, pIdx) => ({
x: pt.x, x: pt.x,
y: pt.y, y: pt.y,
radius: visualRadius, radius: radius / scale.value,
fill: color, fill: color,
stroke: isSelected ? "#fff" : color, stroke: isSelected ? "#fff" : color,
strokeWidth: 1.5 / scale.value, strokeWidth: 2 / scale.value,
draggable: true, draggable: true,
_datumId: datum.id, _datumId: datum.id,
_pointIndex: pIdx, _pointIndex: pIdx,
hitStrokeWidth: 12 / scale.value, hitStrokeWidth: 20 / scale.value,
})) }))
} }

View File

@ -1,9 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from "vue" import { ref, computed } from "vue"
import { useMediaQuery } from "@vueuse/core" import { useMediaQuery } from "@vueuse/core"
import { useAppStore } from "@/stores/app" import { useAppStore } from "@/stores/app"
import { saveDatums } from "@/lib/datum-cache"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Tooltip, Tooltip,
@ -42,16 +40,6 @@ const nextTooltip = computed(() => {
const names = incompleteDatums.value.map((d) => d.label) const names = incompleteDatums.value.map((d) => d.label)
return `Missing dimensions: ${names.join(", ")}` return `Missing dimensions: ${names.join(", ")}`
}) })
watch(
() => store.datums,
(datums) => {
if (store.fileHash && datums.length > 0) {
saveDatums(store.fileHash, datums)
}
},
{ deep: true },
)
</script> </script>
<template> <template>
@ -89,16 +77,6 @@ watch(
</div> </div>
</div> </div>
<Transition name="fade">
<Badge
v-if="store.cacheRestoreMessage"
variant="secondary"
class="text-xs"
>
{{ store.cacheRestoreMessage }}
</Badge>
</Transition>
<!-- Single layout: canvas always present, sidebar conditionally placed --> <!-- Single layout: canvas always present, sidebar conditionally placed -->
<div <div
class="grid gap-4" class="grid gap-4"

View File

@ -149,9 +149,7 @@ function getExifRows(): ExifRow[] {
<TableCell class="font-medium">{{ <TableCell class="font-medium">{{
row.label row.label
}}</TableCell> }}</TableCell>
<TableCell class="font-mono text-sm">{{ <TableCell>{{ row.value }}</TableCell>
row.value
}}</TableCell>
</TableRow> </TableRow>
</TableBody> </TableBody>
</Table> </Table>
@ -181,31 +179,26 @@ function getExifRows(): ExifRow[] {
</svg> </svg>
<div class="text-sm text-muted-foreground"> <div class="text-sm text-muted-foreground">
<p class="font-medium text-foreground"> <p class="font-medium text-foreground">
Why EXIF matters Lens Correction Info
</p> </p>
<p class="mt-1"> <p class="mt-1">
Focal length and lens model data allow the
algorithm to estimate radial distortion
(barrel/pincushion) introduced by the lens.
Without this, the perspective correction relies
solely on the datum geometry you provide.
</p>
<p class="mt-2">
<template v-if="store.exifData.focalLength"> <template v-if="store.exifData.focalLength">
Detected This image was shot at
<span class="font-mono font-medium text-foreground" <strong
>{{ store.exifData.focalLength }}mm</span >{{ store.exifData.focalLength }}mm</strong
> >
<template v-if="store.exifData.lensModel"> <template v-if="store.exifData.lensModel">
on with a
<span class="font-medium text-foreground">{{ <strong>{{
store.exifData.lensModel store.exifData.lensModel
}}</span> </template }}</strong> </template
>. Lens correction will be applied. >. The deskew algorithm can use this to correct
barrel/pincushion distortion.
</template> </template>
<template v-else> <template v-else>
No focal length found. Lens distortion No focal length data found. The algorithm will
correction will be skipped. rely solely on datum measurements for
perspective correction.
</template> </template>
</p> </p>
</div> </div>

View File

@ -1,10 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from "vue" import { ref } from "vue"
import { useAppStore } from "@/stores/app" import { useAppStore } from "@/stores/app"
import { loadImage } from "@/lib/image-loader" import { loadImage } from "@/lib/image-loader"
import { extractExif } from "@/lib/exif" import { extractExif } from "@/lib/exif"
import { hashFile } from "@/lib/file-hash"
import { loadDatums, clearCache, getCacheSize } from "@/lib/datum-cache"
import { import {
Card, Card,
CardContent, CardContent,
@ -12,62 +10,33 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card" } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Progress } from "@/components/ui/progress" import { Progress } from "@/components/ui/progress"
const store = useAppStore() const store = useAppStore()
const isDragging = ref(false) const isDragging = ref(false)
const error = ref("") const error = ref("")
const fileInput = ref<HTMLInputElement | null>(null) const fileInput = ref<HTMLInputElement | null>(null)
const cacheCount = ref(0)
const ACCEPTED = ".jpg,.jpeg,.heic,.heif" const ACCEPTED = ".jpg,.jpeg,.heic,.heif"
onMounted(() => {
cacheCount.value = getCacheSize()
})
function handleClearCache() {
clearCache()
cacheCount.value = 0
}
async function handleFile(file: File) { async function handleFile(file: File) {
error.value = "" error.value = ""
store.isProcessing = true store.isProcessing = true
store.processingStatus = "Reading file..." store.processingStatus = "Reading file..."
try { try {
const { image, convertedFile } = await loadImage( const { image, convertedFile } = await loadImage(file, (status) => {
file,
(status) => {
store.processingStatus = status store.processingStatus = status
}, })
)
store.processingStatus = "Extracting EXIF data..." store.processingStatus = "Extracting EXIF data..."
const exif = await extractExif(file) const exif = await extractExif(file)
store.processingStatus = "Computing file hash..."
const hash = await hashFile(file)
store.setFileHash(hash)
const cached = loadDatums(hash)
if (cached && cached.length > 0) {
store.datums = cached
store.cacheRestoreMessage =
`Restored ${String(cached.length)} datum${cached.length === 1 ? "" : "s"} from cache`
setTimeout(() => {
store.cacheRestoreMessage = ""
}, 4000)
}
store.setImage(convertedFile, image) store.setImage(convertedFile, image)
store.setExif(exif) store.setExif(exif)
store.goToStep(2) store.goToStep(2)
} catch (e) { } catch (e) {
error.value = error.value = e instanceof Error ? e.message : "Failed to load image"
e instanceof Error ? e.message : "Failed to load image"
} finally { } finally {
store.isProcessing = false store.isProcessing = false
store.processingStatus = "" store.processingStatus = ""
@ -88,14 +57,13 @@ function onFileSelect(e: Event) {
</script> </script>
<template> <template>
<div class="flex min-h-[60vh] items-start justify-center pt-12"> <div class="flex min-h-[60vh] items-center justify-center">
<div class="w-full max-w-2xl space-y-6"> <Card class="w-full max-w-lg">
<Card>
<CardHeader class="text-center"> <CardHeader class="text-center">
<CardTitle class="text-lg">Load Source Image</CardTitle> <CardTitle class="text-2xl">Upload an Image</CardTitle>
<CardDescription> <CardDescription>
Drop a JPG or HEIC file, or click to browse. HEIC Drop a JPG or HEIC image, or click to browse. HEIC files
is converted automatically. will be converted automatically.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -144,10 +112,8 @@ function onFileSelect(e: Event) {
>browse</span >browse</span
> >
</p> </p>
<p <p class="mt-1 text-xs text-muted-foreground/70">
class="mt-1 font-mono text-xs text-muted-foreground/60" JPG, JPEG, HEIC, HEIF
>
.jpg .jpeg .heic .heif
</p> </p>
</template> </template>
@ -168,191 +134,5 @@ function onFileSelect(e: Event) {
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<div
v-if="cacheCount > 0"
class="flex justify-end"
>
<Button
variant="ghost"
size="sm"
class="h-7 gap-1.5 text-xs text-muted-foreground/60 hover:text-destructive"
@click="handleClearCache"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 6h18" />
<path
d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"
/>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
</svg>
Clear cache ({{ cacheCount }})
</Button>
</div>
<div class="space-y-2 text-left">
<p class="text-xs font-medium uppercase tracking-wider text-muted-foreground/70">Example</p>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<img
src="/example-before.jpg"
alt="Before: angled photograph of a Pioneer CDJ-1000MK3 top case"
class="w-full rounded-md border border-border object-cover"
/>
<p class="text-xs text-muted-foreground">Before &mdash; angled shot</p>
</div>
<div class="space-y-1.5">
<img
src="/example-after.jpg"
alt="After: perspective-corrected front-facing view"
class="w-full rounded-md border border-border object-cover"
/>
<p class="text-xs text-muted-foreground">After &mdash; corrected perspective</p>
</div>
</div>
</div>
<div class="space-y-2 text-left">
<p class="text-sm leading-relaxed text-muted-foreground">
Correct perspective distortion in photographs using
known reference dimensions. Useful when you need to
use a photo as a scale reference for design work,
measure objects from photographs, or recover accurate
geometry from angled shots.
</p>
</div>
<div class="space-y-4 text-left">
<h3 class="text-sm font-medium text-foreground">How it works</h3>
<p class="text-sm leading-relaxed text-muted-foreground">
Place an object with known dimensions (a ruler, credit card, or A4 sheet) next to the subject
you want to photograph. Take the picture from any angle. Skwik uses the reference object to
compute a perspective transform and produce a corrected, front-facing image with accurate
proportions.
</p>
<p class="text-sm leading-relaxed text-muted-foreground">
This is especially handy for reverse-engineering enclosure cutouts, measuring parts you
can't easily reach, or getting a dimensionally accurate top-down view without a tripod.
</p>
<h3 class="mt-8 text-sm font-medium text-foreground">Tips for best results</h3>
<ul class="list-disc space-y-2 pl-5 text-sm leading-relaxed text-muted-foreground">
<li>
<strong class="text-foreground/80">Use a large, rigid rectangle with precise dimensions.</strong>
An A4 magazine cover works well. Plain paper is acceptable but can bend or curl,
which degrades accuracy.
</li>
<li>
<strong class="text-foreground/80">Lay everything on a flat surface.</strong>
Both the subject and the reference object must sit on the same plane.
</li>
<li>
<strong class="text-foreground/80">Shoot from as high up as possible and zoom in.</strong>
Stand on a chair or stool and hold your phone at arm's length above the scene.
Use optical zoom (2&times; or more) to narrow the field of view &mdash; this compresses
perspective closer to an orthogonal projection and makes the correction more accurate.
</li>
</ul>
<!-- Side-view illustration: person on chair photographing downward -->
<div class="mt-4 flex justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 360 260"
class="w-full max-w-xl"
role="img"
aria-label="Side-view illustration: a person standing on a chair holds a phone high, zooming in on an object on the ground below"
>
<!-- Ground line -->
<line x1="0" y1="240" x2="360" y2="240" stroke="currentColor" stroke-width="1.5" class="text-border" />
<!-- Object on the ground directly below phone (centered at x=180) -->
<rect x="145" y="230" width="70" height="10" rx="2"
class="text-primary" fill="currentColor" opacity="0.18"
stroke="currentColor" stroke-width="1.2" />
<rect x="165" y="224" width="30" height="16" rx="1"
fill="none" stroke="currentColor" stroke-width="0.8"
stroke-dasharray="3 2" class="text-muted-foreground" />
<text x="170" y="245" font-size="7" class="text-muted-foreground" fill="currentColor">ref</text>
<!-- Chair (simple side-view) -->
<g class="text-muted-foreground" stroke="currentColor" stroke-width="1.5" fill="none">
<!-- seat -->
<line x1="150" y1="170" x2="190" y2="170" />
<!-- legs -->
<line x1="153" y1="170" x2="150" y2="240" />
<line x1="187" y1="170" x2="190" y2="240" />
<!-- back rest -->
<line x1="150" y1="170" x2="147" y2="125" />
<line x1="147" y1="125" x2="157" y2="125" />
</g>
<!-- Person (stick figure standing on the chair) -->
<g class="text-foreground" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round">
<!-- head -->
<circle cx="170" cy="102" r="9" />
<!-- body -->
<line x1="170" y1="111" x2="170" y2="150" />
<!-- legs on chair seat -->
<line x1="170" y1="150" x2="160" y2="170" />
<line x1="170" y1="150" x2="180" y2="170" />
<!-- arms reaching forward/down holding phone -->
<polyline points="170,125 183,115 180,108" />
<!-- other arm at side for balance -->
<line x1="170" y1="125" x2="152" y2="140" />
</g>
<!-- Phone in hand (held out, pointing straight down) -->
<rect x="175" y="100" width="10" height="16" rx="2"
class="text-foreground" fill="currentColor" opacity="0.85" />
<!-- small lens dot -->
<circle cx="180" cy="116" r="1.5" class="text-background" fill="currentColor" />
<!-- Camera FOV cone wide angle (faded, dashed) pointing straight down -->
<polygon points="180,116 124,238 236,238"
fill="currentColor" class="text-muted-foreground" opacity="0.06" />
<line x1="180" y1="116" x2="124" y2="238"
stroke="currentColor" stroke-width="0.8" stroke-dasharray="4 3"
class="text-muted-foreground" opacity="0.3" />
<line x1="180" y1="116" x2="236" y2="238"
stroke="currentColor" stroke-width="0.8" stroke-dasharray="4 3"
class="text-muted-foreground" opacity="0.3" />
<!-- Camera FOV cone zoomed in (narrow, stronger) pointing straight down -->
<polygon points="180,116 155,230 205,230"
fill="currentColor" class="text-primary" opacity="0.10" />
<line x1="180" y1="116" x2="155" y2="230"
stroke="currentColor" stroke-width="1.2"
class="text-primary" opacity="0.6" />
<line x1="180" y1="116" x2="205" y2="230"
stroke="currentColor" stroke-width="1.2"
class="text-primary" opacity="0.6" />
<!-- Labels -->
<text x="238" y="230" font-size="8" class="text-muted-foreground" fill="currentColor">wide angle</text>
<text x="238" y="240" font-size="7" class="text-muted-foreground" fill="currentColor">(more distortion)</text>
<text x="207" y="215" font-size="8" class="text-primary" fill="currentColor" font-weight="600">zoomed in</text>
<!-- Small arrow showing "higher = better" -->
<g class="text-muted-foreground" stroke="currentColor" stroke-width="1" opacity="0.5">
<line x1="50" y1="230" x2="50" y2="105" />
<polyline points="45,112 50,105 55,112" fill="none" />
</g>
<text x="28" y="168" font-size="7" class="text-muted-foreground" fill="currentColor"
transform="rotate(-90 40 168)">higher is better</text>
</svg>
</div>
</div>
</div>
</div> </div>
</template> </template>

View File

@ -1,19 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue" import { ref } 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,
@ -30,11 +21,6 @@ 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)
@ -42,77 +28,6 @@ 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
@ -134,27 +49,12 @@ 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)
@ -169,118 +69,12 @@ async function runDeskew() {
} }
} }
function addScaleBar(image: HTMLImageElement): Promise<Blob> { function download() {
return new Promise((resolve, reject) => { if (!resultUrl.value) return
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 = url a.href = resultUrl.value
const baseName = a.download = `skwik-${store.originalFile?.name ?? "output"}.png`
store.originalFile?.name.replace(/\.[^.]+$/, "") ?? "output"
a.download = `${baseName}-skwik.png`
a.click() a.click()
URL.revokeObjectURL(url)
} }
function hasRects(): boolean { function hasRects(): boolean {
@ -298,9 +92,7 @@ function hasRects(): boolean {
download. download.
</p> </p>
</div> </div>
<Button variant="outline" @click="store.goToStep(3)" <Button variant="outline" @click="store.goToStep(3)">Back</Button>
>Back</Button
>
</div> </div>
<!-- Scale setting --> <!-- Scale setting -->
@ -308,8 +100,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. Pixels per millimeter in the corrected output image. Higher
Higher = larger output. = larger output.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -319,45 +111,13 @@ function hasRects(): boolean {
:model-value="String(store.scalePxPerMm)" :model-value="String(store.scalePxPerMm)"
type="number" type="number"
min="1" min="1"
class="w-28 font-mono" class="w-28"
@update:model-value=" @update:model-value="
(v: string | number) => (v: string | number) =>
(store.scalePxPerMm = Number(v) || 10) (store.scalePxPerMm = Number(v) || 10)
" "
/> />
<span class="font-mono text-sm text-muted-foreground" <span class="text-sm text-muted-foreground">px / mm</span>
>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 }}&times;{{
estimatedOutput.h
}}px</span
>
&ensp;&mdash;&ensp;
<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 &mdash;
lower the scale or use a smaller source image.
</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -377,36 +137,27 @@ 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`
}}</span> }}) &mdash; confidence {{ datum.confidence }}/5
<span class="ml-1 text-muted-foreground"
>conf {{ datum.confidence }}/5</span
>
</Badge> </Badge>
</div> </div>
<p <p v-if="!hasRects()" class="mt-3 text-sm text-destructive">
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 + progress --> <!-- Run button -->
<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=" :disabled="store.isProcessing || !hasRects()"
store.isProcessing || !hasRects() || tooLarge
"
@click="runDeskew" @click="runDeskew"
> >
<template v-if="store.isProcessing"> <template v-if="store.isProcessing">
@ -440,20 +191,6 @@ 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">
@ -461,7 +198,7 @@ function hasRects(): boolean {
</p> </p>
<!-- Result --> <!-- Result -->
<template v-if="store.deskewResult && resultUrl"> <template v-if="store.deskewResult">
<!-- Diagnostics --> <!-- Diagnostics -->
<Card> <Card>
<CardHeader> <CardHeader>
@ -471,60 +208,24 @@ function hasRects(): boolean {
<strong>{{ <strong>{{
store.deskewResult.diagnostics.primaryDatum store.deskewResult.diagnostics.primaryDatum
}}</strong> }}</strong>
<span class="mx-1 text-muted-foreground/50" &ensp;&bull;&ensp; Output:
>|</span {{
>
Output:
<span class="font-mono"
>{{
store.deskewResult.diagnostics.outputWidthPx store.deskewResult.diagnostics.outputWidthPx
}}&times;{{ }}&times;{{
store.deskewResult.diagnostics.outputHeightPx store.deskewResult.diagnostics.outputHeightPx
}}px</span }}px
>
<span class="mx-1 text-muted-foreground/50"
>|</span
>
<span class="font-mono"
>{{
(
store.deskewResult.diagnostics
.outputWidthPx / store.scalePxPerMm
).toFixed(1)
}}&times;{{
(
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 <div class="rounded-md border p-3">
class="rounded-md border border-border/50 p-3"
>
<p <p
class="text-xs font-medium uppercase tracking-wide text-muted-foreground" class="text-xs font-medium text-muted-foreground"
> >
X-axis correction X-axis correction
</p> </p>
<p class="font-mono text-lg font-semibold"> <p class="text-lg font-semibold">
{{ {{
( (
store.deskewResult.diagnostics store.deskewResult.diagnostics
@ -532,25 +233,22 @@ function hasRects(): boolean {
).toFixed(2) ).toFixed(2)
}}% }}%
</p> </p>
<p <p class="text-xs text-muted-foreground">
class="font-mono text-xs text-muted-foreground" weight:
> {{
w={{
store.deskewResult.diagnostics.xCorrection.totalWeight.toFixed( store.deskewResult.diagnostics.xCorrection.totalWeight.toFixed(
1, 1,
) )
}} }}
</p> </p>
</div> </div>
<div <div class="rounded-md border p-3">
class="rounded-md border border-border/50 p-3"
>
<p <p
class="text-xs font-medium uppercase tracking-wide text-muted-foreground" class="text-xs font-medium text-muted-foreground"
> >
Y-axis correction Y-axis correction
</p> </p>
<p class="font-mono text-lg font-semibold"> <p class="text-lg font-semibold">
{{ {{
( (
store.deskewResult.diagnostics store.deskewResult.diagnostics
@ -558,10 +256,9 @@ function hasRects(): boolean {
).toFixed(2) ).toFixed(2)
}}% }}%
</p> </p>
<p <p class="text-xs text-muted-foreground">
class="font-mono text-xs text-muted-foreground" weight:
> {{
w={{
store.deskewResult.diagnostics.yCorrection.totalWeight.toFixed( store.deskewResult.diagnostics.yCorrection.totalWeight.toFixed(
1, 1,
) )
@ -573,8 +270,7 @@ function hasRects(): boolean {
<!-- Per-datum table --> <!-- Per-datum table -->
<Table <Table
v-if=" v-if="
store.deskewResult.diagnostics.perDatum.length > store.deskewResult.diagnostics.perDatum.length > 0
0
" "
> >
<TableHeader> <TableHeader>
@ -587,36 +283,32 @@ function hasRects(): boolean {
<TableHead class="text-right" <TableHead class="text-right"
>Measured (mm)</TableHead >Measured (mm)</TableHead
> >
<TableHead class="text-right" <TableHead class="text-right">Error</TableHead>
>Error</TableHead
>
<TableHead>Axis</TableHead> <TableHead>Axis</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
<TableRow <TableRow
v-for="report in store.deskewResult v-for="report in store.deskewResult.diagnostics
.diagnostics.perDatum" .perDatum"
:key="report.label" :key="report.label"
> >
<TableCell class="font-medium">{{ <TableCell class="font-medium">{{
report.label report.label
}}</TableCell> }}</TableCell>
<TableCell> <TableCell>
<Badge <Badge variant="outline" class="text-xs">{{
variant="outline" report.type
class="text-xs" }}</Badge>
>{{ report.type }}</Badge
>
</TableCell> </TableCell>
<TableCell class="font-mono text-right">{{ <TableCell class="text-right">{{
report.expectedMm.toFixed(1) report.expectedMm.toFixed(1)
}}</TableCell> }}</TableCell>
<TableCell class="font-mono text-right">{{ <TableCell class="text-right">{{
report.measuredMm.toFixed(1) report.measuredMm.toFixed(1)
}}</TableCell> }}</TableCell>
<TableCell <TableCell
class="font-mono text-right" class="text-right"
:class=" :class="
report.errorPercent > 5 report.errorPercent > 5
? 'text-destructive' ? 'text-destructive'
@ -634,134 +326,26 @@ function hasRects(): boolean {
</CardContent> </CardContent>
</Card> </Card>
<!-- Algorithm explanation --> <!-- Corrected image -->
<Card class="border-border/40"> <Card>
<CardContent class="pb-5 pt-5"> <CardHeader>
<button <CardTitle class="text-base">Corrected Image</CardTitle>
class="flex w-full items-center gap-2 text-left text-sm font-medium text-muted-foreground transition-colors hover:text-foreground" </CardHeader>
@click=" <CardContent>
showAlgoDetails = !showAlgoDetails
"
>
<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"
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 <div
v-show="showAlgoDetails" class="flex items-center justify-center overflow-hidden rounded-md bg-muted"
class="mt-4 pl-6"
> >
<ol <img
class="list-decimal space-y-2 text-sm leading-relaxed text-muted-foreground marker:font-semibold marker:text-foreground/60" v-if="resultUrl"
> :src="resultUrl"
<li> alt="Corrected image"
The primary rectangle datum class="max-h-[500px] w-full object-contain"
defines a />
<strong
class="text-foreground/80"
>homography</strong
>
&mdash; a 3&times;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>
<!-- Corrected image with tools --> <div class="flex justify-center pb-8">
<Card>
<CardHeader>
<CardTitle class="text-base"
>Corrected Image</CardTitle
>
</CardHeader>
<CardContent>
<CorrectedImageViewer
:image-url="resultUrl"
:scale-px-per-mm="store.scalePxPerMm"
/>
</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"> <Button size="lg" @click="download">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@ -775,44 +359,12 @@ function hasRects(): boolean {
stroke-linejoin="round" stroke-linejoin="round"
class="mr-2" class="mr-2"
> >
<path <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"
/>
<polyline points="7 10 12 15 17 10" /> <polyline points="7 10 12 15 17 10" />
<line <line x1="12" x2="12" y1="15" y2="3" />
x1="12"
x2="12"
y1="15"
y2="3"
/>
</svg> </svg>
Download PNG Download PNG
</Button> </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
}}&times;{{
store.deskewResult.diagnostics.outputHeightPx
}} px &mdash;
{{
(
store.deskewResult
.correctedImageBlob.size /
1024 /
1024
).toFixed(1)
}} MB
</p>
</div> </div>
</template> </template>
</div> </div>

View File

@ -1,280 +0,0 @@
<script setup lang="ts">
withDefaults(
defineProps<{
size?: number
}>(),
{
size: 28,
}
)
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 64 64"
:width="size"
:height="size"
aria-label="Skwik squirrel logo"
>
<!-- Tail (fluffy, curling up behind) -->
<path
d="M50 52 Q58 40 54 28 Q52 22 48 20
Q55 18 56 12 Q56 8 52 10
Q48 12 46 18"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
opacity="0.7"
/>
<!-- Body -->
<ellipse
cx="32"
cy="46"
rx="12"
ry="10"
fill="none"
stroke="currentColor"
stroke-width="2"
/>
<!-- Head -->
<ellipse
cx="32"
cy="28"
rx="12"
ry="13"
fill="none"
stroke="currentColor"
stroke-width="2"
/>
<!-- Ear tufts -->
<path
d="M22 18 Q18 10 14 12 Q12 16 20 20"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M42 18 Q46 10 50 12 Q52 16 44 20"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Eyes -->
<circle
cx="27"
cy="26"
r="3"
fill="currentColor"
/>
<circle
cx="37"
cy="26"
r="3"
fill="currentColor"
/>
<!-- Eye shine -->
<circle cx="28.2" cy="25" r="1" fill="var(--background, #fff)" />
<circle cx="38.2" cy="25" r="1" fill="var(--background, #fff)" />
<!-- Nose -->
<ellipse
cx="32"
cy="32"
rx="2"
ry="1.5"
fill="currentColor"
/>
<!-- Cheeks -->
<ellipse
cx="24"
cy="32"
rx="4"
ry="3"
fill="none"
stroke="currentColor"
stroke-width="1"
opacity="0.3"
/>
<ellipse
cx="40"
cy="32"
rx="4"
ry="3"
fill="none"
stroke="currentColor"
stroke-width="1"
opacity="0.3"
/>
<!-- Mouth -->
<path
d="M30 34 Q32 36 34 34"
fill="none"
stroke="currentColor"
stroke-width="1.2"
stroke-linecap="round"
/>
<!-- Arms holding ruler -->
<path
d="M20 42 L12 38"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
<path
d="M44 42 L52 38"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
<!-- Ruler (held diagonally) -->
<rect
x="4"
y="34"
width="22"
height="4"
rx="0.5"
fill="none"
stroke="currentColor"
stroke-width="1.5"
transform="rotate(-15 15 36)"
/>
<!-- Ruler tick marks -->
<line
x1="8" y1="34" x2="8" y2="36"
stroke="currentColor"
stroke-width="1"
transform="rotate(-15 15 36)"
/>
<line
x1="12" y1="34" x2="12" y2="36"
stroke="currentColor"
stroke-width="1"
transform="rotate(-15 15 36)"
/>
<line
x1="16" y1="34" x2="16" y2="36"
stroke="currentColor"
stroke-width="1"
transform="rotate(-15 15 36)"
/>
<line
x1="20" y1="34" x2="20" y2="36"
stroke="currentColor"
stroke-width="1"
transform="rotate(-15 15 36)"
/>
<!-- Safety goggles on forehead -->
<path
d="M22 20 Q27 17 32 18 Q37 17 42 20"
fill="none"
stroke="#f59e0b"
stroke-width="2"
stroke-linecap="round"
/>
<!-- Goggle lenses -->
<ellipse
cx="26"
cy="20"
rx="3.5"
ry="2.5"
fill="none"
stroke="#f59e0b"
stroke-width="1.5"
/>
<ellipse
cx="38"
cy="20"
rx="3.5"
ry="2.5"
fill="none"
stroke="#f59e0b"
stroke-width="1.5"
/>
<!-- Goggle bridge -->
<path
d="M29.5 20 Q32 19 34.5 20"
fill="none"
stroke="#f59e0b"
stroke-width="1.2"
/>
<!-- Goggle lens tint -->
<ellipse
cx="26"
cy="20"
rx="2.5"
ry="1.5"
fill="#f59e0b"
opacity="0.15"
/>
<ellipse
cx="38"
cy="20"
rx="2.5"
ry="1.5"
fill="#f59e0b"
opacity="0.15"
/>
<!-- Hard hat -->
<path
d="M18 16 Q18 6 32 5 Q46 6 46 16"
fill="none"
stroke="#f59e0b"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Hat brim -->
<path
d="M16 16 L48 16"
stroke="#f59e0b"
stroke-width="2"
stroke-linecap="round"
/>
<!-- Hat dome highlight -->
<path
d="M26 9 Q32 7 38 9"
fill="none"
stroke="#fbbf24"
stroke-width="1.5"
stroke-linecap="round"
opacity="0.6"
/>
<!-- Feet -->
<ellipse
cx="26"
cy="56"
rx="4"
ry="2"
fill="none"
stroke="currentColor"
stroke-width="1.5"
/>
<ellipse
cx="38"
cy="56"
rx="4"
ry="2"
fill="none"
stroke="currentColor"
stroke-width="1.5"
/>
</svg>
</template>

View File

@ -17,32 +17,17 @@ const steps = [
<template v-for="(step, i) in steps" :key="step.num"> <template v-for="(step, i) in steps" :key="step.num">
<Badge <Badge
:variant=" :variant="
store.currentStep === step.num store.currentStep === step.num ? 'default' : 'outline'
? 'default'
: 'outline'
" "
class="select-none font-mono text-xs" class="cursor-default select-none text-xs"
:class="{ :class="{
'opacity-40': store.currentStep < step.num, 'opacity-40': store.currentStep < step.num,
'cursor-pointer hover:bg-accent':
step.num <= store.maxStepReached
&& step.num !== store.currentStep,
'cursor-default':
step.num > store.maxStepReached
|| step.num === store.currentStep,
}" }"
@click="
step.num <= store.maxStepReached
? store.goToStep(step.num)
: undefined
"
> >
{{ step.num }}.{{ step.label }} {{ step.num }}. {{ step.label }}
</Badge> </Badge>
<span <span v-if="i < steps.length - 1" class="text-muted-foreground"
v-if="i < steps.length - 1" >&middot;</span
class="text-xs text-muted-foreground/40"
>&rsaquo;</span
> >
</template> </template>
</nav> </nav>

View File

@ -1,34 +0,0 @@
<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>

View File

@ -1 +0,0 @@
export { default as Checkbox } from './Checkbox.vue'

View File

@ -1,48 +0,0 @@
import type { Datum } from "@/types"
const KEY_PREFIX = "skwik-datums-"
export function saveDatums(hash: string, datums: Datum[]): void {
try {
localStorage.setItem(
KEY_PREFIX + hash,
JSON.stringify(datums),
)
} catch {
// localStorage full or unavailable — silently ignore
}
}
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[]
} catch {
return null
}
}
export function clearCache(): void {
const toRemove: string[] = []
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key?.startsWith(KEY_PREFIX)) {
toRemove.push(key)
}
}
for (const key of toRemove) {
localStorage.removeItem(key)
}
}
export function getCacheSize(): number {
let count = 0
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key?.startsWith(KEY_PREFIX)) {
count++
}
}
return count
}

View File

@ -1,8 +1,9 @@
/** /**
* deskew.ts Browser-based perspective correction using OpenCV.js (WASM) * deskew.ts Browser-based perspective correction using OpenCV.js (WASM)
* *
* Accepts N datums (rectangles and/or lines), each with known real-world * Adapted from the reference algorithm. Accepts N datums (rectangles and/or
* dimensions and a confidence score (15). Minimum: one rectangle. * lines), each with known real-world dimensions and a confidence score (15).
* Minimum: one rectangle.
* *
* Algorithm: * Algorithm:
* 1. Pick the highest-confidence rectangle as primary reference. * 1. Pick the highest-confidence rectangle as primary reference.
@ -26,10 +27,6 @@ import type {
RectDatum, RectDatum,
} from "@/types" } from "@/types"
// Max output dimension in pixels to avoid WASM OOM
// 12288 = ~576MB RGBA at square, but actual images are rarely square
const MAX_OUTPUT_DIM = 12288
// ─── OpenCV helpers ────────────────────────────────────────────────────────── // ─── OpenCV helpers ──────────────────────────────────────────────────────────
function pointsToMat(points: Point[]): InstanceType<typeof cv.Mat> { function pointsToMat(points: Point[]): InstanceType<typeof cv.Mat> {
@ -78,8 +75,7 @@ function mul3x3(A: number[], B: number[]): number[] {
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 += sum += (A[r * 3 + k] ?? 0) * (B[k * 3 + c] ?? 0)
(A[r * 3 + k] ?? 0) * (B[k * 3 + c] ?? 0)
} }
R[r * 3 + c] = sum R[r * 3 + c] = sum
} }
@ -90,20 +86,17 @@ function mul3x3(A: number[], B: number[]): number[] {
// ─── Validation ────────────────────────────────────────────────────────────── // ─── Validation ──────────────────────────────────────────────────────────────
function pickPrimary(datums: Datum[]): RectDatum { function pickPrimary(datums: Datum[]): RectDatum {
const rects = datums.filter( const rects = datums.filter((d): d is RectDatum => d.type === "rectangle")
(d): d is RectDatum => d.type === "rectangle",
)
if (rects.length === 0) { if (rects.length === 0) {
throw new Error( throw new Error(
"At least one rectangle datum is required for perspective correction.", "At least one rectangle datum is required for perspective correction.",
) )
} }
// Highest confidence; tie-break by pixel area (larger = more precise corners)
rects.sort((a, b) => { rects.sort((a, b) => {
if (b.confidence !== a.confidence) if (b.confidence !== a.confidence) return b.confidence - a.confidence
return b.confidence - a.confidence
const area = (r: RectDatum) => const area = (r: RectDatum) =>
dist(r.corners[0], r.corners[1]) * dist(r.corners[0], r.corners[1]) * dist(r.corners[0], r.corners[3])
dist(r.corners[0], r.corners[3])
return area(b) - area(a) return area(b) - area(a)
}) })
return rects[0] as RectDatum return rects[0] as RectDatum
@ -116,6 +109,7 @@ function pickPrimary(datums: Datum[]): RectDatum {
function cornersToAlgoOrder( function cornersToAlgoOrder(
corners: [Point, Point, Point, Point], corners: [Point, Point, Point, Point],
): [Point, Point, Point, Point] { ): [Point, Point, Point, Point] {
// App: [TL, TR, BR, BL] → Algo: [TL, TR, BL, BR]
return [corners[0], corners[1], corners[3], corners[2]] return [corners[0], corners[1], corners[3], corners[2]]
} }
@ -129,8 +123,11 @@ function canvasToBlob(
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
canvas.toBlob( canvas.toBlob(
(b) => { (b) => {
if (b) resolve(b) if (b) {
else reject(new Error("toBlob failed")) resolve(b)
} else {
reject(new Error("toBlob failed"))
}
}, },
type, type,
quality, quality,
@ -140,91 +137,47 @@ function canvasToBlob(
// ─── Core ──────────────────────────────────────────────────────────────────── // ─── Core ────────────────────────────────────────────────────────────────────
const log = (tag: string, ...args: unknown[]) => { export async function deskewImage(input: DeskewInput): Promise<DeskewResult> {
console.log(`[deskew:${tag}]`, ...args) const { image, datums, scalePxPerMm: scale } = input
}
export async function deskewImage(
input: DeskewInput,
): Promise<DeskewResult> {
const { image, datums, scalePxPerMm: scale, onProgress } = input
log("start", `${String(datums.length)} datums, scale=${String(scale)} px/mm`)
const TOTAL_STEPS = 7
const progress = async (step: number, label: string) => {
log(`progress`, `[${String(step + 1)}/${String(TOTAL_STEPS)}] ${label}`)
onProgress?.(step, TOTAL_STEPS, label)
// Yield to let the browser repaint
await new Promise((r) => {
requestAnimationFrame(r)
})
}
if (datums.length === 0) throw new Error("No datums provided.") if (datums.length === 0) throw new Error("No datums provided.")
const primary = pickPrimary(datums) const primary = pickPrimary(datums)
log("primary", primary.label, `${String(primary.widthMm)}×${String(primary.heightMm)}mm`, `conf=${String(primary.confidence)}`)
// Load source image into OpenCV // Load source image into OpenCV
let srcCanvas: HTMLCanvasElement let srcCanvas: HTMLCanvasElement
if (image instanceof HTMLCanvasElement) { if (image instanceof HTMLCanvasElement) {
srcCanvas = image srcCanvas = image
log("input", `canvas ${String(image.width)}×${String(image.height)}`)
} else { } else {
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("input", `img ${String(image.naturalWidth)}×${String(image.naturalHeight)}, drawing to canvas`)
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)
} }
const src = cv.imread(srcCanvas)
await progress(0, "Loading image into OpenCV")
// All OpenCV mats to clean up
const mats: InstanceType<typeof cv.Mat>[] = []
const track = <T extends InstanceType<typeof cv.Mat>>(m: T): T => {
mats.push(m)
return m
}
try {
log("cv.imread", "reading source canvas into cv.Mat")
const src = track(cv.imread(srcCanvas))
const imgW = src.cols const imgW = src.cols
const imgH = src.rows const imgH = src.rows
log("cv.imread", `done: ${String(imgW)}×${String(imgH)}, type=${String(src.type())}, channels=${String(src.channels())}`)
// ============================================================ // ================================================================
// STEP 1 — Initial perspective correction from primary rect // STEP 1 — Initial perspective correction from primary rectangle
// ============================================================ // ================================================================
await progress(1, "Computing initial homography")
const pw = primary.widthMm * scale const pw = primary.widthMm * scale
const ph = primary.heightMm * scale const ph = primary.heightMm * scale
log("step1", `dest rect: ${pw.toFixed(1)}×${ph.toFixed(1)} px`)
const algoCorners = cornersToAlgoOrder(primary.corners) const algoCorners = cornersToAlgoOrder(primary.corners)
log("step1", `corners (algo order): ${JSON.stringify(algoCorners)}`) const srcPts = pointsToMat(algoCorners)
const srcPts = track(pointsToMat(algoCorners)) const dstInit = pointsToMat([
const dstInit = track(
pointsToMat([
{ x: 0, y: 0 }, { x: 0, y: 0 },
{ x: pw, y: 0 }, { x: pw, y: 0 },
{ x: 0, y: ph }, { x: 0, y: ph },
{ x: pw, y: ph }, { x: pw, y: ph },
]), ])
) const mInit = cv.getPerspectiveTransform(srcPts, dstInit)
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 // STEP 2 — Measure all secondary datums, accumulate corrections
// ============================================================ // ================================================================
await progress(2, "Measuring secondary datums")
let xWSum = 0, let xWSum = 0,
xWTotal = 0 xWTotal = 0
let yWSum = 0, let yWSum = 0,
@ -247,12 +200,7 @@ export async function deskewImage(
} }
if (datum.type === "line") { if (datum.type === "line") {
const pts = transformPoints( const [s, e] = transformPoints(datum.endpoints as Point[], mInit)
datum.endpoints as Point[],
mInit,
)
const s = pts[0]
const e = pts[1]
if (!s || !e) continue if (!s || !e) continue
const dx = Math.abs(e.x - s.x) const dx = Math.abs(e.x - s.x)
const dy = Math.abs(e.y - s.y) const dy = Math.abs(e.y - s.y)
@ -260,6 +208,7 @@ export async function deskewImage(
const expected = datum.lengthMm * scale const expected = datum.lengthMm * scale
const ratio = expected / measured const ratio = expected / measured
// Axis contribution proportional to alignment
const total = dx + dy const total = dx + dy
if (total > 1e-6) { if (total > 1e-6) {
const xFrac = dx / total const xFrac = dx / total
@ -279,14 +228,9 @@ export async function deskewImage(
axisContribution: dx > dy ? "x" : "y", axisContribution: dx > dy ? "x" : "y",
}) })
} else { } else {
// Secondary rectangle: top edge → X, left edge → Y
const ac = cornersToAlgoOrder(datum.corners) const ac = cornersToAlgoOrder(datum.corners)
const pts = transformPoints( const [tl, tr, bl] = transformPoints([ac[0], ac[1], ac[2]], mInit)
[ac[0], ac[1], ac[2]],
mInit,
)
const tl = pts[0]
const tr = pts[1]
const bl = pts[2]
if (!tl || !tr || !bl) continue if (!tl || !tr || !bl) continue
const mW = dist(tl, tr) const mW = dist(tl, tr)
const mH = dist(tl, bl) const mH = dist(tl, bl)
@ -303,17 +247,15 @@ export async function deskewImage(
type: "rectangle", type: "rectangle",
measuredMm: mW / scale, measuredMm: mW / scale,
expectedMm: datum.widthMm, expectedMm: datum.widthMm,
errorPercent: errorPercent: (Math.abs(1 - xR) + Math.abs(1 - yR)) * 50,
(Math.abs(1 - xR) + Math.abs(1 - yR)) * 50,
axisContribution: "both", axisContribution: "both",
}) })
} }
} }
// ============================================================ // ================================================================
// STEP 3 — Weighted corrections (1.0 = no secondary data) // STEP 3 — Weighted corrections (1.0 = no secondary data)
// ============================================================ // ================================================================
await progress(3, "Computing axis corrections")
const xCorr: AxisCorrection = { const xCorr: AxisCorrection = {
ratio: xWTotal > 0 ? xWSum / xWTotal : 1.0, ratio: xWTotal > 0 ? xWSum / xWTotal : 1.0,
totalWeight: xWTotal, totalWeight: xWTotal,
@ -322,34 +264,24 @@ export async function deskewImage(
ratio: yWTotal > 0 ? yWSum / yWTotal : 1.0, ratio: yWTotal > 0 ? yWSum / yWTotal : 1.0,
totalWeight: yWTotal, 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 // STEP 4 — Fold into destination rectangle, recompute transform
// ============================================================ // ================================================================
await progress(4, "Recomputing final transform")
const pwFinal = pw * xCorr.ratio const pwFinal = pw * xCorr.ratio
const phFinal = ph * yCorr.ratio const phFinal = ph * yCorr.ratio
log("step4", `final dest rect: ${pwFinal.toFixed(1)}×${phFinal.toFixed(1)} px`)
const dstFinal = track( const dstFinal = pointsToMat([
pointsToMat([
{ x: 0, y: 0 }, { x: 0, y: 0 },
{ x: pwFinal, y: 0 }, { x: pwFinal, y: 0 },
{ x: 0, y: phFinal }, { x: 0, y: phFinal },
{ x: pwFinal, y: phFinal }, { x: pwFinal, y: phFinal },
]), ])
) const mFinal = cv.getPerspectiveTransform(srcPts, dstFinal)
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 // 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 },
@ -357,12 +289,6 @@ export async function deskewImage(
{ x: imgW, y: imgH }, { x: imgW, y: imgH },
] ]
const warped = transformPoints(imgCorners, mFinal) const warped = transformPoints(imgCorners, mFinal)
if (warped.length < 4) {
throw new Error(
"Perspective transform produced invalid bounds",
)
}
let xMin = Infinity, let xMin = Infinity,
yMin = Infinity, yMin = Infinity,
xMax = -Infinity, xMax = -Infinity,
@ -374,45 +300,18 @@ export async function deskewImage(
yMax = Math.max(yMax, c.y) yMax = Math.max(yMax, c.y)
} }
let outW = Math.ceil(xMax - xMin) const outW = Math.ceil(xMax - xMin)
let outH = Math.ceil(yMax - yMin) const outH = Math.ceil(yMax - yMin)
log("step5", `bounds: x=[${xMin.toFixed(1)}, ${xMax.toFixed(1)}], y=[${yMin.toFixed(1)}, ${yMax.toFixed(1)}]`)
log("step5", `raw output: ${String(outW)}×${String(outH)} px`)
// Guard against absurd output sizes that crash WASM
if (outW <= 0 || outH <= 0) {
throw new Error(
`Invalid output dimensions: ${String(outW)}×${String(outH)}`,
)
}
let downscale = 1
if (outW > MAX_OUTPUT_DIM || outH > MAX_OUTPUT_DIM) {
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)
outH = Math.ceil(outH * downscale)
}
log("step5", `final output: ${String(outW)}×${String(outH)} px (${String(Math.round(outW * outH * 4 / 1024 / 1024))} MB RGBA)`)
const mData: number[] = readMat3x3(mFinal) const mData: number[] = readMat3x3(mFinal)
// Translate so the top-left warped corner is at (0,0), const tShift: number[] = [1, 0, -xMin, 0, 1, -yMin, 0, 0, 1]
// then scale down if we clamped the output size.
const tShift: number[] = [
downscale, 0, -xMin * downscale,
0, downscale, -yMin * downscale,
0, 0, 1,
]
const mOutData: number[] = mul3x3(tShift, mData) const mOutData: number[] = mul3x3(tShift, mData)
const mOut = track( const mOut = cv.matFromArray(3, 3, cv.CV_64FC1, mOutData)
cv.matFromArray(3, 3, cv.CV_64FC1, mOutData),
)
// ============================================================ // ================================================================
// STEP 6 — Warp // STEP 6 — Warp
// ============================================================ // ================================================================
await progress(6, "Warping image (this may take a moment)") const dstMat = new cv.Mat()
log("step6", "calling warpPerspective...")
const dstMat = track(new cv.Mat())
cv.warpPerspective( cv.warpPerspective(
src, src,
dstMat, dstMat,
@ -423,17 +322,22 @@ 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())}`)
log("export", "cv.imshow to canvas")
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)") // Cleanup OpenCV mats
src.delete()
srcPts.delete()
dstInit.delete()
mInit.delete()
dstFinal.delete()
mFinal.delete()
mOut.delete()
dstMat.delete()
const blob = await canvasToBlob(outCanvas, "image/png", 0.95) const blob = await canvasToBlob(outCanvas, "image/png", 0.95)
log("export", `blob size: ${String(Math.round(blob.size / 1024))} KB`)
const diagnostics: DeskewDiagnostics = { const diagnostics: DeskewDiagnostics = {
primaryDatum: primary.label, primaryDatum: primary.label,
@ -444,51 +348,20 @@ export async function deskewImage(
outputHeightPx: outH, outputHeightPx: outH,
} }
log("done", "success")
return { correctedImageBlob: blob, diagnostics } return { correctedImageBlob: blob, diagnostics }
} finally {
// Always clean up all OpenCV mats, even on error
for (const m of mats) {
try {
m.delete()
} catch {
// already deleted or invalid — ignore
}
}
}
} }
// ─── OpenCV init ──────────────────────────────────────────────────────────── // ─── OpenCV init ────────────────────────────────────────────────────────────
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) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
log("opencv", "already ready") if (cv.Mat) {
resolve() resolve()
return return
} }
// Test if WASM is actually functional by trying to create a mat
try {
log("opencv", "probing cv.Mat()...")
const test = new cv.Mat()
test.delete()
cvReady = true
log("opencv", "probe succeeded, WASM ready")
resolve()
return
} catch {
log("opencv", "probe failed, waiting for onRuntimeInitialized")
// Not ready yet, wait for callback
}
cv.onRuntimeInitialized = () => { cv.onRuntimeInitialized = () => {
cvReady = true
log("opencv", "onRuntimeInitialized fired, WASM ready")
resolve() resolve()
} }
}) })

View File

@ -1,6 +0,0 @@
export async function hashFile(file: File): Promise<string> {
const buffer = await file.arrayBuffer()
const hashBuffer = await crypto.subtle.digest("SHA-256", buffer)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("")
}

View File

@ -1,24 +0,0 @@
export interface SkwikSettings {
scalePxPerMm: number
includeScaleBar: boolean
}
const KEY = "skwik-settings"
export function saveSettings(settings: SkwikSettings): void {
try {
localStorage.setItem(KEY, JSON.stringify(settings))
} catch {
// localStorage full or unavailable — silently ignore
}
}
export function loadSettings(): SkwikSettings | null {
try {
const raw = localStorage.getItem(KEY)
if (!raw) return null
return JSON.parse(raw) as SkwikSettings
} catch {
return null
}
}

View File

@ -2,13 +2,9 @@ import { defineStore } from "pinia"
import { ref, computed } from "vue" import { ref, computed } from "vue"
import type { AppStep, Datum, DeskewResult, ExifData } 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"
export const useAppStore = defineStore("app", () => { export const useAppStore = defineStore("app", () => {
const cached = loadSettings()
const currentStep = ref<AppStep>(1) const currentStep = ref<AppStep>(1)
const maxStepReached = ref<AppStep>(1)
const originalFile = ref<File | null>(null) const originalFile = ref<File | null>(null)
const loadedImage = ref<HTMLImageElement | null>(null) const loadedImage = ref<HTMLImageElement | null>(null)
const exifData = ref<ExifData>({}) const exifData = ref<ExifData>({})
@ -17,11 +13,7 @@ export const useAppStore = defineStore("app", () => {
const isProcessing = ref(false) const isProcessing = ref(false)
const processingStatus = ref("") const processingStatus = ref("")
const selectedDatumId = ref<string | null>(null) const selectedDatumId = ref<string | null>(null)
const scalePxPerMm = ref( const scalePxPerMm = ref(DEFAULT_SCALE_PX_PER_MM)
cached?.scalePxPerMm ?? DEFAULT_SCALE_PX_PER_MM,
)
const fileHash = ref<string | null>(null)
const cacheRestoreMessage = ref("")
const canProceedToStep2 = computed(() => loadedImage.value !== null) const canProceedToStep2 = computed(() => loadedImage.value !== null)
const canProceedToStep3 = computed(() => canProceedToStep2.value) const canProceedToStep3 = computed(() => canProceedToStep2.value)
@ -44,9 +36,6 @@ export const useAppStore = defineStore("app", () => {
function goToStep(step: AppStep) { function goToStep(step: AppStep) {
currentStep.value = step currentStep.value = step
if (step > maxStepReached.value) {
maxStepReached.value = step
}
} }
function addDatum(datum: Datum) { function addDatum(datum: Datum) {
@ -76,13 +65,8 @@ export const useAppStore = defineStore("app", () => {
deskewResult.value = result deskewResult.value = result
} }
function setFileHash(hash: string) {
fileHash.value = hash
}
function reset() { function reset() {
currentStep.value = 1 currentStep.value = 1
maxStepReached.value = 1
originalFile.value = null originalFile.value = null
loadedImage.value = null loadedImage.value = null
exifData.value = {} exifData.value = {}
@ -92,13 +76,10 @@ export const useAppStore = defineStore("app", () => {
processingStatus.value = "" processingStatus.value = ""
selectedDatumId.value = null selectedDatumId.value = null
scalePxPerMm.value = DEFAULT_SCALE_PX_PER_MM scalePxPerMm.value = DEFAULT_SCALE_PX_PER_MM
fileHash.value = null
cacheRestoreMessage.value = ""
} }
return { return {
currentStep, currentStep,
maxStepReached,
originalFile, originalFile,
loadedImage, loadedImage,
exifData, exifData,
@ -108,8 +89,6 @@ export const useAppStore = defineStore("app", () => {
processingStatus, processingStatus,
selectedDatumId, selectedDatumId,
scalePxPerMm, scalePxPerMm,
fileHash,
cacheRestoreMessage,
canProceedToStep2, canProceedToStep2,
canProceedToStep3, canProceedToStep3,
canProceedToStep4, canProceedToStep4,
@ -120,7 +99,6 @@ export const useAppStore = defineStore("app", () => {
updateDatum, updateDatum,
removeDatum, removeDatum,
setResult, setResult,
setFileHash,
reset, reset,
} }
}) })

View File

@ -49,8 +49,6 @@ export interface DeskewInput {
exif: ExifData exif: ExifData
/** Output pixels per mm. */ /** Output pixels per mm. */
scalePxPerMm: number scalePxPerMm: number
/** Called with (stepIndex 0-based, totalSteps, stepLabel) */
onProgress?: (step: number, total: number, label: string) => void
} }
export interface AxisCorrection { export interface AxisCorrection {