From 65ffcb767fb96ec9adf5d0b245035aad1cfb741a Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Thu, 22 May 2025 21:58:50 +1000 Subject: [PATCH] Add raw and filtered projection graphs --- public/ProjectionGraphComponent.js | 160 +++++++++++++++++++++++++++++ public/UploadImageComponent.js | 148 +++++++++++++++++++------- public/sinogram.js | 22 ++-- 3 files changed, 288 insertions(+), 42 deletions(-) create mode 100644 public/ProjectionGraphComponent.js diff --git a/public/ProjectionGraphComponent.js b/public/ProjectionGraphComponent.js new file mode 100644 index 0000000..4982ab7 --- /dev/null +++ b/public/ProjectionGraphComponent.js @@ -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", + }), + ]), + ]), + ] + ); + }, +}; diff --git a/public/UploadImageComponent.js b/public/UploadImageComponent.js index b21eb3f..6b4a61d 100644 --- a/public/UploadImageComponent.js +++ b/public/UploadImageComponent.js @@ -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)"), - ] - ), - ]), ] ), diff --git a/public/sinogram.js b/public/sinogram.js index 479a25e..8c9fbb7 100644 --- a/public/sinogram.js +++ b/public/sinogram.js @@ -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, }; }