Add raw and filtered projection graphs

This commit is contained in:
Peter Stockings
2025-05-22 21:58:50 +10:00
parent bcb7f1bce3
commit 65ffcb767f
3 changed files with 288 additions and 42 deletions

View 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",
}),
]),
]),
]
);
},
};

View File

@@ -5,6 +5,7 @@ import {
} from "./sinogram.js";
import { StepAccordion } from "./CTAccordion .js";
import { ProjectionGraphComponent } from "./ProjectionGraphComponent.js";
export const UploadImageComponent = {
hasLoadedInitialImage: false,
@@ -21,6 +22,10 @@ export const UploadImageComponent = {
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
@@ -134,6 +139,10 @@ export const UploadImageComponent = {
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;
@@ -151,11 +160,20 @@ export const UploadImageComponent = {
if (img) this.imageElement = img;
}
this.sinogramUrl = await generateSinogram(
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 = [];
@@ -181,6 +199,20 @@ export const UploadImageComponent = {
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();
},
@@ -473,9 +505,87 @@ export const UploadImageComponent = {
(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"),
]
),
]),
]
),
@@ -665,7 +775,7 @@ export const UploadImageComponent = {
m(
"div",
{
class: "mt-6 grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4",
class: "mt-6",
},
[
m("div", [
@@ -698,40 +808,6 @@ export const UploadImageComponent = {
]
),
]),
m("div", [
m(
"label",
{
for: "filterTypeSelect",
class: "block text-sm font-medium text-gray-700 mb-1",
},
"Filter Type (Reconstruction):"
),
m(
"select",
{
id: "filterTypeSelect",
class:
"mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm rounded-md shadow-sm",
value: this.filterType,
onchange: (e) => {
this.filterType = e.target.value;
this.loadAndProcessDebounced(
this.imageUrl,
this.imageUrl !== this.defaultImageUrl
);
},
},
[
m("option", { value: "ramp" }, "Ramp (Ram-Lak)"),
m("option", { value: "shepp-logan" }, "Shepp-Logan"),
m("option", { value: "cosine" }, "Cosine"),
m("option", { value: "hamming" }, "Hamming"),
m("option", { value: "hann" }, "Hann"),
m("option", { value: "none" }, "None (Unfiltered)"),
]
),
]),
]
),

View File

@@ -69,7 +69,10 @@ export async function generateSinogram(
}
sinCtx.putImageData(imgData, 0, 0);
return sinogramCanvas.toDataURL();
return {
sinogramUrl: sinogramCanvas.toDataURL(),
projections: projections, // Return the raw projections
};
}
export async function reconstructImageFromSinogram(
@@ -113,23 +116,28 @@ export async function reconstructImageFromSinogram(
const accum = new Float32Array(size * size);
const center = size / 2;
const rawProjectionsFromSinogram = []; // To store raw projections extracted from sinogram image
const allFilteredProjections = []; // To store filtered projections
for (let angle = 0; angle < angles; angle++) {
const theta = (angle * Math.PI) / angles;
let projection = [];
let currentRawProjection = [];
for (let x = 0; x < width; x++) {
const i = isVertical
? (x * angles + angle) * 4 // transposed layout
: (angle * width + x) * 4; // normal layout
projection.push(sinogramData[i]);
currentRawProjection.push(sinogramData[i]);
}
rawProjectionsFromSinogram.push(currentRawProjection); // Store the raw projection
// Apply the selected filter
projection = applyFilter(projection, filterType);
allFilteredProjections.push(projection); // Store the filtered projection
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 x = 0; x < size; x++) {
@@ -139,7 +147,7 @@ export async function reconstructImageFromSinogram(
x0 * Math.cos(theta) + y0 * Math.sin(theta) + width / 2
);
if (s >= 0 && s < width) {
accum[y * size + x] += projection[s];
accum[y * size + x] += filteredProjection[s]; // Use the filtered projection for accumulation
}
}
}
@@ -214,6 +222,8 @@ export async function reconstructImageFromSinogram(
return {
reconstructedUrl: outputCanvas.toDataURL(),
filteredSinogramUrl: filteredSinogramUrl,
rawProjectionsFromSinogram: rawProjectionsFromSinogram,
allFilteredProjections: allFilteredProjections,
};
}