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, filteredSinogramUrl: null, // Added to store the filtered sinogram reconstructedUrl: null, defaultImageUrl: "https://upload.wikimedia.org/wikipedia/commons/e/e5/Shepp_logan.png", reconstructionFrames: [], currentFrameIndex: 0, manualAngleIndex: 0, // For the new angle control slider sinogramImageElement: null, // To store the sinogram img DOM element sinogramHighlightCanvas: null, // Canvas for sinogram highlight renderMode: "grayscale", // or "heatmap" filterType: "ramp", // Added to manage selected filter 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(); }, drawSinogramHighlight(angleIndex) { if ( !this.sinogramHighlightCanvas || !this.sinogramImageElement || !this.sinogramImageElement.complete || this.sinogramImageElement.naturalWidth === 0 ) { if (this.sinogramHighlightCanvas) { const ctx = this.sinogramHighlightCanvas.getContext("2d"); ctx.clearRect( 0, 0, this.sinogramHighlightCanvas.width, this.sinogramHighlightCanvas.height ); } return; } const canvas = this.sinogramHighlightCanvas; const ctx = canvas.getContext("2d"); const dispWidth = this.sinogramImageElement.width; // Displayed width of sinogram image const dispHeight = this.sinogramHighlightCanvas.height; // Use canvas display height const naturalW = this.sinogramImageElement.naturalWidth; const naturalH = this.sinogramImageElement.naturalHeight; if (canvas.width !== dispWidth) canvas.width = dispWidth; // Ensure canvas height matches the displayed sinogram image's aspect ratio, or a fixed value if not loaded const targetCanvasHeight = this.sinogramImageElement.complete && naturalH > 0 ? (dispWidth * naturalH) / naturalW : 150; if (canvas.height !== targetCanvasHeight) canvas.height = targetCanvasHeight; ctx.clearRect(0, 0, canvas.width, canvas.height); if (this.angleCount <= 0 || naturalW === 0 || naturalH === 0) return; let anglesAlongImageHeight = true; if (naturalW === this.angleCount && naturalH !== this.angleCount) { anglesAlongImageHeight = false; } else if (naturalH === this.angleCount && naturalW !== this.angleCount) { anglesAlongImageHeight = true; } else if (naturalH === this.angleCount && naturalW === this.angleCount) { anglesAlongImageHeight = true; } ctx.strokeStyle = "rgba(0, 255, 0, 0.7)"; if (anglesAlongImageHeight) { const highlightRowHeight = canvas.height / this.angleCount; const yPos = (angleIndex + 0.5) * highlightRowHeight; ctx.lineWidth = Math.max(1, highlightRowHeight * 0.7); ctx.beginPath(); ctx.moveTo(0, yPos); ctx.lineTo(canvas.width, yPos); ctx.stroke(); } else { const highlightColWidth = canvas.width / this.angleCount; const xPos = (angleIndex + 0.5) * highlightColWidth; ctx.lineWidth = Math.max(1, highlightColWidth * 0.7); ctx.beginPath(); ctx.moveTo(xPos, 0); ctx.lineTo(xPos, canvas.height); 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.filteredSinogramUrl = null; this.reconstructedUrl = "loading"; // Indicate reconstruction is also loading this.manualAngleIndex = 0; m.redraw(); let finalUrl = url; if (isUploaded) { finalUrl = await convertToGrayscale(url); this.imageUrl = finalUrl; // Update imageUrl to the grayscaled one } // Ensure imageElement is set for overlay drawing if using default URL if (!isUploaded && !this.imageElement && this.defaultImageUrl === url) { // Attempt to get the image element if it's the default one and not yet set // This might happen if loadAndProcess is called before the default image's onload const img = document.querySelector(`img[src="${url}"]`); if (img) this.imageElement = img; } this.sinogramUrl = await generateSinogram( finalUrl, this.angleCount, this.drawAngleOverlay.bind(this) ); m.redraw(); this.reconstructionFrames = []; this.currentFrameIndex = 0; const reconstructionResult = await reconstructImageFromSinogram( this.sinogramUrl, this.imageElement ? Math.max( this.imageElement.naturalWidth, this.imageElement.naturalHeight ) : 256, // Pass original image size (angle, frameUrl) => { this.reconstructionFrames.push(frameUrl); this.currentFrameIndex = this.reconstructionFrames.length - 1; this.reconstructedUrl = frameUrl; m.redraw(); }, this.renderMode, this.filterType ); this.reconstructedUrl = reconstructionResult.reconstructedUrl; this.filteredSinogramUrl = reconstructionResult.filteredSinogramUrl; m.redraw(); }, oninit() { this.loadAndProcessDebounced = debounce((url, isUploaded = false) => { // Pass isUploaded to debounced function this.loadAndProcess(url, isUploaded); }, 500); // Increased debounce time slightly // Load initial image after a brief delay to ensure DOM is ready for canvas // and imageElement to be potentially available. setTimeout(() => { if (!this.hasLoadedInitialImage && this.defaultImageUrl) { this.loadAndProcess(this.defaultImageUrl, false); // Process default image (not uploaded) this.hasLoadedInitialImage = true; // Prevent re-loading } }, 100); }, view() { return m( "div", { class: "flex flex-col items-center min-h-screen bg-gray-200 py-8 px-4 sm:px-6 lg:px-8", // Slightly darker bg, more padding }, [ // Header m( "header", { class: "mb-8 pb-6 border-b border-gray-300 text-center w-full max-w-3xl", }, [ m( "h1", { class: "text-3xl sm:text-4xl font-bold text-gray-800 mb-3" }, // Responsive text size "Sinogram & CT Reconstruction Explorer" ), m( "p", { class: "text-gray-600 text-md sm:text-lg" }, // Responsive text size "Upload an image, see its sinogram, and explore the reconstruction process." ), ] ), // --- Section 1: Upload, Original Image, and Initial Controls --- m( "div", { class: "bg-white shadow-xl rounded-lg p-6 my-6 w-full max-w-xl" }, [ m(StepAccordion, { index: 0 }), // "1. Upload / Input Image" // Upload Box m( "div", { class: "mt-4 w-full border-4 border-dashed border-gray-300 hover:border-blue-500 bg-gray-50 rounded-xl p-6 sm:p-8 text-center hover:bg-gray-100 cursor-pointer transition duration-150 ease-in-out", // Added duration 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.loadAndProcessDebounced(url, true); // Use debounced version } }, onclick: () => document.getElementById("fileInput").click(), }, [ m( "svg", { class: "mx-auto h-12 w-12 text-gray-400", stroke: "currentColor", fill: "none", viewBox: "0 0 48 48", "aria-hidden": "true", }, m("path", { d: "M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round", }) ), m( "p", { class: "mt-2 text-sm font-medium text-gray-700" }, // Slightly bolder text "Click to upload or drag and drop an image" ), m( "p", { class: "text-xs text-gray-500 mt-1" }, "PNG, JPG, GIF recommended" ), 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.loadAndProcessDebounced(url, true); // Use debounced version } }, }), ] ), m("div", { class: "mt-4 text-center" }, [ m( "button", { class: "text-sm text-blue-600 hover:text-blue-800 underline", onclick: () => { this.loadAndProcessDebounced(this.defaultImageUrl, false); // Load default, not as uploaded }, }, "Load Default Shepp-Logan Phantom" ), ]), m(StepAccordion, { index: 1 }), // Image Preview m( "div", { class: "relative mt-6 border rounded-md p-4 bg-gray-50" }, [ m( "h3", { class: "text-lg font-semibold text-gray-700 mb-3 text-center", }, // Bolder "Original Image" ), m( "div", { class: "relative w-64 h-64 mx-auto bg-gray-200 flex items-center justify-center rounded", }, [ // Container for image and canvas m("img", { src: this.imageUrl, class: "rounded shadow-md max-w-full max-h-full h-auto mx-auto block", // max-h-full // key: this.imageUrl, // Removed to fix Mithril key error onload: (e) => { this.imageElement = e.target; // Adjust canvas to image size if (this.overlayCanvas) { this.overlayCanvas.width = this.imageElement.naturalWidth; this.overlayCanvas.height = this.imageElement.naturalHeight; // Update style to match image display size if different from natural this.overlayCanvas.style.width = `${this.imageElement.width}px`; this.overlayCanvas.style.height = `${this.imageElement.height}px`; } if (this.isOverlayReady()) { // Check if overlay can be drawn this.drawAngleOverlay( (this.manualAngleIndex * Math.PI) / this.angleCount ); } if ( !this.hasLoadedInitialImage && this.imageUrl === this.defaultImageUrl ) { this.hasLoadedInitialImage = true; // Mark that initial (default) image has been processed by onload } }, onerror: () => { // Handle image load error, e.g., show a placeholder or message if (this.overlayCanvas) { // Clear overlay if image fails const ctx = this.overlayCanvas.getContext("2d"); ctx.clearRect( 0, 0, this.overlayCanvas.width, this.overlayCanvas.height ); } }, }), m("canvas", { // Overlay Canvas // Width/height set by onload of image or default class: "absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 pointer-events-none", oncreate: ({ dom }) => { this.overlayCanvas = dom; // Attempt to set initial size based on current imageElement if available if (this.imageElement && this.imageElement.complete) { dom.width = this.imageElement.naturalWidth; dom.height = this.imageElement.naturalHeight; dom.style.width = `${this.imageElement.width}px`; dom.style.height = `${this.imageElement.height}px`; if (this.isOverlayReady()) { this.drawAngleOverlay( (this.manualAngleIndex * Math.PI) / this.angleCount ); } } else { // Default placeholder size if no image yet dom.width = 256; dom.height = 256; dom.style.width = `256px`; dom.style.height = `256px`; } }, }), ] ), ] ), // Angle Slider m("div", { class: "mt-6" }, [ m( "label", { for: "angleCountSlider", class: "block text-sm font-medium text-gray-700 mb-1", }, `Number of Angles: ${this.angleCount}` ), m("input", { id: "angleCountSlider", type: "range", min: 5, max: 360, value: this.angleCount, step: 1, class: "w-full h-2 bg-gray-300 rounded-lg appearance-none cursor-pointer accent-blue-600", // Styled slider oninput: (e) => { this.angleCount = parseInt(e.target.value, 10); this.manualAngleIndex = 0; const newTheta = (0 * Math.PI) / this.angleCount; // Angle for index 0 this.drawAngleOverlay(newTheta); if (this.sinogramHighlightCanvas) { this.drawSinogramHighlight(this.manualAngleIndex); } this.loadAndProcessDebounced( this.imageUrl, this.imageUrl !== this.defaultImageUrl ); // Pass isUploaded }, }), ]), // Manual Angle Slider for Preview m("div", { class: "mt-4" }, [ m( "label", { for: "manualAngleSlider", class: "block text-sm font-medium text-gray-700 mb-1", }, `Preview Angle: ${this.manualAngleIndex} (θ ≈ ${( (this.manualAngleIndex * 180) / (this.angleCount || 1) ) // Avoid division by zero .toFixed(1)}°)` ), m("input", { id: "manualAngleSlider", type: "range", min: 0, max: Math.max(0, this.angleCount - 1), value: this.manualAngleIndex, step: 1, class: "w-full h-2 bg-gray-300 rounded-lg appearance-none cursor-pointer accent-blue-600", // Styled slider disabled: !this.imageUrl || this.sinogramUrl === "loading", // Disable if no image or loading oninput: (e) => { this.manualAngleIndex = parseInt(e.target.value, 10); const theta = (this.manualAngleIndex * Math.PI) / (this.angleCount || 1); this.drawAngleOverlay(theta); this.drawSinogramHighlight(this.manualAngleIndex); }, }), ]), ] ), // --- Section 2: Sinogram Generation & Filtering --- (this.sinogramUrl || this.filteredSinogramUrl) && m( "div", { class: "bg-white shadow-xl rounded-lg p-6 my-6 w-full max-w-xl" }, [ m(StepAccordion, { index: 2 }), this.sinogramUrl && m( "div", { class: "mt-6 border rounded-md p-4 bg-gray-50 text-center relative", }, [ m( "h3", { class: "text-lg font-semibold text-gray-700 mb-3" }, "Generated Sinogram" ), m( "div", { class: "relative inline-block w-full", style: "max-width:100%;", }, [ this.sinogramUrl === "loading" ? m( "div", { class: "flex items-center justify-center h-48 text-gray-500", }, [ m("div", { class: "animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500 mr-3", }), "Processing Sinogram...", ] ) : m("img", { src: this.sinogramUrl, alt: "Sinogram", class: "rounded shadow-md max-w-full h-auto block w-full", // Removed mx-auto, parent is inline-block style: "max-height: 250px;", // key: this.sinogramUrl, // Removed to fix Mithril key error onload: ({ target }) => { this.sinogramImageElement = target; if (this.sinogramHighlightCanvas) { this.sinogramHighlightCanvas.width = target.naturalWidth; // Use natural for accuracy this.sinogramHighlightCanvas.height = target.naturalHeight; // Style canvas to match displayed image size this.sinogramHighlightCanvas.style.width = `${target.width}px`; this.sinogramHighlightCanvas.style.height = `${target.height}px`; this.drawSinogramHighlight( this.manualAngleIndex ); } }, onerror: () => { this.sinogramImageElement = null; if (this.sinogramHighlightCanvas) { // Clear highlight const sctx = this.sinogramHighlightCanvas.getContext( "2d" ); sctx.clearRect( 0, 0, this.sinogramHighlightCanvas.width, this.sinogramHighlightCanvas.height ); } }, }), m("canvas", { class: "absolute top-0 left-0 pointer-events-none", oncreate: ({ dom }) => { this.sinogramHighlightCanvas = dom; if ( this.sinogramImageElement && this.sinogramImageElement.complete ) { dom.width = this.sinogramImageElement.naturalWidth; dom.height = this.sinogramImageElement.naturalHeight; dom.style.width = `${this.sinogramImageElement.width}px`; dom.style.height = `${this.sinogramImageElement.height}px`; this.drawSinogramHighlight(this.manualAngleIndex); } else { // Default placeholder size dom.width = 300; dom.height = 150; dom.style.width = `300px`; dom.style.height = `150px`; } }, }), ] ), ] ), m(StepAccordion, { index: 3 }), this.filteredSinogramUrl && m( "div", { class: "mt-6 border rounded-md p-4 bg-gray-50 text-center", }, [ m( "h3", { class: "text-lg font-semibold text-gray-700 mb-3" }, "Filtered Sinogram" ), m("img", { src: this.filteredSinogramUrl, alt: "Filtered Sinogram", class: "rounded shadow-md max-w-full h-auto mx-auto block w-full", style: "max-height: 250px;", // key: this.filteredSinogramUrl, // Removed to fix Mithril key error }), ] ), ] ), // --- Section 3: Reconstruction --- this.reconstructedUrl && m( "div", { class: "bg-white shadow-xl rounded-lg p-6 my-6 w-full max-w-xl" }, [ m(StepAccordion, { index: 4 }), m(StepAccordion, { index: 5 }), m( "div", { class: "mt-6 border rounded-md p-4 bg-gray-50 text-center" }, [ m( "h3", { class: "text-lg font-semibold text-gray-700 mb-3" }, "Reconstructed Image (Filtered Back Projection)" ), this.reconstructedUrl === "loading" ? m( "div", { class: "flex items-center justify-center h-48 text-gray-500", }, [ m("div", { class: "animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500 mr-3", }), "Reconstructing Image...", ] ) : m("img", { src: this.reconstructionFrames[this.currentFrameIndex] || this.reconstructedUrl, alt: "Reconstructed Image", class: "rounded shadow-md max-w-full h-auto mx-auto block", // key: // Removed to fix Mithril key error // this.reconstructionFrames[this.currentFrameIndex] || // this.reconstructedUrl, }), ] ), m( "div", { class: "mt-6 grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4", }, [ m("div", [ m( "label", { for: "renderStyleSelect", class: "block text-sm font-medium text-gray-700 mb-1", }, "Render Style:" ), m( "select", { id: "renderStyleSelect", class: "mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm rounded-md shadow-sm", value: this.renderMode, onchange: (e) => { this.renderMode = e.target.value; this.loadAndProcessDebounced( this.imageUrl, this.imageUrl !== this.defaultImageUrl ); }, }, [ m("option", { value: "heatmap" }, "Heatmap"), m("option", { value: "grayscale" }, "Grayscale"), ] ), ]), m("div", [ m( "label", { for: "filterTypeSelect", class: "block text-sm font-medium text-gray-700 mb-1", }, "Filter Type (Reconstruction):" ), m( "select", { id: "filterTypeSelect", class: "mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm rounded-md shadow-sm", value: this.filterType, onchange: (e) => { this.filterType = e.target.value; this.loadAndProcessDebounced( this.imageUrl, this.imageUrl !== this.defaultImageUrl ); }, }, [ m("option", { value: "ramp" }, "Ramp (Ram-Lak)"), m("option", { value: "shepp-logan" }, "Shepp-Logan"), m("option", { value: "cosine" }, "Cosine"), m("option", { value: "hamming" }, "Hamming"), m("option", { value: "hann" }, "Hann"), m("option", { value: "none" }, "None (Unfiltered)"), ] ), ]), ] ), this.reconstructionFrames.length > 1 && m("div", { class: "mt-6" }, [ m( "label", { for: "reconstructionFrameSlider", class: "block text-sm font-medium text-gray-700 mb-1", }, `Reconstruction Progress (Frame: ${ this.currentFrameIndex + 1 } / ${this.reconstructionFrames.length})` ), m("input", { id: "reconstructionFrameSlider", type: "range", min: 0, max: this.reconstructionFrames.length - 1, value: this.currentFrameIndex, step: 1, class: "w-full h-2 bg-gray-300 rounded-lg appearance-none cursor-pointer accent-blue-600", oninput: (e) => { this.currentFrameIndex = +e.target.value; m.redraw(); }, }), ]), ] ), ] ); }, }; // Debounce utility function debounce(fn, delay) { let timeout; return (...args) => { clearTimeout(timeout); timeout = setTimeout(() => fn(...args), delay); }; }