Compare commits

..

4 Commits

Author SHA1 Message Date
Samuel Prevost
98c6fc9a35 feat(ui): squirrel logo, fork ribbon, clickable steps, and polish
- Squirrel engineer logo (SkwikLogo.vue) with hard hat and ruler
- Matching favicon with squirrel head silhouette
- Gitea fork ribbon (top-left, desktop only, Gitea green)
- Centered header with logo, title, and subtitle
- Footer: "Made by Samuel Prevost" with GitHub link
- Clickable step indicators for previously visited steps
- Smaller datum dots (6/4 base radius with visual cap)
- Engineering-tool styling: monospace for measurements, Geist Mono
  font, deeper dark mode colors, instrument-panel header
- EXIF viewer explains why focal length matters
- Upload page describes what Skwik does

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-14 23:19:44 +02:00
Samuel Prevost
11e8013b6a feat(cache): persist datums per file hash and user settings
- Add file-hash.ts: SHA-256 hash of uploaded files via Web Crypto API
- Add datum-cache.ts: localStorage save/load/clear for datums by hash
- Add settings-cache.ts: persist scalePxPerMm and includeScaleBar
- Restore datums from cache on re-upload of same file
- Discreet "Clear cache" button on upload page
- Store fileHash and cacheRestoreMessage in Pinia store
- Auto-save datums on every change via deep watcher
- Track maxStepReached for clickable step navigation

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-14 23:19:34 +02:00
Samuel Prevost
0cb9009eaa 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>
2026-04-14 23:19:25 +02:00
Samuel Prevost
1bc1f46bb8 feat(deskew): add debug logging, progress callbacks, and WASM safety
- Add step-by-step console logging throughout the algorithm
- Add onProgress callback for UI progress bar integration
- Fix WASM OOM: clamp output dimensions with proper matrix scaling
  (previously clamped size but not the transform, causing cropping)
- Fix waitForOpenCV race condition: probe cv.Mat() instead of
  checking constructor existence
- Wrap all OpenCV mats in try/finally for guaranteed cleanup
- Raise MAX_OUTPUT_DIM to 12288 for more leniency

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-14 23:19:16 +02:00
19 changed files with 2369 additions and 330 deletions

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -6,20 +6,44 @@ import ExifViewer from "@/components/ExifViewer.vue"
import DatumEditor from "@/components/DatumEditor.vue"
import ResultViewer from "@/components/ResultViewer.vue"
import ThemeToggle from "@/components/ThemeToggle.vue"
import SkwikLogo from "@/components/SkwikLogo.vue"
const store = useAppStore()
</script>
<template>
<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
class="sticky top-0 z-50 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
>
<div
class="mx-auto flex h-14 max-w-7xl items-center justify-between px-4"
class="mx-auto grid h-14 max-w-7xl grid-cols-3 items-center px-4"
>
<h1 class="text-lg font-semibold tracking-tight">Skwik</h1>
<div class="flex items-center gap-4">
<div><!-- spacer for ribbon --></div>
<div class="flex items-center justify-center gap-2">
<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 />
<ThemeToggle />
</div>
@ -32,5 +56,18 @@ const store = useAppStore()
<DatumEditor v-else-if="store.currentStep === 3" />
<ResultViewer v-else-if="store.currentStep === 4" />
</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>
</template>

View File

