Move files to public folder
This commit is contained in:
326
public/UploadImageComponent.js
Normal file
326
public/UploadImageComponent.js
Normal file
@@ -0,0 +1,326 @@
|
||||
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);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user