diff --git a/public/CTAccordion .js b/public/CTAccordion .js index d0b173c..76b8a05 100644 --- a/public/CTAccordion .js +++ b/public/CTAccordion .js @@ -97,12 +97,19 @@ export const StepAccordion = { }, }; -// You must provide a markdownToHTML() function or use a library like marked.js +// Use marked.js for robust Markdown to HTML conversion +const renderer = new marked.Renderer(); +renderer.image = (href, title, text) => { + // Add Tailwind classes for styling images + return `${text}`; +}; + +marked.setOptions({ renderer }); + function markdownToHTML(text) { - return text - .replace(/\n/g, "
") - .replace( - /!\[(.*?)\]\((.*?)\)/g, - '$1' - ); + // Replace escaped newlines from template literals with actual newlines for marked + const processedText = text.replace(/\\n/g, "\n"); + return marked.parse(processedText); } diff --git a/public/UploadImageComponent.js b/public/UploadImageComponent.js index 9424312..b21eb3f 100644 --- a/public/UploadImageComponent.js +++ b/public/UploadImageComponent.js @@ -71,61 +71,50 @@ export const UploadImageComponent = { const canvas = this.sinogramHighlightCanvas; const ctx = canvas.getContext("2d"); const dispWidth = this.sinogramImageElement.width; // Displayed width of sinogram image - const dispHeight = this.sinogramImageElement.height; // Displayed height 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; - if (canvas.height !== dispHeight) canvas.height = dispHeight; + // 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, dispWidth, dispHeight); + ctx.clearRect(0, 0, canvas.width, canvas.height); if (this.angleCount <= 0 || naturalW === 0 || naturalH === 0) return; let anglesAlongImageHeight = true; - // Infer orientation based on natural dimensions of the sinogram image vs angleCount - // This logic assumes that one of the dimensions of the *natural* sinogram image data - // (before it's scaled for display) corresponds to this.angleCount. if (naturalW === this.angleCount && naturalH !== this.angleCount) { - anglesAlongImageHeight = false; // Angles are columns, highlight is vertical + anglesAlongImageHeight = false; } else if (naturalH === this.angleCount && naturalW !== this.angleCount) { - anglesAlongImageHeight = true; // Angles are rows, highlight is horizontal + anglesAlongImageHeight = true; } else if (naturalH === this.angleCount && naturalW === this.angleCount) { - // Square sinogram (angleCount might equal detector_size), assume angles along height by convention anglesAlongImageHeight = true; } - // If neither naturalW nor naturalH strictly equals angleCount (and the other doesn't), - // we stick with the default assumption (anglesAlongImageHeight = true). - // This can happen if the sinogram image itself is generated with an aspect ratio - // where neither dimension is exactly `this.angleCount` (e.g. if `generateSinogram` in sinogram.js - // has `size` not equal to `angles` and the output canvas is `size` x `angles` or `angles` x `size`). - // The key is that `this.angleCount` represents the number of projection angles. - ctx.strokeStyle = "rgba(0, 255, 0, 0.7)"; // Green highlight + ctx.strokeStyle = "rgba(0, 255, 0, 0.7)"; if (anglesAlongImageHeight) { - // Angles are effectively rows in the displayed sinogram image. - // The number of such rows is this.angleCount. - // We need to highlight the row corresponding to `angleIndex`. - const highlightRowHeight = dispHeight / this.angleCount; + 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(dispWidth, yPos); + ctx.lineTo(canvas.width, yPos); ctx.stroke(); } else { - // Angles are effectively columns in the displayed sinogram image. - // The number of such columns is this.angleCount. - const highlightColWidth = dispWidth / this.angleCount; + 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, dispHeight); + ctx.lineTo(xPos, canvas.height); ctx.stroke(); } }, @@ -142,17 +131,24 @@ export const UploadImageComponent = { async loadAndProcess(url, isUploaded = false) { this.imageUrl = url; this.sinogramUrl = "loading"; - this.filteredSinogramUrl = null; // Reset filtered sinogram - this.reconstructedUrl = null; - this.manualAngleIndex = 0; // Reset manual angle on new image + this.filteredSinogramUrl = null; + this.reconstructedUrl = "loading"; // Indicate reconstruction is also loading + this.manualAngleIndex = 0; m.redraw(); - this.imageUrl = url; let finalUrl = url; if (isUploaded) { finalUrl = await convertToGrayscale(url); - this.imageUrl = finalUrl; + 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( @@ -167,353 +163,613 @@ export const UploadImageComponent = { const reconstructionResult = await reconstructImageFromSinogram( this.sinogramUrl, - undefined, + 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; // This is the final reconstructed image frame + this.reconstructedUrl = frameUrl; m.redraw(); }, this.renderMode, this.filterType ); - // After all frames are processed, the final reconstructedUrl and filteredSinogramUrl are available this.reconstructedUrl = reconstructionResult.reconstructedUrl; this.filteredSinogramUrl = reconstructionResult.filteredSinogramUrl; - m.redraw(); // Ensure UI updates with the final urls + m.redraw(); }, oninit() { - this.loadAndProcessDebounced = debounce((url) => { - this.loadAndProcess(url); - }, 300); + 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-100 py-10 px-4", + 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-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", + "header", { 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(), + "mb-8 pb-6 border-b border-gray-300 text-center w-full max-w-3xl", }, [ m( - "p", - { class: "text-gray-500" }, - "Click or drag a grayscale image here" + "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." ), - 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.manualAngleIndex = 0; // Reset manual angle if angleCount changes - // Redraw overlay for new manualAngleIndex=0 with new angleCount - const newTheta = (0 * Math.PI) / this.angleCount; // Theta for index 0 - this.drawAngleOverlay(newTheta); - if (this.sinogramHighlightCanvas) { - // If sinogram highlight exists, update it too - this.drawSinogramHighlight(this.manualAngleIndex); - } - this.loadAndProcessDebounced(this.imageUrl); // reprocess with new angle count - }, - }), - ]), - - // Manual Angle Slider for Preview - m("div", { class: "mt-4 w-full max-w-md" }, [ - m( - "label", - { class: "block text-sm font-medium text-gray-700 mb-1" }, - `Preview Angle: ${this.manualAngleIndex} (θ ≈ ${( - (this.manualAngleIndex * 180) / - this.angleCount - ).toFixed(1)}°)` - ), - m("input", { - type: "range", - min: 0, - max: Math.max(0, this.angleCount - 1), // Ensure max is not negative if angleCount is 0 or 1 - value: this.manualAngleIndex, - step: 1, - class: "w-full", - disabled: !this.hasLoadedInitialImage, // Disable if no image loaded - oninput: (e) => { - this.manualAngleIndex = parseInt(e.target.value, 10); - const theta = (this.manualAngleIndex * Math.PI) / this.angleCount; - this.drawAngleOverlay(theta); - this.drawSinogramHighlight(this.manualAngleIndex); // Update sinogram highlight - }, - }), - ]), - - m(StepAccordion, { index: 2 }), - - // Sinogram - this.sinogramUrl && - m("div", { class: "mt-10 w-full max-w-md text-center relative" }, [ + // --- 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( - "h2", - { class: "text-xl font-semibold text-gray-700 mb-4" }, - "Generated Sinogram" - ), - m("div", { class: "relative" }, [ - this.sinogramUrl === "loading" - ? m("p", "Processing...") - : m("img", { - src: this.sinogramUrl, - alt: "Sinogram", - class: "rounded shadow max-w-full h-auto mx-auto", - style: "width: 100%; max-height: 300px;", - onload: ({ target }) => { - this.sinogramImageElement = target; - // Set initial dimensions for highlight canvas based on loaded image - if (this.sinogramHighlightCanvas) { - this.sinogramHighlightCanvas.width = target.width; - this.sinogramHighlightCanvas.height = target.height; - } - this.drawSinogramHighlight(this.manualAngleIndex); // Draw initial highlight - }, - onerror: () => { - // If sinogram fails to load, clear element - this.sinogramImageElement = null; - this.drawSinogramHighlight(this.manualAngleIndex); // Attempt to clear highlight - }, - }), - // Sinogram Highlight Canvas - m("canvas", { - style: - "position:absolute; top:0; left:0; pointer-events:none; max-width: 100%; max-height: 300px;", - oncreate: ({ dom }) => { - this.sinogramHighlightCanvas = dom; - // Ensure dimensions are set if image is already loaded by now (race condition) - if ( - this.sinogramImageElement && - this.sinogramImageElement.complete - ) { - dom.width = this.sinogramImageElement.width; - dom.height = this.sinogramImageElement.height; - this.drawSinogramHighlight(this.manualAngleIndex); + "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 } }, - // Set width/height to 0 initially or based on a placeholder if needed - width: this.sinogramImageElement - ? this.sinogramImageElement.width - : this.overlayCanvas - ? this.overlayCanvas.width - : 300, // Default or match other canvas - height: this.sinogramImageElement - ? this.sinogramImageElement.height - : this.overlayCanvas - ? this.overlayCanvas.height - : 300, - }), - ]), - ]), - - // Filtered Sinogram Display - this.filteredSinogramUrl && - m("div", { class: "mt-10 w-full max-w-md text-center" }, [ - m( - "h2", - { class: "text-xl font-semibold text-gray-700 mb-4" }, - "Filtered Sinogram" - ), - m("img", { - src: this.filteredSinogramUrl, - alt: "Filtered Sinogram", - class: "rounded shadow max-w-full h-auto mx-auto", - style: "width: 100%; max-height: 300px;", - }), - ]), - - 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 + 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("option", { value: "heatmap" }, "Heatmap"), - m("option", { value: "grayscale" }, "Grayscale"), - ] - ), - ]), - - m("div", { class: "mt-4 w-full max-w-md text-left" }, [ - m( - "label", - { class: "text-sm text-gray-600 mr-2" }, - "Filter Type:" - ), - m( - "select", - { - class: "p-1 border rounded text-sm", - value: this.filterType, - onchange: (e) => { - this.filterType = e.target.value; - this.loadAndProcess(this.imageUrl); - }, - }, - [ - m("option", { value: "ramp" }, "Ramp"), - m("option", { value: "shepp-logan" }, "Shepp-Logan"), - m("option", { value: "hann" }, "Hann"), - m("option", { value: "hamming" }, "Hamming"), - m("option", { value: "cosine" }, "Cosine"), - m("option", { value: "none" }, "None"), - ] - ), - ]), - - 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("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: "text-sm text-gray-500 mt-1" }, - `Angle ${this.currentFrameIndex + 1} / ${ - this.reconstructionFrames.length - }` + { class: "mt-2 text-sm font-medium text-gray-700" }, // Slightly bolder text + "Click to upload or drag and drop an image" ), - ]), - m(StepAccordion, { index: 4 }), - ]), + 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) => { diff --git a/public/index.html b/public/index.html index 3748e2e..6f82191 100644 --- a/public/index.html +++ b/public/index.html @@ -17,6 +17,7 @@ }; + diff --git a/public/sinogram.js b/public/sinogram.js index 85f1fc3..479a25e 100644 --- a/public/sinogram.js +++ b/public/sinogram.js @@ -19,7 +19,7 @@ export async function generateSinogram( const theta = (angle * Math.PI) / angles; if (drawAngleCallback) drawAngleCallback(theta); - await new Promise((r) => setTimeout(r, 0.01)); + // await new Promise((r) => setTimeout(r, 0.01)); // Removed for performance ctx.clearRect(0, 0, size, size); ctx.save(); @@ -167,7 +167,7 @@ export async function reconstructImageFromSinogram( } outputCtx.putImageData(imageData, 0, 0); - await new Promise((r) => setTimeout(r, 1)); + // await new Promise((r) => setTimeout(r, 1)); // Removed for performance onFrame(angle, outputCanvas.toDataURL()); } }