524 lines
19 KiB
JavaScript
524 lines
19 KiB
JavaScript
import {
|
|
generateSinogram,
|
|
reconstructImageFromSinogram,
|
|
convertToGrayscale,
|
|
} from "./sinogram.js";
|
|
|
|
import { StepAccordion } from "./CTAccordion .js";
|
|
|
|
export const UploadImageComponent = {
|
|
hasLoadedInitialImage: false,
|
|
angleCount: 40,
|
|
imageUrl:
|
|
"https://upload.wikimedia.org/wikipedia/commons/e/e5/Shepp_logan.png",
|
|
sinogramUrl: null,
|
|
filteredSinogramUrl: null, // Added to store the filtered sinogram
|
|
reconstructedUrl: null,
|
|
defaultImageUrl:
|
|
"https://upload.wikimedia.org/wikipedia/commons/e/e5/Shepp_logan.png",
|
|
reconstructionFrames: [],
|
|
currentFrameIndex: 0,
|
|
manualAngleIndex: 0, // For the new angle control slider
|
|
sinogramImageElement: null, // To store the sinogram img DOM element
|
|
sinogramHighlightCanvas: null, // Canvas for sinogram highlight
|
|
renderMode: "grayscale", // or "heatmap"
|
|
filterType: "ramp", // Added to manage selected filter
|
|
|
|
drawAngleOverlay(theta) {
|
|
const canvas = this.overlayCanvas;
|
|
if (!canvas || !this.imageElement) return;
|
|
|
|
const ctx = canvas.getContext("2d");
|
|
const w = canvas.width;
|
|
const h = canvas.height;
|
|
const cx = w / 2;
|
|
const cy = h / 2;
|
|
const len = Math.max(w, h);
|
|
|
|
ctx.clearRect(0, 0, w, h);
|
|
|
|
const dx = len * Math.cos(theta);
|
|
const dy = len * Math.sin(theta);
|
|
|
|
ctx.strokeStyle = "rgba(255,0,0,0.8)";
|
|
ctx.lineWidth = 2;
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx - dx, cy - dy);
|
|
ctx.lineTo(cx + dx, cy + dy);
|
|
ctx.stroke();
|
|
},
|
|
|
|
drawSinogramHighlight(angleIndex) {
|
|
if (
|
|
!this.sinogramHighlightCanvas ||
|
|
!this.sinogramImageElement ||
|
|
!this.sinogramImageElement.complete ||
|
|
this.sinogramImageElement.naturalWidth === 0
|
|
) {
|
|
if (this.sinogramHighlightCanvas) {
|
|
const ctx = this.sinogramHighlightCanvas.getContext("2d");
|
|
ctx.clearRect(
|
|
0,
|
|
0,
|
|
this.sinogramHighlightCanvas.width,
|
|
this.sinogramHighlightCanvas.height
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const canvas = this.sinogramHighlightCanvas;
|
|
const ctx = canvas.getContext("2d");
|
|
const dispWidth = this.sinogramImageElement.width; // Displayed width of sinogram image
|
|
const dispHeight = this.sinogramImageElement.height; // Displayed height of sinogram image
|
|
const naturalW = this.sinogramImageElement.naturalWidth;
|
|
const naturalH = this.sinogramImageElement.naturalHeight;
|
|
|
|
if (canvas.width !== dispWidth) canvas.width = dispWidth;
|
|
if (canvas.height !== dispHeight) canvas.height = dispHeight;
|
|
|
|
ctx.clearRect(0, 0, dispWidth, dispHeight);
|
|
|
|
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
|
|
} else if (naturalH === this.angleCount && naturalW !== this.angleCount) {
|
|
anglesAlongImageHeight = true; // Angles are rows, highlight is horizontal
|
|
} 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
|
|
|
|
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 yPos = (angleIndex + 0.5) * highlightRowHeight;
|
|
|
|
ctx.lineWidth = Math.max(1, highlightRowHeight * 0.7);
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, yPos);
|
|
ctx.lineTo(dispWidth, 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 xPos = (angleIndex + 0.5) * highlightColWidth;
|
|
|
|
ctx.lineWidth = Math.max(1, highlightColWidth * 0.7);
|
|
ctx.beginPath();
|
|
ctx.moveTo(xPos, 0);
|
|
ctx.lineTo(xPos, dispHeight);
|
|
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; // Reset filtered sinogram
|
|
this.reconstructedUrl = null;
|
|
this.manualAngleIndex = 0; // Reset manual angle on new image
|
|
m.redraw();
|
|
|
|
this.imageUrl = url;
|
|
let finalUrl = url;
|
|
|
|
if (isUploaded) {
|
|
finalUrl = await convertToGrayscale(url);
|
|
this.imageUrl = finalUrl;
|
|
}
|
|
|
|
this.sinogramUrl = await generateSinogram(
|
|
finalUrl,
|
|
this.angleCount,
|
|
this.drawAngleOverlay.bind(this)
|
|
);
|
|
m.redraw();
|
|
|
|
this.reconstructionFrames = [];
|
|
this.currentFrameIndex = 0;
|
|
|
|
const reconstructionResult = await reconstructImageFromSinogram(
|
|
this.sinogramUrl,
|
|
undefined,
|
|
(angle, frameUrl) => {
|
|
this.reconstructionFrames.push(frameUrl);
|
|
this.currentFrameIndex = this.reconstructionFrames.length - 1;
|
|
this.reconstructedUrl = frameUrl; // This is the final reconstructed image frame
|
|
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
|
|
},
|
|
|
|
oninit() {
|
|
this.loadAndProcessDebounced = debounce((url) => {
|
|
this.loadAndProcess(url);
|
|
}, 300);
|
|
},
|
|
|
|
view() {
|
|
return m(
|
|
"div",
|
|
{
|
|
class: "flex flex-col items-center min-h-screen bg-gray-100 py-10 px-4",
|
|
},
|
|
[
|
|
// 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",
|
|
{
|
|
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(),
|
|
},
|
|
[
|
|
m(
|
|
"p",
|
|
{ class: "text-gray-500" },
|
|
"Click or drag a grayscale image here"
|
|
),
|
|
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" }, [
|
|
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);
|
|
}
|
|
},
|
|
// 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
|
|
},
|
|
},
|
|
[
|
|
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(
|
|
"p",
|
|
{ class: "text-sm text-gray-500 mt-1" },
|
|
`Angle ${this.currentFrameIndex + 1} / ${
|
|
this.reconstructionFrames.length
|
|
}`
|
|
),
|
|
]),
|
|
m(StepAccordion, { index: 4 }),
|
|
]),
|
|
]
|
|
);
|
|
},
|
|
};
|
|
|
|
function debounce(fn, delay) {
|
|
let timeout;
|
|
return (...args) => {
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(() => fn(...args), delay);
|
|
};
|
|
}
|