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); }; }