327 lines
9.8 KiB
JavaScript
327 lines
9.8 KiB
JavaScript
import {
|
|
generateSinogram,
|
|
reconstructImageFromSinogram,
|
|
convertToGrayscale,
|
|
} from "./sinogram.js";
|
|
|
|
import { StepAccordion } from "./CTAccordion .js";
|
|
|
|
export const UploadImageComponent = {
|
|
hasLoadedInitialImage: false,
|
|
angleCount: 180,
|
|
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);
|
|
};
|
|
}
|