- 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>
887 lines
33 KiB
Vue
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 & 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 }}×{{
|
|
estimatedOutput.h
|
|
}}px</span
|
|
>
|
|
 — 
|
|
<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 > 0.
|
|
</p>
|
|
<p v-else-if="tooLarge" class="font-medium">
|
|
Exceeds {{ MAX_RGBA_MB }} MB limit —
|
|
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
|
|
}}×{{
|
|
store.deskewResult.diagnostics.outputHeightPx
|
|
}}px</span
|
|
>
|
|
<span class="mx-1 text-muted-foreground/50"
|
|
>|</span
|
|
>
|
|
<span class="font-mono"
|
|
>{{
|
|
(
|
|
store.deskewResult.diagnostics
|
|
.outputWidthPx / store.scalePxPerMm
|
|
).toFixed(1)
|
|
}}×{{
|
|
(
|
|
store.deskewResult.diagnostics
|
|
.outputHeightPx / store.scalePxPerMm
|
|
).toFixed(1)
|
|
}}mm</span
|
|
>
|
|
<span class="mx-1 text-muted-foreground/50"
|
|
>|</span
|
|
>
|
|
<span class="font-mono"
|
|
>{{
|
|
(
|
|
store.deskewResult.correctedImageBlob
|
|
.size /
|
|
1024 /
|
|
1024
|
|
).toFixed(1)
|
|
}} MB</span
|
|
>
|
|
</CardDescription>
|
|
</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
|
|
>
|
|
— a 3×3 projective
|
|
transform mapping the
|
|
quadrilateral in the source
|
|
image to a true rectangle at
|
|
the specified real-world
|
|
dimensions.
|
|
</li>
|
|
<li>
|
|
Secondary datums (additional
|
|
rectangles or line segments)
|
|
provide
|
|
<strong
|
|
class="text-foreground/80"
|
|
>weighted correction
|
|
factors</strong
|
|
>
|
|
for the X and Y axes. Each
|
|
secondary datum's contribution
|
|
is weighted by its confidence
|
|
score, refining the scale in
|
|
each axis independently.
|
|
</li>
|
|
<li>
|
|
The final correction is applied
|
|
as a single
|
|
<code
|
|
class="rounded bg-muted px-1 py-0.5 font-mono text-xs text-foreground"
|
|
>cv::warpPerspective</code
|
|
>
|
|
call via OpenCV WASM, producing
|
|
the output image at the
|
|
requested px/mm scale.
|
|
</li>
|
|
</ol>
|
|
</div>
|
|
</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
|
|
}}×{{
|
|
store.deskewResult.diagnostics.outputHeightPx
|
|
}} px —
|
|
{{
|
|
(
|
|
store.deskewResult
|
|
.correctedImageBlob.size /
|
|
1024 /
|
|
1024
|
|
).toFixed(1)
|
|
}} MB
|
|
</p>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|