Files
sinogram/public/UploadImageComponent.js
2025-05-22 21:58:50 +10:00

856 lines
34 KiB
JavaScript

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