Skwik/src/components/ResultViewer.vue
Samuel Prevost 03d0f38476
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled
fix(result): fix scale bar export, scale input UX, and auto-scale
- Fix scale bar checkbox: replace shadcn Checkbox (broken event
  propagation) with native input[type=checkbox] + v-model
- Scale input: use local string ref so user can type freely;
  red highlight when invalid, run button disabled until valid
- Auto-compute default scale to match input image dimensions,
  capped at 8192px output; cached scale takes priority

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-14 23:59:36 +02:00

887 lines
33 KiB
Vue

<script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue"
import { useAppStore } from "@/stores/app"
import { deskewImage, waitForOpenCV } from "@/lib/deskew"
import type { RectDatum } from "@/types"
import { DEFAULT_SCALE_PX_PER_MM } from "@/types"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Progress } from "@/components/ui/progress"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import {
Table,
TableBody,
TableCell,
TableHead,
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)
const error = ref("")
const hasRun = ref(false)
const cvReady = ref(false)
const cvLoading = ref(false)
const showAlgoDetails = ref(false)
const includeScaleBar = ref(false)
const scaleInput = ref(String(store.scalePxPerMm))
const scaleValid = computed(() => {
const n = Number(scaleInput.value)
return Number.isFinite(n) && n > 0
})
watch(scaleInput, (v) => {
const n = Number(v)
if (Number.isFinite(n) && n > 0) {
store.scalePxPerMm = n
}
})
const MAX_AUTO_SCALE_DIM = 8192
function computeAutoScale(): number {
const img = store.loadedImage
const primary = store.datums.find(
(d): d is RectDatum => d.type === "rectangle",
)
if (!img || !primary) return DEFAULT_SCALE_PX_PER_MM
// Approximate source-pixel size of the datum
const c = primary.corners
const datumSrcW = Math.max(
Math.hypot(c[1].x - c[0].x, c[1].y - c[0].y),
Math.hypot(c[2].x - c[3].x, c[2].y - c[3].y),
)
const datumSrcH = Math.max(
Math.hypot(c[3].x - c[0].x, c[3].y - c[0].y),
Math.hypot(c[2].x - c[1].x, c[2].y - c[1].y),
)
// Scale that would make the datum the same pixel size as in source
const sx =
datumSrcW > 0 ? datumSrcW / primary.widthMm : 0
const sy =
datumSrcH > 0 ? datumSrcH / primary.heightMm : 0
let autoScale = Math.max(sx, sy)
// Clamp so the full output doesn't exceed MAX_AUTO_SCALE_DIM
const estW = img.naturalWidth * autoScale / Math.max(datumSrcW / primary.widthMm, 0.001)
const estH = img.naturalHeight * autoScale / Math.max(datumSrcH / primary.heightMm, 0.001)
if (estW > MAX_AUTO_SCALE_DIM || estH > MAX_AUTO_SCALE_DIM) {
autoScale *= MAX_AUTO_SCALE_DIM / Math.max(estW, estH)
}
// Round to a clean number
return Math.max(1, Math.round(autoScale * 10) / 10)
}
onMounted(() => {
const cached = loadSettings()
if (cached) {
includeScaleBar.value = cached.includeScaleBar
// Only use cached scale if it was explicitly set before
if (cached.scalePxPerMm !== DEFAULT_SCALE_PX_PER_MM) {
scaleInput.value = String(cached.scalePxPerMm)
return
}
}
// Auto-compute a sensible default scale
const auto = computeAutoScale()
store.scalePxPerMm = auto
scaleInput.value = String(auto)
})
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
cvLoading.value = true
store.processingStatus = "Loading OpenCV WASM..."
await waitForOpenCV()
cvReady.value = true
cvLoading.value = false
}
async function runDeskew() {
if (!store.loadedImage) return
error.value = ""
store.isProcessing = true
hasRun.value = true
try {
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)
if (resultUrl.value) URL.revokeObjectURL(resultUrl.value)
resultUrl.value = URL.createObjectURL(result.correctedImageBlob)
} catch (e) {
error.value = e instanceof Error ? e.message : "Deskew failed"
} finally {
store.isProcessing = false
store.processingStatus = ""
}
}
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
console.log("[download] includeScaleBar =", includeScaleBar.value)
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 = url
const baseName =
store.originalFile?.name.replace(/\.[^.]+$/, "") ?? "output"
a.download = `${baseName}-skwik.png`
a.click()
URL.revokeObjectURL(url)
}
function hasRects(): boolean {
return store.datums.some((d) => d.type === "rectangle")
}
</script>
<template>
<div class="mx-auto max-w-4xl space-y-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-semibold">Process &amp; Download</h2>
<p class="text-sm text-muted-foreground">
Set the output scale, run perspective correction, and
download.
</p>
</div>
<Button variant="outline" @click="store.goToStep(3)"
>Back</Button
>
</div>
<!-- Scale setting -->
<Card>
<CardHeader>
<CardTitle class="text-base">Output Scale</CardTitle>
<CardDescription>
Pixels per millimeter in the corrected output image.
Higher = larger output.
</CardDescription>
</CardHeader>
<CardContent>
<div class="flex items-center gap-3">
<Label>Scale</Label>
<Input
:model-value="scaleInput"
type="number"
min="1"
class="w-28 font-mono"
:class="
scaleValid
? ''
: 'border-destructive ring-destructive/30 ring-2'
"
@update:model-value="
(v: string | number) =>
(scaleInput = String(v))
"
/>
<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="!scaleValid" class="font-medium text-destructive">
Enter a valid scale &gt; 0.
</p>
<p v-else-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>
<!-- Summary of datums -->
<Card>
<CardHeader>
<CardTitle class="text-base">Datum Summary</CardTitle>
<CardDescription>
{{ store.datums.length }} datum(s) will be used for
calibration.
</CardDescription>
</CardHeader>
<CardContent>
<div class="flex flex-wrap gap-2">
<Badge
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`
}}</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"
>
At least one rectangle datum is required for perspective
correction.
</p>
</CardContent>
</Card>
<!-- Run button + progress -->
<div class="flex flex-col items-center gap-3">
<Button
size="lg"
:disabled="
store.isProcessing ||
!hasRects() ||
tooLarge ||
!scaleValid
"
@click="runDeskew"
>
<template v-if="store.isProcessing">
<svg
class="mr-2 h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
{{ store.processingStatus || "Processing..." }}
</template>
<template v-else>
{{
hasRun
? "Re-run Correction"
: "Run Perspective Correction"
}}
</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">
{{ error }}
</p>
<!-- Result -->
<template v-if="store.deskewResult && resultUrl">
<!-- Diagnostics -->
<Card>
<CardHeader>
<CardTitle class="text-base">Diagnostics</CardTitle>
<CardDescription>
Primary reference:
<strong>{{
store.deskewResult.diagnostics.primaryDatum
}}</strong>
<span class="mx-1 text-muted-foreground/50"
>|</span
>
Output:
<span class="font-mono"
>{{
store.deskewResult.diagnostics.outputWidthPx
}}&times;{{
store.deskewResult.diagnostics.outputHeightPx
}}px</span
>
<span class="mx-1 text-muted-foreground/50"
>|</span
>
<span class="font-mono"
>{{
(
store.deskewResult.diagnostics
.outputWidthPx / store.scalePxPerMm
).toFixed(1)
}}&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 border-border/50 p-3"
>
<p
class="text-xs font-medium uppercase tracking-wide text-muted-foreground"
>
X-axis correction
</p>
<p class="font-mono text-lg font-semibold">
{{
(
store.deskewResult.diagnostics
.xCorrection.ratio * 100
).toFixed(2)
}}%
</p>
<p
class="font-mono text-xs text-muted-foreground"
>
w={{
store.deskewResult.diagnostics.xCorrection.totalWeight.toFixed(
1,
)
}}
</p>
</div>
<div
class="rounded-md border border-border/50 p-3"
>
<p
class="text-xs font-medium uppercase tracking-wide text-muted-foreground"
>
Y-axis correction
</p>
<p class="font-mono text-lg font-semibold">
{{
(
store.deskewResult.diagnostics
.yCorrection.ratio * 100
).toFixed(2)
}}%
</p>
<p
class="font-mono text-xs text-muted-foreground"
>
w={{
store.deskewResult.diagnostics.yCorrection.totalWeight.toFixed(
1,
)
}}
</p>
</div>
</div>
<!-- Per-datum table -->
<Table
v-if="
store.deskewResult.diagnostics.perDatum.length >
0
"
>
<TableHeader>
<TableRow>
<TableHead>Datum</TableHead>
<TableHead>Type</TableHead>
<TableHead class="text-right"
>Expected (mm)</TableHead
>
<TableHead class="text-right"
>Measured (mm)</TableHead
>
<TableHead class="text-right"
>Error</TableHead
>
<TableHead>Axis</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
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
>
</TableCell>
<TableCell class="font-mono text-right">{{
report.expectedMm.toFixed(1)
}}</TableCell>
<TableCell class="font-mono text-right">{{
report.measuredMm.toFixed(1)
}}</TableCell>
<TableCell
class="font-mono text-right"
:class="
report.errorPercent > 5
? 'text-destructive'
: ''
"
>
{{ report.errorPercent.toFixed(1) }}%
</TableCell>
<TableCell>{{
report.axisContribution
}}</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
<!-- 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
"
>
<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>
<!-- 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 select-none"
>
<input
v-model="includeScaleBar"
type="checkbox"
class="h-4 w-4 accent-primary"
/>
<span
class="text-sm text-muted-foreground"
>Include scale bar in export</span
>
</label>
</TooltipTrigger>
<TooltipContent side="top" class="max-w-xs">
Appends a black bar at the bottom of the
exported image with a measurement scale and
px/mm annotation.
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div class="flex items-center gap-3">
<Button size="lg" @click="download">
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="mr-2"
>
<path
d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"
/>
<polyline points="7 10 12 15 17 10" />
<line
x1="12"
x2="12"
y1="15"
y2="3"
/>
</svg>
Download PNG
</Button>
<Button
size="lg"
variant="outline"
@click="store.reset()"
>
Process New Image
</Button>
</div>
<p
class="font-mono text-sm text-muted-foreground"
>
{{
store.deskewResult.diagnostics.outputWidthPx
}}&times;{{
store.deskewResult.diagnostics.outputHeightPx
}} px &mdash;
{{
(
store.deskewResult
.correctedImageBlob.size /
1024 /
1024
).toFixed(1)
}} MB
</p>
</div>
</template>
</div>
</template>