diff --git a/public/UploadImageComponent.js b/public/UploadImageComponent.js index 8872700..9424312 100644 --- a/public/UploadImageComponent.js +++ b/public/UploadImageComponent.js @@ -18,6 +18,9 @@ export const UploadImageComponent = { "https://upload.wikimedia.org/wikipedia/commons/e/e5/Shepp_logan.png", reconstructionFrames: [], currentFrameIndex: 0, + manualAngleIndex: 0, // For the new angle control slider + sinogramImageElement: null, // To store the sinogram img DOM element + sinogramHighlightCanvas: null, // Canvas for sinogram highlight renderMode: "grayscale", // or "heatmap" filterType: "ramp", // Added to manage selected filter @@ -46,6 +49,87 @@ export const UploadImageComponent = { ctx.stroke(); }, + drawSinogramHighlight(angleIndex) { + if ( + !this.sinogramHighlightCanvas || + !this.sinogramImageElement || + !this.sinogramImageElement.complete || + this.sinogramImageElement.naturalWidth === 0 + ) { + if (this.sinogramHighlightCanvas) { + const ctx = this.sinogramHighlightCanvas.getContext("2d"); + ctx.clearRect( + 0, + 0, + this.sinogramHighlightCanvas.width, + this.sinogramHighlightCanvas.height + ); + } + return; + } + + const canvas = this.sinogramHighlightCanvas; + const ctx = canvas.getContext("2d"); + const dispWidth = this.sinogramImageElement.width; // Displayed width of sinogram image + const dispHeight = this.sinogramImageElement.height; // Displayed height of sinogram image + const naturalW = this.sinogramImageElement.naturalWidth; + const naturalH = this.sinogramImageElement.naturalHeight; + + if (canvas.width !== dispWidth) canvas.width = dispWidth; + if (canvas.height !== dispHeight) canvas.height = dispHeight; + + ctx.clearRect(0, 0, dispWidth, dispHeight); + + if (this.angleCount <= 0 || naturalW === 0 || naturalH === 0) return; + + let anglesAlongImageHeight = true; + + // Infer orientation based on natural dimensions of the sinogram image vs angleCount + // This logic assumes that one of the dimensions of the *natural* sinogram image data + // (before it's scaled for display) corresponds to this.angleCount. + if (naturalW === this.angleCount && naturalH !== this.angleCount) { + anglesAlongImageHeight = false; // Angles are columns, highlight is vertical + } else if (naturalH === this.angleCount && naturalW !== this.angleCount) { + anglesAlongImageHeight = true; // Angles are rows, highlight is horizontal + } else if (naturalH === this.angleCount && naturalW === this.angleCount) { + // Square sinogram (angleCount might equal detector_size), assume angles along height by convention + anglesAlongImageHeight = true; + } + // If neither naturalW nor naturalH strictly equals angleCount (and the other doesn't), + // we stick with the default assumption (anglesAlongImageHeight = true). + // This can happen if the sinogram image itself is generated with an aspect ratio + // where neither dimension is exactly `this.angleCount` (e.g. if `generateSinogram` in sinogram.js + // has `size` not equal to `angles` and the output canvas is `size` x `angles` or `angles` x `size`). + // The key is that `this.angleCount` represents the number of projection angles. + + ctx.strokeStyle = "rgba(0, 255, 0, 0.7)"; // Green highlight + + if (anglesAlongImageHeight) { + // Angles are effectively rows in the displayed sinogram image. + // The number of such rows is this.angleCount. + // We need to highlight the row corresponding to `angleIndex`. + const highlightRowHeight = dispHeight / this.angleCount; + const yPos = (angleIndex + 0.5) * highlightRowHeight; + + ctx.lineWidth = Math.max(1, highlightRowHeight * 0.7); + ctx.beginPath(); + ctx.moveTo(0, yPos); + ctx.lineTo(dispWidth, yPos); + ctx.stroke(); + } else { + // Angles are effectively columns in the displayed sinogram image. + // The number of such columns is this.angleCount. + const highlightColWidth = dispWidth / this.angleCount; + const xPos = (angleIndex + 0.5) * highlightColWidth; + + ctx.lineWidth = Math.max(1, highlightColWidth * 0.7); + ctx.beginPath(); + ctx.moveTo(xPos, 0); + ctx.lineTo(xPos, dispHeight); + ctx.stroke(); + } + }, + isOverlayReady() { return ( this.overlayCanvas && @@ -60,6 +144,7 @@ export const UploadImageComponent = { this.sinogramUrl = "loading"; this.filteredSinogramUrl = null; // Reset filtered sinogram this.reconstructedUrl = null; + this.manualAngleIndex = 0; // Reset manual angle on new image m.redraw(); this.imageUrl = url; @@ -216,29 +301,108 @@ export const UploadImageComponent = { class: "w-full", oninput: (e) => { this.angleCount = parseInt(e.target.value, 10); + this.manualAngleIndex = 0; // Reset manual angle if angleCount changes + // Redraw overlay for new manualAngleIndex=0 with new angleCount + const newTheta = (0 * Math.PI) / this.angleCount; // Theta for index 0 + this.drawAngleOverlay(newTheta); + if (this.sinogramHighlightCanvas) { + // If sinogram highlight exists, update it too + this.drawSinogramHighlight(this.manualAngleIndex); + } this.loadAndProcessDebounced(this.imageUrl); // reprocess with new angle count }, }), ]), + // Manual Angle Slider for Preview + m("div", { class: "mt-4 w-full max-w-md" }, [ + m( + "label", + { class: "block text-sm font-medium text-gray-700 mb-1" }, + `Preview Angle: ${this.manualAngleIndex} (θ ≈ ${( + (this.manualAngleIndex * 180) / + this.angleCount + ).toFixed(1)}°)` + ), + m("input", { + type: "range", + min: 0, + max: Math.max(0, this.angleCount - 1), // Ensure max is not negative if angleCount is 0 or 1 + value: this.manualAngleIndex, + step: 1, + class: "w-full", + disabled: !this.hasLoadedInitialImage, // Disable if no image loaded + oninput: (e) => { + this.manualAngleIndex = parseInt(e.target.value, 10); + const theta = (this.manualAngleIndex * Math.PI) / this.angleCount; + this.drawAngleOverlay(theta); + this.drawSinogramHighlight(this.manualAngleIndex); // Update sinogram highlight + }, + }), + ]), + m(StepAccordion, { index: 2 }), // Sinogram this.sinogramUrl && - m("div", { class: "mt-10 w-full max-w-md text-center" }, [ + m("div", { class: "mt-10 w-full max-w-md text-center relative" }, [ m( "h2", { class: "text-xl font-semibold text-gray-700 mb-4" }, "Generated Sinogram" ), - this.sinogramUrl === "loading" - ? m("p", "Processing...") - : m("img", { - src: this.sinogramUrl, - alt: "Sinogram", - class: "rounded shadow max-w-full h-auto mx-auto", - style: "width: 100%; max-height: 300px;", - }), + m("div", { class: "relative" }, [ + this.sinogramUrl === "loading" + ? m("p", "Processing...") + : m("img", { + src: this.sinogramUrl, + alt: "Sinogram", + class: "rounded shadow max-w-full h-auto mx-auto", + style: "width: 100%; max-height: 300px;", + onload: ({ target }) => { + this.sinogramImageElement = target; + // Set initial dimensions for highlight canvas based on loaded image + if (this.sinogramHighlightCanvas) { + this.sinogramHighlightCanvas.width = target.width; + this.sinogramHighlightCanvas.height = target.height; + } + this.drawSinogramHighlight(this.manualAngleIndex); // Draw initial highlight + }, + onerror: () => { + // If sinogram fails to load, clear element + this.sinogramImageElement = null; + this.drawSinogramHighlight(this.manualAngleIndex); // Attempt to clear highlight + }, + }), + // Sinogram Highlight Canvas + m("canvas", { + style: + "position:absolute; top:0; left:0; pointer-events:none; max-width: 100%; max-height: 300px;", + oncreate: ({ dom }) => { + this.sinogramHighlightCanvas = dom; + // Ensure dimensions are set if image is already loaded by now (race condition) + if ( + this.sinogramImageElement && + this.sinogramImageElement.complete + ) { + dom.width = this.sinogramImageElement.width; + dom.height = this.sinogramImageElement.height; + this.drawSinogramHighlight(this.manualAngleIndex); + } + }, + // Set width/height to 0 initially or based on a placeholder if needed + width: this.sinogramImageElement + ? this.sinogramImageElement.width + : this.overlayCanvas + ? this.overlayCanvas.width + : 300, // Default or match other canvas + height: this.sinogramImageElement + ? this.sinogramImageElement.height + : this.overlayCanvas + ? this.overlayCanvas.height + : 300, + }), + ]), ]), // Filtered Sinogram Display