Files
sinogram/public/UploadImageComponent.js
2025-05-17 09:40:03 +10:00

327 lines
9.8 KiB
JavaScript

import {
generateSinogram,
reconstructImageFromSinogram,
convertToGrayscale,
} from "./sinogram.js";
import { StepAccordion } from "./CTAccordion .js";
export const UploadImageComponent = {
hasLoadedInitialImage: false,
angleCount: 40,
imageUrl:
"https://upload.wikimedia.org/wikipedia/commons/e/e5/Shepp_logan.png",
sinogramUrl: null,
reconstructedUrl: null,
defaultImageUrl:
"https://upload.wikimedia.org/wikipedia/commons/e/e5/Shepp_logan.png",
reconstructionFrames: [],
currentFrameIndex: 0,
renderMode: "grayscale", // or "heatmap"
useFBP: true,
drawAngleOverlay(theta) {
const canvas = this.overlayCanvas;
if (!canvas || !this.imageElement) return;
const ctx = canvas.getContext("2d");
const w = canvas.width;
const h = canvas.height;
const cx = w / 2;
const cy = h / 2;
const len = Math.max(w, h);
ctx.clearRect(0, 0, w, h);
const dx = len * Math.cos(theta);
const dy = len * Math.sin(theta);
ctx.strokeStyle = "rgba(255,0,0,0.8)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(cx - dx, cy - dy);
ctx.lineTo(cx + dx, cy + dy);
ctx.stroke();
},
isOverlayReady() {
return (
this.overlayCanvas &&
this.imageElement &&
this.imageElement.complete &&
this.imageElement.naturalWidth > 0
);
},
async loadAndProcess(url, isUploaded = false) {
this.imageUrl = url;
this.sinogramUrl = "loading";
this.reconstructedUrl = null;
m.redraw();
this.imageUrl = url;
let finalUrl = url;
if (isUploaded) {
finalUrl = await convertToGrayscale(url);
this.imageUrl = finalUrl;
}
this.sinogramUrl = await generateSinogram(
finalUrl,
this.angleCount,
this.drawAngleOverlay.bind(this)
);
m.redraw();
this.reconstructionFrames = [];
this.currentFrameIndex = 0;
this.reconstructedUrl = await reconstructImageFromSinogram(
this.sinogramUrl,
undefined,
(angle, frameUrl) => {
this.reconstructionFrames.push(frameUrl);
this.currentFrameIndex = this.reconstructionFrames.length - 1;
this.reconstructedUrl = frameUrl;
m.redraw();
},
this.renderMode,
this.useFBP
);
},
oninit() {
this.loadAndProcessDebounced = debounce((url) => {
this.loadAndProcess(url);
}, 300);
},
view() {
return m(
"div",
{
class: "flex flex-col items-center min-h-screen bg-gray-100 py-10 px-4",
},
[
// Header
m("header", { class: "mb-10 text-center" }, [
m(
"h1",
{ class: "text-4xl font-bold text-gray-800 mb-2" },
"Sinogram Generator"
),
m(
"p",
{ class: "text-gray-600 text-lg" },
"Upload a grayscale image to simulate CT scan projections"
),
]),
m(StepAccordion, { index: 0 }),
// Upload Box
m(
"div",
{
class:
"w-full max-w-lg border-4 border-dashed border-gray-400 bg-white rounded-xl p-6 text-center hover:bg-gray-50 cursor-pointer transition",
ondragover: (e) => e.preventDefault(),
ondrop: (e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith("image/")) {
const url = URL.createObjectURL(file);
this.loadAndProcess(url, true);
}
},
onclick: () => document.getElementById("fileInput").click(),
},
[
m(
"p",
{ class: "text-gray-500" },
"Click or drag a grayscale image here"
),
m("input", {
id: "fileInput",
type: "file",
class: "hidden",
accept: "image/*",
onchange: (e) => {
const file = e.target.files[0];
if (file && file.type.startsWith("image/")) {
const url = URL.createObjectURL(file);
this.loadAndProcess(url);
}
},
}),
]
),
m(StepAccordion, { index: 1 }),
// Image Preview
m("div", { class: "relative mt-6 w-full max-w-md" }, [
m("img", {
src: this.imageUrl,
class: "rounded shadow max-w-full h-auto mx-auto",
onload: (e) => {
this.imageElement = e.target;
// Only start once both image and canvas are ready
if (this.isOverlayReady() && !this.hasLoadedInitialImage) {
this.hasLoadedInitialImage = true;
this.loadAndProcess(this.imageUrl);
}
},
}),
m("canvas", {
width: this.imageElement?.width || 0,
height: this.imageElement?.height || 0,
style: "position:absolute; top:0; left:0; pointer-events:none;",
oncreate: ({ dom }) => {
this.overlayCanvas = dom;
// Trigger load if image was already ready
if (this.isOverlayReady() && !this.hasLoadedInitialImage) {
this.hasLoadedInitialImage = true;
this.loadAndProcess(this.imageUrl);
}
},
}),
]),
// Angle Slider
m("div", { class: "mt-6 w-full max-w-md" }, [
m(
"label",
{ class: "block text-sm font-medium text-gray-700 mb-1" },
`Number of angles: ${this.angleCount}`
),
m("input", {
type: "range",
min: 5,
max: 360,
value: this.angleCount,
step: 1,
class: "w-full",
oninput: (e) => {
this.angleCount = parseInt(e.target.value, 10);
this.loadAndProcessDebounced(this.imageUrl); // reprocess with new angle count
},
}),
]),
m(StepAccordion, { index: 2 }),
// Sinogram
this.sinogramUrl &&
m("div", { class: "mt-10 w-full max-w-md text-center" }, [
m(
"h2",
{ class: "text-xl font-semibold text-gray-700 mb-4" },
"Generated Sinogram"
),
this.sinogramUrl === "loading"
? m("p", "Processing...")
: m("img", {
src: this.sinogramUrl,
alt: "Sinogram",
class: "rounded shadow max-w-full h-auto mx-auto",
}),
]),
m(StepAccordion, { index: 3 }),
// Reconstructed
this.reconstructedUrl &&
m("div", { class: "mt-10 w-full max-w-md text-center" }, [
m(
"h2",
{ class: "text-xl font-semibold text-gray-700 mb-4" },
"Reconstructed Image (Back Projection)"
),
m("img", {
src: this.reconstructionFrames[this.currentFrameIndex],
alt: "Reconstructed",
class: "rounded shadow max-w-full h-auto mx-auto",
}),
m("div", { class: "mt-6 w-full max-w-md text-center" }, [
m(
"label",
{ class: "text-sm text-gray-600 mr-2" },
"Render style:"
),
m(
"select",
{
value: this.renderMode,
onchange: (e) => {
this.renderMode = e.target.value;
this.loadAndProcess(this.imageUrl); // re-render using selected mode
},
},
[
m("option", { value: "heatmap" }, "Heatmap"),
m("option", { value: "grayscale" }, "Grayscale"),
]
),
]),
m("div", { class: "mt-4 w-full max-w-md text-left" }, [
m("label", [
m("input", {
type: "checkbox",
checked: this.useFBP,
onchange: (e) => {
this.useFBP = e.target.checked;
this.loadAndProcess(this.imageUrl); // regenerate with or without FBP
},
}),
m(
"span",
{ class: "ml-2 text-gray-700" },
"Use Filtered Back Projection (Ramp)"
),
]),
]),
this.reconstructionFrames.length > 1 &&
m("div", { class: "mt-4" }, [
m("input", {
type: "range",
min: 0,
max: this.reconstructionFrames.length - 1,
value: this.currentFrameIndex,
step: 1,
oninput: (e) => {
this.currentFrameIndex = +e.target.value;
this.reconstructedUrl =
this.reconstructionFrames[this.currentFrameIndex];
},
}),
m(
"p",
{ class: "text-sm text-gray-500 mt-1" },
`Angle ${this.currentFrameIndex + 1} / ${
this.reconstructionFrames.length
}`
),
]),
m(StepAccordion, { index: 4 }),
]),
]
);
},
};
function debounce(fn, delay) {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => fn(...args), delay);
};
}