@ -1,4 +1,5 @@
@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";
@ -9,7 +10,8 @@
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-sans: "Geist Variable", sans-serif;
--font-sans: "Geist", "Geist Variable", ui-sans-serif, sans-serif;
--font-mono: "Geist Mono", ui-monospace, monospace;
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
@ -84,10 +86,10 @@
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--background: oklch(0.13 0 0);
--foreground: oklch(0.95 0 0);
--card: oklch(0.175 0 0);
--card-foreground: oklch(0.95 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
@ -99,7 +101,7 @@
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--border: oklch(1 0 0 / 12%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
@ -126,3 +128,63 @@
@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

@ -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>

View File

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

View File

@ -1,7 +1,9 @@
<script setup lang="ts">
import { ref, computed } from "vue"
import { ref, computed, watch } from "vue"
import { useMediaQuery } from "@vueuse/core"
import { useAppStore } from "@/stores/app"
import { saveDatums } from "@/lib/datum-cache"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Tooltip,
@ -40,6 +42,16 @@ const nextTooltip = computed(() => {
const names = incompleteDatums.value.map((d) => d.label)
return `Missing dimensions: ${names.join(", ")}`
})
watch(
() => store.datums,
(datums) => {
if (store.fileHash && datums.length > 0) {
saveDatums(store.fileHash, datums)
}
},
{ deep: true },
)
</script>
<template>
@ -77,6 +89,16 @@ const nextTooltip = computed(() => {
</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 -->
<div
class="grid gap-4"

View File

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

View File

@ -1,8 +1,10 @@
<script setup lang="ts">
import { ref } from "vue"
import { ref, onMounted } from "vue"
import { useAppStore } from "@/stores/app"
import { loadImage } from "@/lib/image-loader"
import { extractExif } from "@/lib/exif"
import { hashFile } from "@/lib/file-hash"
import { loadDatums, clearCache, getCacheSize } from "@/lib/datum-cache"
import {
Card,
CardContent,
@ -10,33 +12,62 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Progress } from "@/components/ui/progress"
const store = useAppStore()
const isDragging = ref(false)
const error = ref("")
const fileInput = ref<HTMLInputElement | null>(null)
const cacheCount = ref(0)
const ACCEPTED = ".jpg,.jpeg,.heic,.heif"
onMounted(() => {
cacheCount.value = getCacheSize()
})
function handleClearCache() {
clearCache()
cacheCount.value = 0
}
async function handleFile(file: File) {
error.value = ""
store.isProcessing = true
store.processingStatus = "Reading file..."
try {
const { image, convertedFile } = await loadImage(file, (status) => {
const { image, convertedFile } = await loadImage(
file,
(status) => {
store.processingStatus = status
})
},
)
store.processingStatus = "Extracting EXIF data..."
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.setExif(exif)
store.goToStep(2)
} catch (e) {
error.value = e instanceof Error ? e.message : "Failed to load image"
error.value =
e instanceof Error ? e.message : "Failed to load image"
} finally {
store.isProcessing = false
store.processingStatus = ""
@ -57,13 +88,14 @@ function onFileSelect(e: Event) {
</script>
<template>
<div class="flex min-h-[60vh] items-center justify-center">
<Card class="w-full max-w-lg">
<div class="flex min-h-[60vh] items-start justify-center pt-12">
<div class="w-full max-w-2xl space-y-6">
<Card>
<CardHeader class="text-center">
<CardTitle class="text-2xl">Upload an Image</CardTitle>
<CardTitle class="text-lg">Load Source Image</CardTitle>
<CardDescription>
Drop a JPG or HEIC image, or click to browse. HEIC files
will be converted automatically.
Drop a JPG or HEIC file, or click to browse. HEIC
is converted automatically.
</CardDescription>
</CardHeader>
<CardContent>
@ -112,8 +144,10 @@ function onFileSelect(e: Event) {
>browse</span
>
</p>
<p class="mt-1 text-xs text-muted-foreground/70">
JPG, JPEG, HEIC, HEIF
<p
class="mt-1 font-mono text-xs text-muted-foreground/60"
>
.jpg .jpeg .heic .heif
</p>
</template>
@ -134,5 +168,191 @@ function onFileSelect(e: Event) {
</p>
</CardContent>
</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>
</template>

View File

@ -1,10 +1,19 @@
<script setup lang="ts">
import { ref } from "vue"
import { ref, computed, onMounted, watch } from "vue"
import { useAppStore } from "@/stores/app"
import { deskewImage, waitForOpenCV } from "@/lib/deskew"
import type { RectDatum } from "@/types"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
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 {
Card,
CardContent,
@ -21,6 +30,11 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table"
import CorrectedImageViewer from "@/components/CorrectedImageViewer.vue"
import {
loadSettings,
saveSettings,
} from "@/lib/settings-cache"
const store = useAppStore()
const resultUrl = ref<string | null>(null)
@ -28,6 +42,77 @@ const error = ref("")
const hasRun = ref(false)
const cvReady = 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() {
if (cvReady.value) return
@ -49,12 +134,27 @@ async function runDeskew() {
await ensureOpenCV()
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({
image: store.loadedImage,
datums: store.datums,
exif: store.exifData,
scalePxPerMm: store.scalePxPerMm,
onProgress: (step, total, label) => {
progressStep.value = step
progressTotal.value = total
progressLabel.value = label
store.processingStatus = label
},
})
store.setResult(result)
@ -69,12 +169,118 @@ async function runDeskew() {
}
}
function download() {
if (!resultUrl.value) return
function addScaleBar(image: HTMLImageElement): Promise<Blob> {
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")
a.href = resultUrl.value
a.download = `skwik-${store.originalFile?.name ?? "output"}.png`
a.href = url
const baseName =
store.originalFile?.name.replace(/\.[^.]+$/, "") ?? "output"
a.download = `${baseName}-skwik.png`
a.click()
URL.revokeObjectURL(url)
}
function hasRects(): boolean {
@ -92,7 +298,9 @@ function hasRects(): boolean {
download.
</p>
</div>
<Button variant="outline" @click="store.goToStep(3)">Back</Button>
<Button variant="outline" @click="store.goToStep(3)"
>Back</Button
>
</div>
<!-- Scale setting -->
@ -100,8 +308,8 @@ function hasRects(): boolean {
<CardHeader>
<CardTitle class="text-base">Output Scale</CardTitle>
<CardDescription>
Pixels per millimeter in the corrected output image. Higher
= larger output.
Pixels per millimeter in the corrected output image.
Higher = larger output.
</CardDescription>
</CardHeader>
<CardContent>
@ -111,13 +319,45 @@ function hasRects(): boolean {
:model-value="String(store.scalePxPerMm)"
type="number"
min="1"
class="w-28"
class="w-28 font-mono"
@update:model-value="
(v: string | number) =>
(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 }}&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>
</CardContent>
</Card>
@ -137,27 +377,36 @@ function hasRects(): boolean {
v-for="datum in store.datums"
:key="datum.id"
variant="outline"
class="font-normal"
>
{{ datum.label }}
({{
<span class="ml-1 font-mono text-xs">{{
datum.type === "rectangle"
? `${datum.widthMm}\u00D7${datum.heightMm}mm`
: `${datum.lengthMm}mm`
}}) &mdash; confidence {{ datum.confidence }}/5
}}</span>
<span class="ml-1 text-muted-foreground"
>conf {{ datum.confidence }}/5</span
>
</Badge>
</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
correction.
</p>
</CardContent>
</Card>
<!-- Run button -->
<!-- Run button + progress -->
<div class="flex flex-col items-center gap-3">
<Button
size="lg"
:disabled="store.isProcessing || !hasRects()"
:disabled="
store.isProcessing || !hasRects() || tooLarge
"
@click="runDeskew"
>
<template v-if="store.isProcessing">
@ -191,6 +440,20 @@ function hasRects(): boolean {
}}
</template>
</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>
<p v-if="error" class="text-center text-sm text-destructive">
@ -198,7 +461,7 @@ function hasRects(): boolean {
</p>
<!-- Result -->
<template v-if="store.deskewResult">
<template v-if="store.deskewResult && resultUrl">
<!-- Diagnostics -->
<Card>
<CardHeader>
@ -208,24 +471,60 @@ function hasRects(): boolean {
<strong>{{
store.deskewResult.diagnostics.primaryDatum
}}</strong>
&ensp;&bull;&ensp; Output:
{{
<span class="mx-1 text-muted-foreground/50"
>|</span
>
Output:
<span class="font-mono"
>{{
store.deskewResult.diagnostics.outputWidthPx
}}&times;{{
store.deskewResult.diagnostics.outputHeightPx
}}px
}}px</span
>
<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>
</CardHeader>
<CardContent class="space-y-4">
<!-- Axis corrections -->
<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
class="text-xs font-medium text-muted-foreground"
class="text-xs font-medium uppercase tracking-wide text-muted-foreground"
>
X-axis correction
</p>
<p class="text-lg font-semibold">
<p class="font-mono text-lg font-semibold">
{{
(
store.deskewResult.diagnostics
@ -233,22 +532,25 @@ function hasRects(): boolean {
).toFixed(2)
}}%
</p>
<p class="text-xs text-muted-foreground">
weight:
{{
<p
class="font-mono text-xs text-muted-foreground"
>
w={{
store.deskewResult.diagnostics.xCorrection.totalWeight.toFixed(
1,
)
}}
</p>
</div>
<div class="rounded-md border p-3">
<div
class="rounded-md border border-border/50 p-3"
>
<p
class="text-xs font-medium text-muted-foreground"
class="text-xs font-medium uppercase tracking-wide text-muted-foreground"
>
Y-axis correction
</p>
<p class="text-lg font-semibold">
<p class="font-mono text-lg font-semibold">
{{
(
store.deskewResult.diagnostics
@ -256,9 +558,10 @@ function hasRects(): boolean {
).toFixed(2)
}}%
</p>
<p class="text-xs text-muted-foreground">
weight:
{{
<p
class="font-mono text-xs text-muted-foreground"
>
w={{
store.deskewResult.diagnostics.yCorrection.totalWeight.toFixed(
1,
)
@ -270,7 +573,8 @@ function hasRects(): boolean {
<!-- Per-datum table -->
<Table
v-if="
store.deskewResult.diagnostics.perDatum.length > 0
store.deskewResult.diagnostics.perDatum.length >
0
"
>
<TableHeader>
@ -283,32 +587,36 @@ function hasRects(): boolean {
<TableHead class="text-right"
>Measured (mm)</TableHead
>
<TableHead class="text-right">Error</TableHead>
<TableHead class="text-right"
>Error</TableHead
>
<TableHead>Axis</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="report in store.deskewResult.diagnostics
.perDatum"
v-for="report in store.deskewResult
.diagnostics.perDatum"
:key="report.label"
>
<TableCell class="font-medium">{{
report.label
}}</TableCell>
<TableCell>
<Badge variant="outline" class="text-xs">{{
report.type
}}</Badge>
<Badge
variant="outline"
class="text-xs"
>{{ report.type }}</Badge
>
</TableCell>
<TableCell class="text-right">{{
<TableCell class="font-mono text-right">{{
report.expectedMm.toFixed(1)
}}</TableCell>
<TableCell class="text-right">{{
<TableCell class="font-mono text-right">{{
report.measuredMm.toFixed(1)
}}</TableCell>
<TableCell
class="text-right"
class="font-mono text-right"
:class="
report.errorPercent > 5
? 'text-destructive'
@ -326,26 +634,134 @@ function hasRects(): boolean {
</CardContent>
</Card>
<!-- Corrected image -->
<Card>
<CardHeader>
<CardTitle class="text-base">Corrected Image</CardTitle>
</CardHeader>
<CardContent>
<div
class="flex items-center justify-center overflow-hidden rounded-md bg-muted"
<!-- Algorithm explanation -->
<Card class="border-border/40">
<CardContent class="pb-5 pt-5">
<button
class="flex w-full items-center gap-2 text-left text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
@click="
showAlgoDetails = !showAlgoDetails
"
>
<img
v-if="resultUrl"
:src="resultUrl"
alt="Corrected image"
class="max-h-[500px] w-full object-contain"
/>
<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
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
>
&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>
</CardContent>
</Card>
<div class="flex justify-center pb-8">
<!-- Corrected image with tools -->
<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">
<svg
xmlns="http://www.w3.org/2000/svg"
@ -359,12 +775,44 @@ function hasRects(): boolean {
stroke-linejoin="round"
class="mr-2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<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" />
<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
}}&times;{{
store.deskewResult.diagnostics.outputHeightPx
}} px &mdash;
{{
(
store.deskewResult
.correctedImageBlob.size /
1024 /
1024
).toFixed(1)
}} MB
</p>
</div>
</template>
</div>

View File

@ -0,0 +1,280 @@
<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,17 +17,32 @@ const steps = [
<template v-for="(step, i) in steps" :key="step.num">
<Badge
:variant="
store.currentStep === step.num ? 'default' : 'outline'
store.currentStep === step.num
? 'default'
: 'outline'
"
class="cursor-default select-none text-xs"
class="select-none font-mono text-xs"
:class="{
'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 }}
</Badge>
<span v-if="i < steps.length - 1" class="text-muted-foreground"
>&middot;</span
<span
v-if="i < steps.length - 1"
class="text-xs text-muted-foreground/40"
>&rsaquo;</span
>
</template>
</nav>

View 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>

View File

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

48
src/lib/datum-cache.ts Normal file
View File

@ -0,0 +1,48 @@
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,9 +1,8 @@
/**
* deskew.ts Browser-based perspective correction using OpenCV.js (WASM)
*
* Adapted from the reference algorithm. Accepts N datums (rectangles and/or
* lines), each with known real-world dimensions and a confidence score (15).
* Minimum: one rectangle.
* Accepts N datums (rectangles and/or lines), each with known real-world
* dimensions and a confidence score (15). Minimum: one rectangle.
*
* Algorithm:
* 1. Pick the highest-confidence rectangle as primary reference.
@ -27,6 +26,10 @@ import type {
RectDatum,
} 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 ──────────────────────────────────────────────────────────
function pointsToMat(points: Point[]): InstanceType<typeof cv.Mat> {
@ -75,7 +78,8 @@ function mul3x3(A: number[], B: number[]): number[] {
for (let c = 0; c < 3; c++) {
let sum = 0
for (let k = 0; k < 3; k++) {
sum += (A[r * 3 + k] ?? 0) * (B[k * 3 + c] ?? 0)
sum +=
(A[r * 3 + k] ?? 0) * (B[k * 3 + c] ?? 0)
}
R[r * 3 + c] = sum
}
@ -86,17 +90,20 @@ function mul3x3(A: number[], B: number[]): number[] {
// ─── Validation ──────────────────────────────────────────────────────────────
function pickPrimary(datums: Datum[]): RectDatum {
const rects = datums.filter((d): d is RectDatum => d.type === "rectangle")
const rects = datums.filter(
(d): d is RectDatum => d.type === "rectangle",
)
if (rects.length === 0) {
throw new Error(
"At least one rectangle datum is required for perspective correction.",
)
}
// Highest confidence; tie-break by pixel area (larger = more precise corners)
rects.sort((a, b) => {
if (b.confidence !== a.confidence) return b.confidence - a.confidence
if (b.confidence !== a.confidence)
return b.confidence - a.confidence
const area = (r: RectDatum) =>
dist(r.corners[0], r.corners[1]) * dist(r.corners[0], r.corners[3])
dist(r.corners[0], r.corners[1]) *
dist(r.corners[0], r.corners[3])
return area(b) - area(a)
})
return rects[0] as RectDatum
@ -109,7 +116,6 @@ function pickPrimary(datums: Datum[]): RectDatum {
function cornersToAlgoOrder(
corners: [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]]
}
@ -123,11 +129,8 @@ function canvasToBlob(
return new Promise((resolve, reject) => {
canvas.toBlob(
(b) => {
if (b) {
resolve(b)
} else {
reject(new Error("toBlob failed"))
}
if (b) resolve(b)
else reject(new Error("toBlob failed"))
},
type,
quality,
@ -137,47 +140,91 @@ function canvasToBlob(
// ─── Core ────────────────────────────────────────────────────────────────────
export async function deskewImage(input: DeskewInput): Promise<DeskewResult> {
const { image, datums, scalePxPerMm: scale } = input
const log = (tag: string, ...args: unknown[]) => {
console.log(`[deskew:${tag}]`, ...args)
}
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.")
const primary = pickPrimary(datums)
log("primary", primary.label, `${String(primary.widthMm)}×${String(primary.heightMm)}mm`, `conf=${String(primary.confidence)}`)
// Load source image into OpenCV
let srcCanvas: HTMLCanvasElement
if (image instanceof HTMLCanvasElement) {
srcCanvas = image
log("input", `canvas ${String(image.width)}×${String(image.height)}`)
} else {
srcCanvas = document.createElement("canvas")
srcCanvas.width = image.naturalWidth
srcCanvas.height = image.naturalHeight
log("input", `img ${String(image.naturalWidth)}×${String(image.naturalHeight)}, drawing to canvas`)
const ctx = srcCanvas.getContext("2d")
if (!ctx) throw new Error("Failed to get 2d context")
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 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 rectangle
// ================================================================
// ============================================================
// STEP 1 — Initial perspective correction from primary rect
// ============================================================
await progress(1, "Computing initial homography")
const pw = primary.widthMm * scale
const ph = primary.heightMm * scale
log("step1", `dest rect: ${pw.toFixed(1)}×${ph.toFixed(1)} px`)
const algoCorners = cornersToAlgoOrder(primary.corners)
const srcPts = pointsToMat(algoCorners)
const dstInit = pointsToMat([
log("step1", `corners (algo order): ${JSON.stringify(algoCorners)}`)
const srcPts = track(pointsToMat(algoCorners))
const dstInit = track(
pointsToMat([
{ x: 0, y: 0 },
{ x: pw, y: 0 },
{ x: 0, 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 all secondary datums, accumulate corrections
// ================================================================
// ============================================================
// STEP 2 — Measure secondary datums, accumulate corrections
// ============================================================
await progress(2, "Measuring secondary datums")
let xWSum = 0,
xWTotal = 0
let yWSum = 0,
@ -200,7 +247,12 @@ export async function deskewImage(input: DeskewInput): Promise<DeskewResult> {
}
if (datum.type === "line") {
const [s, e] = transformPoints(datum.endpoints as Point[], mInit)
const pts = transformPoints(
datum.endpoints as Point[],
mInit,
)
const s = pts[0]
const e = pts[1]
if (!s || !e) continue
const dx = Math.abs(e.x - s.x)
const dy = Math.abs(e.y - s.y)
@ -208,7 +260,6 @@ export async function deskewImage(input: DeskewInput): Promise<DeskewResult> {
const expected = datum.lengthMm * scale
const ratio = expected / measured
// Axis contribution proportional to alignment
const total = dx + dy
if (total > 1e-6) {
const xFrac = dx / total
@ -228,9 +279,14 @@ export async function deskewImage(input: DeskewInput): Promise<DeskewResult> {
axisContribution: dx > dy ? "x" : "y",
})
} else {
// Secondary rectangle: top edge → X, left edge → Y
const ac = cornersToAlgoOrder(datum.corners)
const [tl, tr, bl] = transformPoints([ac[0], ac[1], ac[2]], mInit)
const pts = transformPoints(
[ac[0], ac[1], ac[2]],
mInit,
)
const tl = pts[0]
const tr = pts[1]
const bl = pts[2]
if (!tl || !tr || !bl) continue
const mW = dist(tl, tr)
const mH = dist(tl, bl)
@ -247,15 +303,17 @@ export async function deskewImage(input: DeskewInput): Promise<DeskewResult> {
type: "rectangle",
measuredMm: mW / scale,
expectedMm: datum.widthMm,
errorPercent: (Math.abs(1 - xR) + Math.abs(1 - yR)) * 50,
errorPercent:
(Math.abs(1 - xR) + Math.abs(1 - yR)) * 50,
axisContribution: "both",
})
}
}
// ================================================================
// ============================================================
// STEP 3 — Weighted corrections (1.0 = no secondary data)
// ================================================================
// ============================================================
await progress(3, "Computing axis corrections")
const xCorr: AxisCorrection = {
ratio: xWTotal > 0 ? xWSum / xWTotal : 1.0,
totalWeight: xWTotal,
@ -264,24 +322,34 @@ export async function deskewImage(input: DeskewInput): Promise<DeskewResult> {
ratio: yWTotal > 0 ? yWSum / yWTotal : 1.0,
totalWeight: yWTotal,
}
log("step3", `xCorr=${xCorr.ratio.toFixed(4)} (w=${xCorr.totalWeight.toFixed(1)}), yCorr=${yCorr.ratio.toFixed(4)} (w=${yCorr.totalWeight.toFixed(1)})`)
// ================================================================
// STEP 4 — Fold into destination rectangle, recompute transform
// ================================================================
// ============================================================
// STEP 4 — Fold corrections, recompute transform
// ============================================================
await progress(4, "Recomputing final transform")
const pwFinal = pw * xCorr.ratio
const phFinal = ph * yCorr.ratio
log("step4", `final dest rect: ${pwFinal.toFixed(1)}×${phFinal.toFixed(1)} px`)
const dstFinal = pointsToMat([
const dstFinal = track(
pointsToMat([
{ x: 0, y: 0 },
{ x: pwFinal, y: 0 },
{ x: 0, 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
// ================================================================
// ============================================================
await progress(5, "Computing output bounds")
const imgCorners: Point[] = [
{ x: 0, y: 0 },
{ x: imgW, y: 0 },
@ -289,6 +357,12 @@ export async function deskewImage(input: DeskewInput): Promise<DeskewResult> {
{ x: imgW, y: imgH },
]
const warped = transformPoints(imgCorners, mFinal)
if (warped.length < 4) {
throw new Error(
"Perspective transform produced invalid bounds",
)
}
let xMin = Infinity,
yMin = Infinity,
xMax = -Infinity,
@ -300,18 +374,45 @@ export async function deskewImage(input: DeskewInput): Promise<DeskewResult> {
yMax = Math.max(yMax, c.y)
}
const outW = Math.ceil(xMax - xMin)
const outH = Math.ceil(yMax - yMin)
let outW = Math.ceil(xMax - xMin)
let 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 tShift: number[] = [1, 0, -xMin, 0, 1, -yMin, 0, 0, 1]
// Translate so the top-left warped corner is at (0,0),
// 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 mOut = cv.matFromArray(3, 3, cv.CV_64FC1, mOutData)
const mOut = track(
cv.matFromArray(3, 3, cv.CV_64FC1, mOutData),
)
// ================================================================
// ============================================================
// STEP 6 — Warp
// ================================================================
const dstMat = new cv.Mat()
// ============================================================
await progress(6, "Warping image (this may take a moment)")
log("step6", "calling warpPerspective...")
const dstMat = track(new cv.Mat())
cv.warpPerspective(
src,
dstMat,
@ -322,22 +423,17 @@ export async function deskewImage(input: DeskewInput): Promise<DeskewResult> {
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")
outCanvas.width = outW
outCanvas.height = outH
cv.imshow(outCanvas, dstMat)
// Cleanup OpenCV mats
src.delete()
srcPts.delete()
dstInit.delete()
mInit.delete()
dstFinal.delete()
mFinal.delete()
mOut.delete()
dstMat.delete()
log("export", "canvas.toBlob (PNG)")
const blob = await canvasToBlob(outCanvas, "image/png", 0.95)
log("export", `blob size: ${String(Math.round(blob.size / 1024))} KB`)
const diagnostics: DeskewDiagnostics = {
primaryDatum: primary.label,
@ -348,20 +444,51 @@ export async function deskewImage(input: DeskewInput): Promise<DeskewResult> {
outputHeightPx: outH,
}
log("done", "success")
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 ────────────────────────────────────────────────────────────
let cvReady = false
/** Wait for OpenCV WASM to initialize. Call once at app startup. */
export function waitForOpenCV(): Promise<void> {
log("opencv", "waitForOpenCV called, cvReady=" + String(cvReady))
return new Promise<void>((resolve) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (cv.Mat) {
if (cvReady) {
log("opencv", "already ready")
resolve()
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 = () => {
cvReady = true
log("opencv", "onRuntimeInitialized fired, WASM ready")
resolve()
}
})

6
src/lib/file-hash.ts Normal file
View File

@ -0,0 +1,6 @@
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("")
}

24
src/lib/settings-cache.ts Normal file
View File

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

View File

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