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 `
`;
+};
+
+marked.setOptions({ renderer });
+
function markdownToHTML(text) {
- return text
- .replace(/\n/g, "
")
- .replace(
- /!\[(.*?)\]\((.*?)\)/g,
- '
'
- );
+ // 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());
}
}