Compare commits
10 Commits
86f7a69b37
...
65ffcb767f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65ffcb767f | ||
|
|
bcb7f1bce3 | ||
|
|
41c3c76218 | ||
|
|
fe6c00c0fa | ||
|
|
9f12966b12 | ||
|
|
26e860d168 | ||
|
|
ad74375567 | ||
|
|
7541a472ed | ||
|
|
d8eaf675cf | ||
|
|
f12aa03fc5 |
1
.buildpacks
Normal file
1
.buildpacks
Normal file
@@ -0,0 +1 @@
|
|||||||
|
https://github.com/heroku/heroku-buildpack-static.git
|
||||||
@@ -1,326 +0,0 @@
|
|||||||
import {
|
|
||||||
generateSinogram,
|
|
||||||
reconstructImageFromSinogram,
|
|
||||||
convertToGrayscale,
|
|
||||||
} from "./sinogram.js";
|
|
||||||
|
|
||||||
import { StepAccordion } from "./CTAccordion .js";
|
|
||||||
|
|
||||||
export const UploadImageComponent = {
|
|
||||||
hasLoadedInitialImage: false,
|
|
||||||
angleCount: 180,
|
|
||||||
imageUrl:
|
|
||||||
"https://upload.wikimedia.org/wikipedia/commons/e/e5/Shepp_logan.png",
|
|
||||||
sinogramUrl: null,
|
|
||||||
reconstructedUrl: null,
|
|
||||||
defaultImageUrl:
|
|
||||||
"https://upload.wikimedia.org/wikipedia/commons/e/e5/Shepp_logan.png",
|
|
||||||
reconstructionFrames: [],
|
|
||||||
currentFrameIndex: 0,
|
|
||||||
renderMode: "grayscale", // or "heatmap"
|
|
||||||
useFBP: true,
|
|
||||||
|
|
||||||
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();
|
|
||||||
},
|
|
||||||
|
|
||||||
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.reconstructedUrl = null;
|
|
||||||
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;
|
|
||||||
|
|
||||||
this.reconstructedUrl = await reconstructImageFromSinogram(
|
|
||||||
this.sinogramUrl,
|
|
||||||
undefined,
|
|
||||||
(angle, frameUrl) => {
|
|
||||||
this.reconstructionFrames.push(frameUrl);
|
|
||||||
this.currentFrameIndex = this.reconstructionFrames.length - 1;
|
|
||||||
this.reconstructedUrl = frameUrl;
|
|
||||||
m.redraw();
|
|
||||||
},
|
|
||||||
this.renderMode,
|
|
||||||
this.useFBP
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
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.loadAndProcessDebounced(this.imageUrl); // reprocess with new angle count
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
|
|
||||||
m(StepAccordion, { index: 2 }),
|
|
||||||
|
|
||||||
// Sinogram
|
|
||||||
this.sinogramUrl &&
|
|
||||||
m("div", { class: "mt-10 w-full max-w-md text-center" }, [
|
|
||||||
m(
|
|
||||||
"h2",
|
|
||||||
{ class: "text-xl font-semibold text-gray-700 mb-4" },
|
|
||||||
"Generated Sinogram"
|
|
||||||
),
|
|
||||||
this.sinogramUrl === "loading"
|
|
||||||
? m("p", "Processing...")
|
|
||||||
: m("img", {
|
|
||||||
src: this.sinogramUrl,
|
|
||||||
alt: "Sinogram",
|
|
||||||
class: "rounded shadow max-w-full h-auto mx-auto",
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
|
|
||||||
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", [
|
|
||||||
m("input", {
|
|
||||||
type: "checkbox",
|
|
||||||
checked: this.useFBP,
|
|
||||||
onchange: (e) => {
|
|
||||||
this.useFBP = e.target.checked;
|
|
||||||
this.loadAndProcess(this.imageUrl); // regenerate with or without FBP
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
m(
|
|
||||||
"span",
|
|
||||||
{ class: "ml-2 text-gray-700" },
|
|
||||||
"Use Filtered Back Projection (Ramp)"
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
]),
|
|
||||||
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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) {
|
function markdownToHTML(text) {
|
||||||
return text
|
// Replace escaped newlines from template literals with actual newlines for marked
|
||||||
.replace(/\n/g, "<br>")
|
const processedText = text.replace(/\\n/g, "\n");
|
||||||
.replace(
|
return marked.parse(processedText);
|
||||||
/!\[(.*?)\]\((.*?)\)/g,
|
|
||||||
'<img alt="$1" src="$2" class="my-2 rounded shadow max-w-full">'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
160
public/ProjectionGraphComponent.js
Normal file
160
public/ProjectionGraphComponent.js
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
export const ProjectionGraphComponent = {
|
||||||
|
view: function (vnode) {
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
title,
|
||||||
|
width = 300,
|
||||||
|
height = 150,
|
||||||
|
color = "steelblue",
|
||||||
|
} = vnode.attrs;
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return m(
|
||||||
|
"div.projection-graph-container",
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
width: `${width}px`,
|
||||||
|
height: `${height}px`,
|
||||||
|
border: "1px solid #ccc",
|
||||||
|
display: "flex",
|
||||||
|
"align-items": "center",
|
||||||
|
"justify-content": "center",
|
||||||
|
"background-color": "#f9f9f9",
|
||||||
|
"margin-bottom": "10px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
m("p.text-gray-500", title ? `${title}: No data` : "No data available")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const padding = { top: 20, right: 20, bottom: 30, left: 40 };
|
||||||
|
const chartWidth = width - padding.left - padding.right;
|
||||||
|
const chartHeight = height - padding.top - padding.bottom;
|
||||||
|
|
||||||
|
const maxVal = Math.max(...data, 0); // Ensure maxVal is at least 0
|
||||||
|
const minVal = Math.min(...data, 0); // Ensure minVal is at most 0
|
||||||
|
|
||||||
|
// Adjust scale if all values are 0 or very close to 0
|
||||||
|
let yMax = maxVal;
|
||||||
|
let yMin = minVal;
|
||||||
|
|
||||||
|
if (maxVal === 0 && minVal === 0) {
|
||||||
|
yMax = 1; // Avoid division by zero if all data points are 0
|
||||||
|
yMin = -1;
|
||||||
|
} else if (maxVal === minVal) {
|
||||||
|
// All values are the same but not zero
|
||||||
|
yMax = maxVal + Math.abs(maxVal * 0.1) + 1; // Add some padding
|
||||||
|
yMin = minVal - Math.abs(minVal * 0.1) - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const xScale = (index) => (index / (data.length - 1)) * chartWidth;
|
||||||
|
const yScale = (value) =>
|
||||||
|
chartHeight - ((value - yMin) / (yMax - yMin)) * chartHeight;
|
||||||
|
|
||||||
|
const linePath = data
|
||||||
|
.map((d, i) => `${i === 0 ? "M" : "L"}${xScale(i)},${yScale(d)}`)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
// Generate Y-axis ticks (e.g., 3 ticks: min, mid, max)
|
||||||
|
const yTicks = [];
|
||||||
|
if (yMin !== yMax) {
|
||||||
|
yTicks.push({ value: yMin, y: yScale(yMin) });
|
||||||
|
yTicks.push({ value: (yMin + yMax) / 2, y: yScale((yMin + yMax) / 2) });
|
||||||
|
yTicks.push({ value: yMax, y: yScale(yMax) });
|
||||||
|
} else {
|
||||||
|
// Handle case where all values are the same
|
||||||
|
yTicks.push({ value: yMin - 1, y: yScale(yMin - 1) });
|
||||||
|
yTicks.push({ value: yMin, y: yScale(yMin) });
|
||||||
|
yTicks.push({ value: yMin + 1, y: yScale(yMin + 1) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return m(
|
||||||
|
"div.projection-graph-container",
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
"margin-bottom": "15px",
|
||||||
|
padding: "10px",
|
||||||
|
border: "1px solid #e2e8f0",
|
||||||
|
"border-radius": "0.375rem",
|
||||||
|
"background-color": "#fff",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[
|
||||||
|
m(
|
||||||
|
"h4.text-md.font-semibold.text-gray-700.mb-2.text-center",
|
||||||
|
title || "Projection Data"
|
||||||
|
),
|
||||||
|
m("svg", { width: width, height: height }, [
|
||||||
|
m("g", { transform: `translate(${padding.left}, ${padding.top})` }, [
|
||||||
|
// Y-axis
|
||||||
|
m("line", { x1: 0, y1: 0, x2: 0, y2: chartHeight, stroke: "#ccc" }),
|
||||||
|
yTicks.map((tick) =>
|
||||||
|
m("g", { transform: `translate(0, ${tick.y})` }, [
|
||||||
|
m("line", { x1: -5, y1: 0, x2: 0, y2: 0, stroke: "#ccc" }),
|
||||||
|
m(
|
||||||
|
"text",
|
||||||
|
{
|
||||||
|
x: -10,
|
||||||
|
y: 0,
|
||||||
|
"font-size": "10px",
|
||||||
|
"text-anchor": "end",
|
||||||
|
dy: ".32em",
|
||||||
|
},
|
||||||
|
parseFloat(tick.value).toFixed(1)
|
||||||
|
),
|
||||||
|
])
|
||||||
|
),
|
||||||
|
|
||||||
|
// X-axis
|
||||||
|
m("line", {
|
||||||
|
x1: 0,
|
||||||
|
y1: chartHeight,
|
||||||
|
x2: chartWidth,
|
||||||
|
y2: chartHeight,
|
||||||
|
stroke: "#ccc",
|
||||||
|
}),
|
||||||
|
// X-axis labels (optional, e.g., start, mid, end)
|
||||||
|
m(
|
||||||
|
"text",
|
||||||
|
{
|
||||||
|
x: 0,
|
||||||
|
y: chartHeight + 15,
|
||||||
|
"font-size": "10px",
|
||||||
|
"text-anchor": "start",
|
||||||
|
},
|
||||||
|
"0"
|
||||||
|
),
|
||||||
|
m(
|
||||||
|
"text",
|
||||||
|
{
|
||||||
|
x: chartWidth / 2,
|
||||||
|
y: chartHeight + 15,
|
||||||
|
"font-size": "10px",
|
||||||
|
"text-anchor": "middle",
|
||||||
|
},
|
||||||
|
`${Math.floor(data.length / 2)}`
|
||||||
|
),
|
||||||
|
m(
|
||||||
|
"text",
|
||||||
|
{
|
||||||
|
x: chartWidth,
|
||||||
|
y: chartHeight + 15,
|
||||||
|
"font-size": "10px",
|
||||||
|
"text-anchor": "end",
|
||||||
|
},
|
||||||
|
`${data.length - 1}`
|
||||||
|
),
|
||||||
|
|
||||||
|
// Data line
|
||||||
|
m("path", {
|
||||||
|
d: linePath,
|
||||||
|
stroke: color,
|
||||||
|
"stroke-width": 2,
|
||||||
|
fill: "none",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
855
public/UploadImageComponent.js
Normal file
855
public/UploadImageComponent.js
Normal file
@@ -0,0 +1,855 @@
|
|||||||
|
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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -90,3 +90,57 @@ export function applyRampFilter(projection) {
|
|||||||
ifft1D(re, im);
|
ifft1D(re, im);
|
||||||
return Array.from(re.slice(0, projection.length));
|
return Array.from(re.slice(0, projection.length));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function applyFilter(projection, type = "ramp") {
|
||||||
|
const N = nextPow2(projection.length);
|
||||||
|
const re = new Float32Array(N);
|
||||||
|
const im = new Float32Array(N);
|
||||||
|
for (let i = 0; i < projection.length; i++) {
|
||||||
|
re[i] = projection[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
fft1D(re, im);
|
||||||
|
|
||||||
|
for (let i = 0; i < N / 2; i++) {
|
||||||
|
const normFreq = i / N;
|
||||||
|
const ramp = normFreq;
|
||||||
|
|
||||||
|
let filter = 1;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "ramp":
|
||||||
|
filter = ramp;
|
||||||
|
break;
|
||||||
|
case "shepp-logan":
|
||||||
|
const sinc =
|
||||||
|
normFreq === 0
|
||||||
|
? 1
|
||||||
|
: Math.sin(Math.PI * normFreq) / (Math.PI * normFreq);
|
||||||
|
filter = ramp * sinc;
|
||||||
|
break;
|
||||||
|
case "hann":
|
||||||
|
filter = ramp * (0.5 + 0.5 * Math.cos(Math.PI * normFreq));
|
||||||
|
break;
|
||||||
|
case "hamming":
|
||||||
|
filter = ramp * (0.54 + 0.46 * Math.cos(Math.PI * normFreq));
|
||||||
|
break;
|
||||||
|
case "cosine":
|
||||||
|
filter = ramp * Math.cos((Math.PI * normFreq) / 2);
|
||||||
|
break;
|
||||||
|
case "none":
|
||||||
|
filter = 1;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.warn(`Unknown filter type: ${type}. Defaulting to ramp.`);
|
||||||
|
filter = ramp;
|
||||||
|
}
|
||||||
|
|
||||||
|
re[i] *= filter;
|
||||||
|
im[i] *= filter;
|
||||||
|
re[N - i - 1] *= filter;
|
||||||
|
im[N - i - 1] *= filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
ifft1D(re, im);
|
||||||
|
return Array.from(re.slice(0, projection.length));
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js"></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>
|
</head>
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { applyRampFilter } from "./fbp.js";
|
import { applyFilter } from "./fbp.js";
|
||||||
|
|
||||||
export async function generateSinogram(
|
export async function generateSinogram(
|
||||||
imageUrl,
|
imageUrl,
|
||||||
@@ -18,51 +18,49 @@ export async function generateSinogram(
|
|||||||
for (let angle = 0; angle < angles; angle++) {
|
for (let angle = 0; angle < angles; angle++) {
|
||||||
const theta = (angle * Math.PI) / angles;
|
const theta = (angle * Math.PI) / angles;
|
||||||
|
|
||||||
// 🔁 Call visual overlay for this angle
|
|
||||||
if (drawAngleCallback) drawAngleCallback(theta);
|
if (drawAngleCallback) drawAngleCallback(theta);
|
||||||
|
// await new Promise((r) => setTimeout(r, 0.01)); // Removed for performance
|
||||||
|
|
||||||
// (Optional: add delay for animation)
|
|
||||||
await new Promise((r) => setTimeout(r, 0.01));
|
|
||||||
|
|
||||||
// Clear canvas
|
|
||||||
ctx.clearRect(0, 0, size, size);
|
ctx.clearRect(0, 0, size, size);
|
||||||
|
|
||||||
// Transform and draw rotated image
|
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.translate(size / 2, size / 2);
|
ctx.translate(size / 2, size / 2);
|
||||||
ctx.rotate(theta);
|
ctx.rotate(theta);
|
||||||
ctx.drawImage(image, -image.width / 2, -image.height / 2);
|
ctx.drawImage(image, -image.width / 2, -image.height / 2);
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
|
||||||
// Read pixel data
|
|
||||||
const { data } = ctx.getImageData(0, 0, size, size);
|
const { data } = ctx.getImageData(0, 0, size, size);
|
||||||
|
|
||||||
// Sum brightness vertically (simulate X-ray projection)
|
|
||||||
const projection = [];
|
const projection = [];
|
||||||
for (let x = 0; x < size; x++) {
|
for (let x = 0; x < size; x++) {
|
||||||
let sum = 0;
|
let sum = 0;
|
||||||
for (let y = 0; y < size; y++) {
|
for (let y = 0; y < size; y++) {
|
||||||
const i = (y * size + x) * 4;
|
const i = (y * size + x) * 4;
|
||||||
const gray = data[i]; // red channel (since grayscale)
|
sum += data[i]; // grayscale from red channel
|
||||||
sum += gray;
|
|
||||||
}
|
}
|
||||||
projection.push(sum / size); // normalize
|
projection.push(sum / size);
|
||||||
}
|
}
|
||||||
projections.push(projection);
|
projections.push(projection);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create sinogram canvas
|
const isHorizontal = angles >= size;
|
||||||
|
|
||||||
|
// Create rotated canvas accordingly
|
||||||
const sinogramCanvas = Object.assign(document.createElement("canvas"), {
|
const sinogramCanvas = Object.assign(document.createElement("canvas"), {
|
||||||
width: size,
|
width: isHorizontal ? size : angles,
|
||||||
height: angles,
|
height: isHorizontal ? angles : size,
|
||||||
});
|
});
|
||||||
const sinCtx = sinogramCanvas.getContext("2d");
|
const sinCtx = sinogramCanvas.getContext("2d");
|
||||||
const imgData = sinCtx.createImageData(size, angles);
|
const imgData = sinCtx.createImageData(
|
||||||
|
sinogramCanvas.width,
|
||||||
|
sinogramCanvas.height
|
||||||
|
);
|
||||||
|
|
||||||
for (let y = 0; y < angles; y++) {
|
for (let angle = 0; angle < angles; angle++) {
|
||||||
for (let x = 0; x < size; x++) {
|
for (let x = 0; x < size; x++) {
|
||||||
const val = projections[y][x];
|
const val = projections[angle][x];
|
||||||
const i = (y * size + x) * 4;
|
const px = isHorizontal ? x : angle;
|
||||||
|
const py = isHorizontal ? angle : x;
|
||||||
|
const i = (py * sinogramCanvas.width + px) * 4;
|
||||||
imgData.data[i + 0] = val;
|
imgData.data[i + 0] = val;
|
||||||
imgData.data[i + 1] = val;
|
imgData.data[i + 1] = val;
|
||||||
imgData.data[i + 2] = val;
|
imgData.data[i + 2] = val;
|
||||||
@@ -71,7 +69,10 @@ export async function generateSinogram(
|
|||||||
}
|
}
|
||||||
|
|
||||||
sinCtx.putImageData(imgData, 0, 0);
|
sinCtx.putImageData(imgData, 0, 0);
|
||||||
return sinogramCanvas.toDataURL();
|
return {
|
||||||
|
sinogramUrl: sinogramCanvas.toDataURL(),
|
||||||
|
projections: projections, // Return the raw projections
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reconstructImageFromSinogram(
|
export async function reconstructImageFromSinogram(
|
||||||
@@ -79,7 +80,7 @@ export async function reconstructImageFromSinogram(
|
|||||||
size = 256,
|
size = 256,
|
||||||
onFrame = null,
|
onFrame = null,
|
||||||
renderMode = "heatmap",
|
renderMode = "heatmap",
|
||||||
useFBP = true
|
filterType = "ramp"
|
||||||
) {
|
) {
|
||||||
const sinogramImage = await loadImage(sinogramUrl);
|
const sinogramImage = await loadImage(sinogramUrl);
|
||||||
const canvas = Object.assign(document.createElement("canvas"), {
|
const canvas = Object.assign(document.createElement("canvas"), {
|
||||||
@@ -95,7 +96,18 @@ export async function reconstructImageFromSinogram(
|
|||||||
sinogramImage.height
|
sinogramImage.height
|
||||||
).data;
|
).data;
|
||||||
|
|
||||||
size = sinogramImage.width; // match size to sinogram resolution
|
// Detect orientation
|
||||||
|
let width, angles;
|
||||||
|
const isVertical = sinogramImage.height > sinogramImage.width;
|
||||||
|
if (isVertical) {
|
||||||
|
angles = sinogramImage.width;
|
||||||
|
width = sinogramImage.height;
|
||||||
|
} else {
|
||||||
|
angles = sinogramImage.height;
|
||||||
|
width = sinogramImage.width;
|
||||||
|
}
|
||||||
|
|
||||||
|
size = width;
|
||||||
const outputCanvas = Object.assign(document.createElement("canvas"), {
|
const outputCanvas = Object.assign(document.createElement("canvas"), {
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
@@ -104,36 +116,43 @@ export async function reconstructImageFromSinogram(
|
|||||||
const accum = new Float32Array(size * size);
|
const accum = new Float32Array(size * size);
|
||||||
const center = size / 2;
|
const center = size / 2;
|
||||||
|
|
||||||
const angles = sinogramImage.height;
|
const rawProjectionsFromSinogram = []; // To store raw projections extracted from sinogram image
|
||||||
const width = sinogramImage.width;
|
const allFilteredProjections = []; // To store filtered projections
|
||||||
|
|
||||||
for (let angle = 0; angle < angles; angle++) {
|
for (let angle = 0; angle < angles; angle++) {
|
||||||
const theta = (angle * Math.PI) / angles;
|
const theta = (angle * Math.PI) / angles;
|
||||||
|
|
||||||
let projection = [];
|
let currentRawProjection = [];
|
||||||
for (let x = 0; x < width; x++) {
|
for (let x = 0; x < width; x++) {
|
||||||
const i = (angle * width + x) * 4;
|
const i = isVertical
|
||||||
projection.push(sinogramData[i]);
|
? (x * angles + angle) * 4 // transposed layout
|
||||||
}
|
: (angle * width + x) * 4; // normal layout
|
||||||
if (useFBP) {
|
|
||||||
projection = applyRampFilter(projection);
|
currentRawProjection.push(sinogramData[i]);
|
||||||
}
|
}
|
||||||
|
rawProjectionsFromSinogram.push(currentRawProjection); // Store the raw projection
|
||||||
|
|
||||||
|
// Apply the selected filter
|
||||||
|
const filteredProjection = applyFilter(
|
||||||
|
[...currentRawProjection],
|
||||||
|
filterType
|
||||||
|
); // Apply filter to a copy
|
||||||
|
allFilteredProjections.push(filteredProjection); // Store the filtered projection
|
||||||
|
|
||||||
for (let y = 0; y < size; y++) {
|
for (let y = 0; y < size; y++) {
|
||||||
for (let x = 0; x < size; x++) {
|
for (let x = 0; x < size; x++) {
|
||||||
const x0 = x - center;
|
const x0 = x - center;
|
||||||
const y0 = center - y; // flip y
|
const y0 = center - y;
|
||||||
const s = Math.round(
|
const s = Math.round(
|
||||||
x0 * Math.cos(theta) + y0 * Math.sin(theta) + width / 2
|
x0 * Math.cos(theta) + y0 * Math.sin(theta) + width / 2
|
||||||
);
|
);
|
||||||
if (s >= 0 && s < width) {
|
if (s >= 0 && s < width) {
|
||||||
accum[y * size + x] += projection[s];
|
accum[y * size + x] += filteredProjection[s]; // Use the filtered projection for accumulation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onFrame) {
|
if (onFrame) {
|
||||||
// normalize and draw current frame
|
|
||||||
let maxVal = 0;
|
let maxVal = 0;
|
||||||
for (let i = 0; i < accum.length; i++) {
|
for (let i = 0; i < accum.length; i++) {
|
||||||
if (accum[i] > maxVal) maxVal = accum[i];
|
if (accum[i] > maxVal) maxVal = accum[i];
|
||||||
@@ -154,13 +173,58 @@ export async function reconstructImageFromSinogram(
|
|||||||
imageData.data[i * 4 + 2] = b;
|
imageData.data[i * 4 + 2] = b;
|
||||||
imageData.data[i * 4 + 3] = 255;
|
imageData.data[i * 4 + 3] = 255;
|
||||||
}
|
}
|
||||||
|
|
||||||
outputCtx.putImageData(imageData, 0, 0);
|
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());
|
onFrame(angle, outputCanvas.toDataURL());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return outputCanvas.toDataURL();
|
// Create filtered sinogram image
|
||||||
|
const filteredSinogramCanvas = Object.assign(
|
||||||
|
document.createElement("canvas"),
|
||||||
|
{
|
||||||
|
width: isVertical ? angles : width, // Same dimensions as original sinogram
|
||||||
|
height: isVertical ? width : angles,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const filteredSinCtx = filteredSinogramCanvas.getContext("2d");
|
||||||
|
const filteredImgData = filteredSinCtx.createImageData(
|
||||||
|
filteredSinogramCanvas.width,
|
||||||
|
filteredSinogramCanvas.height
|
||||||
|
);
|
||||||
|
|
||||||
|
let maxFilteredVal = 0;
|
||||||
|
for (const proj of allFilteredProjections) {
|
||||||
|
for (const val of proj) {
|
||||||
|
if (val > maxFilteredVal) maxFilteredVal = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Avoid division by zero if maxFilteredVal is 0
|
||||||
|
if (maxFilteredVal === 0) maxFilteredVal = 1;
|
||||||
|
|
||||||
|
for (let angle = 0; angle < angles; angle++) {
|
||||||
|
const currentProjection = allFilteredProjections[angle];
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const val = (currentProjection[x] / maxFilteredVal) * 255; // Normalize and scale
|
||||||
|
const px = isVertical ? angle : x;
|
||||||
|
const py = isVertical ? x : angle;
|
||||||
|
const i = (py * filteredSinogramCanvas.width + px) * 4;
|
||||||
|
filteredImgData.data[i + 0] = val;
|
||||||
|
filteredImgData.data[i + 1] = val;
|
||||||
|
filteredImgData.data[i + 2] = val;
|
||||||
|
filteredImgData.data[i + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filteredSinCtx.putImageData(filteredImgData, 0, 0);
|
||||||
|
const filteredSinogramUrl = filteredSinogramCanvas.toDataURL();
|
||||||
|
|
||||||
|
return {
|
||||||
|
reconstructedUrl: outputCanvas.toDataURL(),
|
||||||
|
filteredSinogramUrl: filteredSinogramUrl,
|
||||||
|
rawProjectionsFromSinogram: rawProjectionsFromSinogram,
|
||||||
|
allFilteredProjections: allFilteredProjections,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Heatmap mapping: blue → green → yellow → red
|
// Heatmap mapping: blue → green → yellow → red
|
||||||
7
static.json
Normal file
7
static.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"root": "public",
|
||||||
|
"clean_urls": true,
|
||||||
|
"routes": {
|
||||||
|
"/**": "index.html"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user