Improve look of app, ive broken accordion content so will need to fix
This commit is contained in:
@@ -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 `<img src="${href}" alt="${text}" title="${
|
||||
title || ""
|
||||
}" class="my-2 rounded shadow max-w-full h-auto mx-auto">`;
|
||||
};
|
||||
|
||||
marked.setOptions({ renderer });
|
||||
|
||||
function markdownToHTML(text) {
|
||||
return text
|
||||
.replace(/\n/g, "<br>")
|
||||
.replace(
|
||||
/!\[(.*?)\]\((.*?)\)/g,
|
||||
'<img alt="$1" src="$2" class="my-2 rounded shadow max-w-full">'
|
||||
);
|
||||
// Replace escaped newlines from template literals with actual newlines for marked
|
||||
const processedText = text.replace(/\\n/g, "\n");
|
||||
return marked.parse(processedText);
|
||||
}
|
||||
|
||||
@@ -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,74 +163,121 @@ 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(
|
||||
"header",
|
||||
{
|
||||
class:
|
||||
"mb-8 pb-6 border-b border-gray-300 text-center w-full max-w-3xl",
|
||||
},
|
||||
[
|
||||
m(
|
||||
"h1",
|
||||
{ class: "text-4xl font-bold text-gray-800 mb-2" },
|
||||
"Sinogram Generator"
|
||||
{ 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-lg" },
|
||||
"Upload a grayscale image to simulate CT scan projections"
|
||||
{ class: "text-gray-600 text-md sm:text-lg" }, // Responsive text size
|
||||
"Upload an image, see its sinogram, and explore the reconstruction process."
|
||||
),
|
||||
]
|
||||
),
|
||||
]),
|
||||
|
||||
m(StepAccordion, { index: 0 }),
|
||||
|
||||
// --- 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:
|
||||
"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",
|
||||
"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.loadAndProcess(url, true);
|
||||
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: "text-gray-500" },
|
||||
"Click or drag a grayscale image here"
|
||||
{ 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",
|
||||
@@ -245,210 +288,408 @@ export const UploadImageComponent = {
|
||||
const file = e.target.files[0];
|
||||
if (file && file.type.startsWith("image/")) {
|
||||
const url = URL.createObjectURL(file);
|
||||
this.loadAndProcess(url);
|
||||
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 w-full max-w-md" }, [
|
||||
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 max-w-full h-auto mx-auto",
|
||||
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;
|
||||
|
||||
// Only start once both image and canvas are ready
|
||||
if (this.isOverlayReady() && !this.hasLoadedInitialImage) {
|
||||
this.hasLoadedInitialImage = true;
|
||||
this.loadAndProcess(this.imageUrl);
|
||||
// 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", {
|
||||
width: this.imageElement?.width || 0,
|
||||
height: this.imageElement?.height || 0,
|
||||
style: "position:absolute; top:0; left:0; pointer-events:none;",
|
||||
// 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;
|
||||
|
||||
// Trigger load if image was already ready
|
||||
if (this.isOverlayReady() && !this.hasLoadedInitialImage) {
|
||||
this.hasLoadedInitialImage = true;
|
||||
this.loadAndProcess(this.imageUrl);
|
||||
// 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 w-full max-w-md" }, [
|
||||
m("div", { class: "mt-6" }, [
|
||||
m(
|
||||
"label",
|
||||
{ class: "block text-sm font-medium text-gray-700 mb-1" },
|
||||
`Number of angles: ${this.angleCount}`
|
||||
{
|
||||
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",
|
||||
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; // 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.manualAngleIndex = 0;
|
||||
const newTheta = (0 * Math.PI) / this.angleCount; // Angle 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
|
||||
this.loadAndProcessDebounced(
|
||||
this.imageUrl,
|
||||
this.imageUrl !== this.defaultImageUrl
|
||||
); // Pass isUploaded
|
||||
},
|
||||
}),
|
||||
]),
|
||||
|
||||
// Manual Angle Slider for Preview
|
||||
m("div", { class: "mt-4 w-full max-w-md" }, [
|
||||
m("div", { class: "mt-4" }, [
|
||||
m(
|
||||
"label",
|
||||
{ class: "block text-sm font-medium text-gray-700 mb-1" },
|
||||
{
|
||||
for: "manualAngleSlider",
|
||||
class: "block text-sm font-medium text-gray-700 mb-1",
|
||||
},
|
||||
`Preview Angle: ${this.manualAngleIndex} (θ ≈ ${(
|
||||
(this.manualAngleIndex * 180) /
|
||||
this.angleCount
|
||||
).toFixed(1)}°)`
|
||||
(this.angleCount || 1)
|
||||
) // Avoid division by zero
|
||||
.toFixed(1)}°)`
|
||||
),
|
||||
m("input", {
|
||||
id: "manualAngleSlider",
|
||||
type: "range",
|
||||
min: 0,
|
||||
max: Math.max(0, this.angleCount - 1), // Ensure max is not negative if angleCount is 0 or 1
|
||||
max: Math.max(0, this.angleCount - 1),
|
||||
value: this.manualAngleIndex,
|
||||
step: 1,
|
||||
class: "w-full",
|
||||
disabled: !this.hasLoadedInitialImage, // Disable if no image loaded
|
||||
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;
|
||||
const theta =
|
||||
(this.manualAngleIndex * Math.PI) / (this.angleCount || 1);
|
||||
this.drawAngleOverlay(theta);
|
||||
this.drawSinogramHighlight(this.manualAngleIndex); // Update sinogram highlight
|
||||
this.drawSinogramHighlight(this.manualAngleIndex);
|
||||
},
|
||||
}),
|
||||
]),
|
||||
]
|
||||
),
|
||||
|
||||
m(StepAccordion, { index: 2 }),
|
||||
|
||||
// Sinogram
|
||||
this.sinogramUrl &&
|
||||
m("div", { class: "mt-10 w-full max-w-md text-center relative" }, [
|
||||
// --- Section 2: Sinogram Generation & Filtering ---
|
||||
(this.sinogramUrl || this.filteredSinogramUrl) &&
|
||||
m(
|
||||
"h2",
|
||||
{ class: "text-xl font-semibold text-gray-700 mb-4" },
|
||||
"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" }, [
|
||||
m(
|
||||
"div",
|
||||
{
|
||||
class: "relative inline-block w-full",
|
||||
style: "max-width:100%;",
|
||||
},
|
||||
[
|
||||
this.sinogramUrl === "loading"
|
||||
? m("p", "Processing...")
|
||||
? 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 max-w-full h-auto mx-auto",
|
||||
style: "width: 100%; max-height: 300px;",
|
||||
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;
|
||||
// Set initial dimensions for highlight canvas based on loaded image
|
||||
if (this.sinogramHighlightCanvas) {
|
||||
this.sinogramHighlightCanvas.width = target.width;
|
||||
this.sinogramHighlightCanvas.height = target.height;
|
||||
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
|
||||
);
|
||||
}
|
||||
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
|
||||
if (this.sinogramHighlightCanvas) {
|
||||
// Clear highlight
|
||||
const sctx =
|
||||
this.sinogramHighlightCanvas.getContext(
|
||||
"2d"
|
||||
);
|
||||
sctx.clearRect(
|
||||
0,
|
||||
0,
|
||||
this.sinogramHighlightCanvas.width,
|
||||
this.sinogramHighlightCanvas.height
|
||||
);
|
||||
}
|
||||
},
|
||||
}),
|
||||
// Sinogram Highlight Canvas
|
||||
m("canvas", {
|
||||
style:
|
||||
"position:absolute; top:0; left:0; pointer-events:none; max-width: 100%; max-height: 300px;",
|
||||
class: "absolute top-0 left-0 pointer-events-none",
|
||||
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;
|
||||
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`;
|
||||
}
|
||||
},
|
||||
// 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
|
||||
m(StepAccordion, { index: 3 }),
|
||||
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" },
|
||||
"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 max-w-full h-auto mx-auto",
|
||||
style: "width: 100%; max-height: 300px;",
|
||||
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
|
||||
}),
|
||||
]),
|
||||
|
||||
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",
|
||||
]
|
||||
),
|
||||
|
||||
// --- 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",
|
||||
}),
|
||||
m("div", { class: "mt-6 w-full max-w-md text-center" }, [
|
||||
"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",
|
||||
{ class: "text-sm text-gray-600 mr-2" },
|
||||
"Render style:"
|
||||
{
|
||||
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.loadAndProcess(this.imageUrl); // re-render using selected mode
|
||||
this.loadAndProcessDebounced(
|
||||
this.imageUrl,
|
||||
this.imageUrl !== this.defaultImageUrl
|
||||
);
|
||||
},
|
||||
},
|
||||
[
|
||||
@@ -457,63 +698,78 @@ export const UploadImageComponent = {
|
||||
]
|
||||
),
|
||||
]),
|
||||
|
||||
m("div", { class: "mt-4 w-full max-w-md text-left" }, [
|
||||
m("div", [
|
||||
m(
|
||||
"label",
|
||||
{ class: "text-sm text-gray-600 mr-2" },
|
||||
"Filter Type:"
|
||||
{
|
||||
for: "filterTypeSelect",
|
||||
class: "block text-sm font-medium text-gray-700 mb-1",
|
||||
},
|
||||
"Filter Type (Reconstruction):"
|
||||
),
|
||||
m(
|
||||
"select",
|
||||
{
|
||||
class: "p-1 border rounded text-sm",
|
||||
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.loadAndProcess(this.imageUrl);
|
||||
this.loadAndProcessDebounced(
|
||||
this.imageUrl,
|
||||
this.imageUrl !== this.defaultImageUrl
|
||||
);
|
||||
},
|
||||
},
|
||||
[
|
||||
m("option", { value: "ramp" }, "Ramp"),
|
||||
m("option", { value: "ramp" }, "Ramp (Ram-Lak)"),
|
||||
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"),
|
||||
m("option", { value: "hamming" }, "Hamming"),
|
||||
m("option", { value: "hann" }, "Hann"),
|
||||
m("option", { value: "none" }, "None (Unfiltered)"),
|
||||
]
|
||||
),
|
||||
]),
|
||||
]
|
||||
),
|
||||
|
||||
this.reconstructionFrames.length > 1 &&
|
||||
m("div", { class: "mt-4" }, [
|
||||
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;
|
||||
this.reconstructedUrl =
|
||||
this.reconstructionFrames[this.currentFrameIndex];
|
||||
m.redraw();
|
||||
},
|
||||
}),
|
||||
m(
|
||||
"p",
|
||||
{ class: "text-sm text-gray-500 mt-1" },
|
||||
`Angle ${this.currentFrameIndex + 1} / ${
|
||||
this.reconstructionFrames.length
|
||||
}`
|
||||
]),
|
||||
]
|
||||
),
|
||||
]),
|
||||
m(StepAccordion, { index: 4 }),
|
||||
]),
|
||||
]
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// Debounce utility
|
||||
function debounce(fn, delay) {
|
||||
let timeout;
|
||||
return (...args) => {
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
};
|
||||
</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
|
||||
</head>
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user