Add raw and filtered projection graphs
This commit is contained in:
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",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from "./sinogram.js";
|
} from "./sinogram.js";
|
||||||
|
|
||||||
import { StepAccordion } from "./CTAccordion .js";
|
import { StepAccordion } from "./CTAccordion .js";
|
||||||
|
import { ProjectionGraphComponent } from "./ProjectionGraphComponent.js";
|
||||||
|
|
||||||
export const UploadImageComponent = {
|
export const UploadImageComponent = {
|
||||||
hasLoadedInitialImage: false,
|
hasLoadedInitialImage: false,
|
||||||
@@ -21,6 +22,10 @@ export const UploadImageComponent = {
|
|||||||
manualAngleIndex: 0, // For the new angle control slider
|
manualAngleIndex: 0, // For the new angle control slider
|
||||||
sinogramImageElement: null, // To store the sinogram img DOM element
|
sinogramImageElement: null, // To store the sinogram img DOM element
|
||||||
sinogramHighlightCanvas: null, // Canvas for sinogram highlight
|
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"
|
renderMode: "grayscale", // or "heatmap"
|
||||||
filterType: "ramp", // Added to manage selected filter
|
filterType: "ramp", // Added to manage selected filter
|
||||||
|
|
||||||
@@ -134,6 +139,10 @@ export const UploadImageComponent = {
|
|||||||
this.filteredSinogramUrl = null;
|
this.filteredSinogramUrl = null;
|
||||||
this.reconstructedUrl = "loading"; // Indicate reconstruction is also loading
|
this.reconstructedUrl = "loading"; // Indicate reconstruction is also loading
|
||||||
this.manualAngleIndex = 0;
|
this.manualAngleIndex = 0;
|
||||||
|
this.rawProjections = [];
|
||||||
|
this.filteredProjections = [];
|
||||||
|
this.currentRawProjectionData = null;
|
||||||
|
this.currentFilteredProjectionData = null;
|
||||||
m.redraw();
|
m.redraw();
|
||||||
|
|
||||||
let finalUrl = url;
|
let finalUrl = url;
|
||||||
@@ -151,11 +160,20 @@ export const UploadImageComponent = {
|
|||||||
if (img) this.imageElement = img;
|
if (img) this.imageElement = img;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sinogramUrl = await generateSinogram(
|
const sinogramResult = await generateSinogram(
|
||||||
finalUrl,
|
finalUrl,
|
||||||
this.angleCount,
|
this.angleCount,
|
||||||
this.drawAngleOverlay.bind(this)
|
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();
|
m.redraw();
|
||||||
|
|
||||||
this.reconstructionFrames = [];
|
this.reconstructionFrames = [];
|
||||||
@@ -181,6 +199,20 @@ export const UploadImageComponent = {
|
|||||||
|
|
||||||
this.reconstructedUrl = reconstructionResult.reconstructedUrl;
|
this.reconstructedUrl = reconstructionResult.reconstructedUrl;
|
||||||
this.filteredSinogramUrl = reconstructionResult.filteredSinogramUrl;
|
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();
|
m.redraw();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -473,9 +505,87 @@ export const UploadImageComponent = {
|
|||||||
(this.manualAngleIndex * Math.PI) / (this.angleCount || 1);
|
(this.manualAngleIndex * Math.PI) / (this.angleCount || 1);
|
||||||
this.drawAngleOverlay(theta);
|
this.drawAngleOverlay(theta);
|
||||||
this.drawSinogramHighlight(this.manualAngleIndex);
|
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(
|
m(
|
||||||
"div",
|
"div",
|
||||||
{
|
{
|
||||||
class: "mt-6 grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4",
|
class: "mt-6",
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
m("div", [
|
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)"),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|||||||
@@ -69,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(
|
||||||
@@ -113,23 +116,28 @@ 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 rawProjectionsFromSinogram = []; // To store raw projections extracted from sinogram image
|
||||||
const allFilteredProjections = []; // To store filtered projections
|
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 = isVertical
|
const i = isVertical
|
||||||
? (x * angles + angle) * 4 // transposed layout
|
? (x * angles + angle) * 4 // transposed layout
|
||||||
: (angle * width + x) * 4; // normal 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
|
// Apply the selected filter
|
||||||
projection = applyFilter(projection, filterType);
|
const filteredProjection = applyFilter(
|
||||||
allFilteredProjections.push(projection); // Store the filtered projection
|
[...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++) {
|
||||||
@@ -139,7 +147,7 @@ export async function reconstructImageFromSinogram(
|
|||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,6 +222,8 @@ export async function reconstructImageFromSinogram(
|
|||||||
return {
|
return {
|
||||||
reconstructedUrl: outputCanvas.toDataURL(),
|
reconstructedUrl: outputCanvas.toDataURL(),
|
||||||
filteredSinogramUrl: filteredSinogramUrl,
|
filteredSinogramUrl: filteredSinogramUrl,
|
||||||
|
rawProjectionsFromSinogram: rawProjectionsFromSinogram,
|
||||||
|
allFilteredProjections: allFilteredProjections,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user