From 88fb43b859fdb06eeaab1ffbded1974594c32445 Mon Sep 17 00:00:00 2001 From: WYVERN2742 <35181365+WYVERN2742@users.noreply.github.com> Date: Thu, 19 Sep 2024 16:05:56 +0100 Subject: [PATCH] feat: :sparkles: Transmission Histogram Capture: - Added support for any button to trigger page update and become set to loading - Added -45/+45 quick rotation buttons Projection preview settings: - Added transmission histogram, with red highlight for <5% transmission - This took far too long due to a scaling bug in chart.js - Fixed raw / log preview settings, they now work correctly. - Added "Transmission Hotspot" setting to highlight areas with <5% transmission - Removed not-implemented gamma setting - This would require a rewrite of the projection preview system to implement well. (e.g using the client to render images, and replacing all images with editable canvases, and perform image processing in js) --- webct/blueprints/app/static/js/app.ts | 16 +++ webct/blueprints/capture/static/js/capture.ts | 25 +++- .../capture/templates/tab.capture.html.j2 | 4 + .../blueprints/detector/static/js/detector.ts | 2 +- webct/blueprints/preview/routes.py | 37 ++++- webct/blueprints/preview/static/js/sim/api.ts | 20 ++- .../preview/static/js/sim/projection.ts | 47 ++++-- .../blueprints/preview/static/js/sim/types.ts | 134 +++++++++++++++++- .../preview/static/scss/previews.scss | 32 ++++- .../preview/templates/preview.pane.html.j2 | 29 ++-- 10 files changed, 311 insertions(+), 35 deletions(-) diff --git a/webct/blueprints/app/static/js/app.ts b/webct/blueprints/app/static/js/app.ts index 93fbf7f..fd497f4 100644 --- a/webct/blueprints/app/static/js/app.ts +++ b/webct/blueprints/app/static/js/app.ts @@ -212,6 +212,14 @@ function setPageLoading(loading: boolean, type: LoadingType = "default", source: button.setAttribute("loading", "true"); } + // special buttons that also update the page, but are not dedicated to that feature. + // e.g quick-fire sample rotate buttons + let miscButtons = document.getElementsByClassName("button-loadonupdate"); + for (let index = 0; index < miscButtons.length; index++) { + const button = miscButtons[index]; + button.setAttribute("loading", "true") + } + document.getElementsByTagName("body")[0].style.cursor = "wait"; LoadingBar.setAttribute("variant", type); @@ -259,6 +267,14 @@ function setPageLoading(loading: boolean, type: LoadingType = "default", source: button.removeAttribute("loading"); } + // special buttons that also update the page, but are not dedicated to that feature. + // e.g quick-fire sample rotate buttons + let miscButtons = document.getElementsByClassName("button-loadonupdate"); + for (let index = 0; index < miscButtons.length; index++) { + const button = miscButtons[index]; + button.removeAttribute("loading"); + } + document.getElementsByTagName("body")[0].style.cursor = ""; LoadingBar.setAttribute("hidden", "true"); } diff --git a/webct/blueprints/capture/static/js/capture.ts b/webct/blueprints/capture/static/js/capture.ts index 80f0c54..9d1f67f 100644 --- a/webct/blueprints/capture/static/js/capture.ts +++ b/webct/blueprints/capture/static/js/capture.ts @@ -3,13 +3,14 @@ * @author Iwan Mitchell */ -import { SlDropdown, SlInput, SlRange, SlSelect } from "@shoelace-style/shoelace"; +import { SlButton, SlDropdown, SlInput, SlRange, SlSelect } from "@shoelace-style/shoelace"; import { AlertType, showAlert } from "../../../base/static/js/base"; import { PanePixelSizeElement, PaneWidthElement } from "../../../detector/static/js/detector"; import { CaptureResponseRegistry, processResponse, requestCaptureData, sendCaptureData, prepareRequest, requestCapturePreview } from "./api"; import { CaptureConfigError, CaptureRequestError, showError } from "./errors"; import { CapturePreview, CaptureProperties } from "./types"; import { validateProjections } from "./validation"; +import { UpdatePage } from "../../../app/static/js/app"; // ====================================================== // // ================== Document Elements ================= // @@ -26,6 +27,8 @@ let DetectorPosZElement: SlInput; let SampleRotateXElement: SlInput; let SampleRotateYElement: SlInput; let SampleRotateZElement: SlInput; +let ButtonRotateClock45Element: SlButton; +let ButtonRotateCounterClock45Element: SlButton; let PreviewImages: NodeListOf; let PreviewOverlays: NodeListOf; @@ -65,6 +68,9 @@ export function setupCapture(): boolean { const sample_rotatey_element = document.getElementById("inputSampleRotateY"); const sample_rotatez_element = document.getElementById("inputSampleRotateZ"); + const sample_rotate_counter_clock_45_element = document.getElementById("buttonSampleRotateCounterClock45"); + const sample_rotate_clock_45_element = document.getElementById("buttonSampleRotateClock45"); + const range_nyquist = document.getElementById("rangeNyquist"); if (total_rotation_element == null || @@ -75,6 +81,8 @@ export function setupCapture(): boolean { sample_rotatex_element == null || sample_rotatey_element == null || sample_rotatez_element == null || + sample_rotate_counter_clock_45_element == null || + sample_rotate_clock_45_element == null || detector_posx_element == null || detector_posy_element == null || detector_posz_element == null || @@ -94,6 +102,8 @@ export function setupCapture(): boolean { console.log(sample_rotatex_element); console.log(sample_rotatey_element); console.log(sample_rotatez_element); + console.log(sample_rotate_clock_45_element); + console.log(sample_rotate_counter_clock_45_element); console.log(range_nyquist); showAlert("Capture setup failure", AlertType.ERROR); @@ -116,6 +126,8 @@ export function setupCapture(): boolean { SampleRotateXElement = sample_rotatex_element as SlInput; SampleRotateYElement = sample_rotatey_element as SlInput; SampleRotateZElement = sample_rotatez_element as SlInput; + ButtonRotateClock45Element = sample_rotate_clock_45_element as SlButton; + ButtonRotateCounterClock45Element = sample_rotate_counter_clock_45_element as SlButton; // Workaround hack to deal with styling annoying shadowroot classes Array.prototype.slice.call(document.getElementsByTagName("sl-dropdown")).forEach((dropdown: SlDropdown) => { @@ -152,6 +164,17 @@ export function setupCapture(): boolean { }); } + ButtonRotateClock45Element.onclick = () => { + SampleRotateZElement.value = (parseFloat(SampleRotateZElement.value) + 45) % 360 + ""; + UpdatePage(); + }; + ButtonRotateCounterClock45Element.onclick = () => { + let rot = (parseFloat(SampleRotateZElement.value) - 45) + if (rot < 0) { rot += 360 } + SampleRotateZElement.value = rot + ""; + UpdatePage(); + }; + validateCapture(); SetOverlaySize(300, 300); return true; diff --git a/webct/blueprints/capture/templates/tab.capture.html.j2 b/webct/blueprints/capture/templates/tab.capture.html.j2 index a7b9664..b480422 100644 --- a/webct/blueprints/capture/templates/tab.capture.html.j2 +++ b/webct/blueprints/capture/templates/tab.capture.html.j2 @@ -36,6 +36,10 @@ + + -45° + +45° + diff --git a/webct/blueprints/detector/static/js/detector.ts b/webct/blueprints/detector/static/js/detector.ts index a0f1e33..3042a1b 100644 --- a/webct/blueprints/detector/static/js/detector.ts +++ b/webct/blueprints/detector/static/js/detector.ts @@ -8,7 +8,7 @@ import { AlertType, showAlert } from "../../../base/static/js/base"; import { SetPreviewSize } from "../../../preview/static/js/sim/projection"; import { DetectorResponseRegistry, prepareRequest, processResponse, requestDetectorData, sendDetectorData } from "./api"; import { DetectorConfigError, DetectorRequestError, showError } from "./errors"; -import { DetectorProperties, EnergyResponseData, EnergyResponseDisplay, LSF, LSFDisplay, LSFParseEnum, ScintillatorMaterial } from "./types"; +import { DetectorProperties, EnergyResponseDisplay, LSF, LSFDisplay, LSFParseEnum, ScintillatorMaterial } from "./types"; import { validateHeight, validatePixel, validateWidth } from "./validation"; // ====================================================== // diff --git a/webct/blueprints/preview/routes.py b/webct/blueprints/preview/routes.py index cf98a84..147a05b 100644 --- a/webct/blueprints/preview/routes.py +++ b/webct/blueprints/preview/routes.py @@ -1,4 +1,6 @@ +from base64 import b64encode from datetime import datetime +import io from typing import List import logging as log @@ -24,6 +26,26 @@ def saveGif(array: np.ndarray) -> None: images[0].save("projections.gif", "GIF", append_images=images[1:], duration=10, loop=0) +def getHistImage(array:np.ndarray, bins:List[float]) -> str: + # create a mask of pixels < bin[5] + # 0 - 1 - 2 - 3 - 4 - 5 - 6 + mask = array < bins[5] + + # create rgb image + array = (array - array.min()) / (array.max() - array.min()) + array = (array * 255).astype("uint8") + array = np.stack([array, array, array], axis=2) + # set red channel of mask to 255, other channels to 0 + array[mask, 0] = 255 + array[mask, 1] = 0 + array[mask, 2] = 0 + + # create png and base64 via bytestream + byteStream = io.BytesIO() + img = Image.fromarray(array, mode="RGB") + img.save(byteStream, "PNG") + byteStream.seek(0) + return str(b64encode(byteStream.read()))[2:-1] @bp.route("/sim/preview/get") def getPreviews() -> Response: @@ -31,8 +53,14 @@ def getPreviews() -> Response: sim = Sim(session) projection = sim.projection(Quality.MEDIUM) + log_projection = np.log(projection) + + hist, bins = sim.transmission_histogram() + histimgstr = getHistImage(projection, bins) + log.info(f"[{sim._sid}] Encoding projection preview") projectionstr = asPngStr(projection) + log_projectionstr = asPngStr(log_projection) layout = sim.layout() log.info(f"[{sim._sid}] Encoding layout preview") @@ -46,9 +74,16 @@ def getPreviews() -> Response: { "time": f"{(then-datetime.now()).total_seconds()}", "projection": { - "image": projectionstr, + "image": { + "raw": projectionstr, + "log": log_projectionstr, + }, "height": projection.shape[0], "width": projection.shape[1], + "transmission": { + "hist": hist, + "image": histimgstr, + } }, "layout": { "image": layoutstr, diff --git a/webct/blueprints/preview/static/js/sim/api.ts b/webct/blueprints/preview/static/js/sim/api.ts index bf775ba..1d2e097 100644 --- a/webct/blueprints/preview/static/js/sim/api.ts +++ b/webct/blueprints/preview/static/js/sim/api.ts @@ -35,9 +35,16 @@ export interface SimResponseRegistry { simResponse: { time:number, projection: { - image:string, + image: { + raw:string, + log:string, + }, height:number, - width:number + width:number, + transmission: { + hist:number[], + image:string, + } }, layout: { image:string, @@ -74,9 +81,16 @@ export function processResponse(data: SimResponseRegistry["simResponse"]): Previ const preview: PreviewData = { time: data.time, projection: { - image: data.projection.image, + image: { + raw: data.projection.image.raw, + log: data.projection.image.log + }, height: data.projection.height, width: data.projection.width, + transmission: { + hist: data.projection.transmission.hist, + image: data.projection.transmission.image + }, }, layout: { image: data.layout.image, diff --git a/webct/blueprints/preview/static/js/sim/projection.ts b/webct/blueprints/preview/static/js/sim/projection.ts index 4e99bdb..705c095 100644 --- a/webct/blueprints/preview/static/js/sim/projection.ts +++ b/webct/blueprints/preview/static/js/sim/projection.ts @@ -1,7 +1,7 @@ import { SlButton, SlCheckbox, SlRadio } from "@shoelace-style/shoelace"; import { processResponse, requestProjection, SimResponseRegistry } from "./api"; import { ProjectionRequestError, showError } from "./errors"; -import { PreviewData } from "./types"; +import { PreviewData, TransmissionDisplay } from "./types"; // import type { Buffer } from "buffer"; // ====================================================== // @@ -11,11 +11,14 @@ import { PreviewData } from "./types"; let ButtonPreviewProjection: SlButton; let ButtonPreviewLayout: SlButton; let PreviewPane: HTMLDivElement; +let TransmissionCanvas: HTMLCanvasElement; let SettingRawElement: SlRadio; let SettingLogElement: SlRadio; +let SettingTransmissionElement:SlRadio; let SettingInvertElement: SlCheckbox; + // ====================================================== // // ====================================================== // // ====================================================== // @@ -27,6 +30,8 @@ let SceneImages: NodeListOf; let PreviewData: PreviewData; let PreviewPaneImage: HTMLImageElement; +let TransmissionChart: TransmissionDisplay; + export function MarkLoading(): void { for (let index = 0; index < PreviewImages.length; index++) { const image = PreviewImages[index]; @@ -61,6 +66,7 @@ export function updateProjection(): Promise { result.then((result: unknown) => { PreviewData = processResponse(result as SimResponseRegistry["simResponse"]); + console.log(PreviewData); updateImageDisplay(); for (let index = 0; index < PreviewImages.length; index++) { @@ -81,9 +87,12 @@ export function updateProjection(): Promise { } function updateImageDisplay(): void { + TransmissionChart = new TransmissionDisplay(PreviewData, TransmissionCanvas) + TransmissionChart.displayTransmission(); + for (let index = 0; index < PreviewImages.length; index++) { const image = PreviewImages[index]; - image.src = "data:image/png;base64," + PreviewData.projection.image; + // image.src = "data:image/png;base64," + PreviewData.projection.image; if (SettingInvertElement.checked) { window.dispatchEvent(new CustomEvent("invertOn",{bubbles:true, cancelable:false})); @@ -91,19 +100,17 @@ function updateImageDisplay(): void { window.dispatchEvent(new CustomEvent("invertOff",{bubbles:true, cancelable:false})); } - // if (!SettingInvertElement.checked) { - // if (SettingLogElement.checked) { - // image.style.backgroundImage = "url('" + "data:image/png;base64," + Images.imagelog.substring(2, Images.imagelog.length - 1) + "')"; - // } else { - // image.style.backgroundImage = "url('" + "data:image/png;base64," + Images.image.substring(2, Images.image.length - 1) + "')"; - // } - // } else { - // if (SettingLogElement.checked) { - // image.style.backgroundImage = "url('" + "data:image/png;base64," + Images.imageloginv.substring(2, Images.imageloginv.length - 1) + "')"; - // } else { - // image.style.backgroundImage = "url('" + "data:image/png;base64," + Images.imageinv.substring(2, Images.imageinv.length - 1) + "')"; - // } - // } + if (SettingRawElement.checked) { + image.src = "data:image/png;base64," + PreviewData.projection.image.raw; + } else if (SettingLogElement.checked) { + image.src = "data:image/png;base64," + PreviewData.projection.image.log; + } + + if (SettingTransmissionElement.checked) { + image.src = "data:image/png;base64," + PreviewData.projection.transmission.image; + } else if (SettingTransmissionElement.checked) { + image.src = "data:image/png;base64," + PreviewData.projection.transmission.image; + } } for (let index = 0; index < LayoutImages.length; index++) { const image = LayoutImages[index]; @@ -120,6 +127,7 @@ export function setupPreview(): void { PreviewImages = document.querySelectorAll("img.image-projection") as NodeListOf; LayoutImages = document.querySelectorAll("img.image-layout") as NodeListOf; SceneImages = document.querySelectorAll("img.image-scene") as NodeListOf; + TransmissionCanvas = document.getElementById("previewTransmissionGraph") as HTMLCanvasElement; // Projection image of the preview pane. Supports live updates when changing detector sizes. PreviewPaneImage = document.querySelector("#previewImage>img.image-projection") as HTMLImageElement; @@ -132,12 +140,14 @@ export function setupPreview(): void { ButtonPreviewProjection.variant = "default"; PreviewPane.setAttribute("selected", "layout"); SettingsDiv.setAttribute("selected", "layout"); + TransmissionCanvas.parentElement?.setAttribute("selected", "layout"); }; ButtonPreviewProjection.onclick = () => { ButtonPreviewLayout.variant = "default"; ButtonPreviewProjection.variant = "primary"; PreviewPane.setAttribute("selected", "projection"); SettingsDiv.setAttribute("selected", "projection"); + TransmissionCanvas.parentElement?.setAttribute("selected", "projection"); }; const SettingsDiv = document.getElementById("settingsPane") as HTMLDivElement; @@ -153,12 +163,19 @@ export function setupPreview(): void { SettingLogElement.onclick = () => { updateImageDisplay(); }; + SettingTransmissionElement = document.getElementById("radioTransmissionProjectionSetting") as SlRadio; + SettingTransmissionElement.onclick = () => { + updateImageDisplay(); + }; console.log(SettingsDiv); const PreviewSettingsButton = document.querySelector("#settingsPane > button") as HTMLButtonElement; PreviewSettingsButton.onclick = () => { SettingsDiv.toggleAttribute("active"); + + // ! workaround for chart resize issues inside flexboxes with chart.js + updateImageDisplay(); }; for (let index = 0; index < PreviewImages.length; index++) { diff --git a/webct/blueprints/preview/static/js/sim/types.ts b/webct/blueprints/preview/static/js/sim/types.ts index 42c8377..faae937 100644 --- a/webct/blueprints/preview/static/js/sim/types.ts +++ b/webct/blueprints/preview/static/js/sim/types.ts @@ -1,9 +1,21 @@ +import { ChartOptions } from "chart.js"; +import { Chart } from "chart.js"; +import { colors } from "../../../../base/static/js/colors"; +//! Chart.js elements must already be registered with Chart.register(...registerables) + export interface PreviewData { time:number, projection: { - image:string, + image: { + raw:string, + log:string, + }, height:number, - width:number + width:number, + transmission: { + hist:number[], + image:string + } }, layout: { image:string, @@ -16,3 +28,121 @@ export interface PreviewData { width:number } } + +/** + * Spectra display class linked to spectra data and a canvas. + */ +export class TransmissionDisplay { + readonly previewData: PreviewData + readonly canvas: HTMLCanvasElement + _chart?: Chart; + + constructor(previewData: PreviewData, canvas: HTMLCanvasElement) { + this.previewData = previewData; + this.canvas = canvas; + + // Obtain a chart item if it already exists on the given canvas. + if (Chart.getChart(this.canvas) !== undefined) { + this._chart = Chart.getChart(this.canvas); + } + } + + public displayTransmission(): void { + + let title = "Image Transmission"; + let label = "Transmission"; + + const chartOptions: ChartOptions = { + indexAxis: 'y', + responsive: true, + maintainAspectRatio: false, + scales: { + y: { + // afterBuildTicks: axis => axis.ticks = [0.0, 5.0, 100.0].map(v => ({ value: v })), + beginAtZero: true, + ticks: { + display: true, + callback: (tickValue, index, ticks) => { + return parseInt(tickValue + "") + "%"; + }, + }, + grid: { + display: true, + drawTicks: false, + }, + title: { + display: true, + text: "Transmission (%)", + }, + }, + x: { + ticks: { + display: true, + callback: (tickValue, index, ticks) => { + return parseFloat(tickValue + "").toFixed(2) + "%"; + }, + }, + grid: { + display: true, + }, + title: { + display: true, + text: "Image Percentage", + }, + } + }, + plugins: { + title: { + display: true, + text: title + }, + legend: { + display: false, + }, + tooltip: { + callbacks: { + // label: (tooltipItem) => { + // return tooltipItem.dataset.label + ": " + tooltipItem.parsed.y.toFixed(2) + "keV" + // }, + title: (tooltipItems) => { + return tooltipItems[0].parsed.y.toFixed(0) + "% Transmission: " + tooltipItems[0].parsed.x.toFixed(2) + "% of pixels."; + }, + } + } + } + }; + + + var barcolors = [colors["red-500"]]; + barcolors.length = this.previewData.projection.transmission.hist.length + barcolors = barcolors.fill(colors["grey-500"], 0, barcolors.length) + barcolors = barcolors.fill(colors["red-500"], 0, 6) + + // ? Unknown type for dealing with chart.js dataset configurations + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const TransmissionSettings: any = { + label: label, + backgroundColor: barcolors, + borderColor: barcolors, + barPercentage: 1, + cubicInterpolationMode: "monotone", + borderDash: undefined, + fill: false, + radius: 0, + data: this.previewData.projection.transmission.hist, + }; + + this._chart?.destroy(); + + this._chart = new Chart(this.canvas, { + type: "bar", + data: { + labels: this.previewData.projection.transmission.hist, + datasets: [TransmissionSettings], + }, + options: chartOptions + }); + + this._chart.update(); + } +} diff --git a/webct/blueprints/preview/static/scss/previews.scss b/webct/blueprints/preview/static/scss/previews.scss index 4a79513..ca2bf7e 100644 --- a/webct/blueprints/preview/static/scss/previews.scss +++ b/webct/blueprints/preview/static/scss/previews.scss @@ -42,6 +42,10 @@ img.error { height: 100%; width: 100%; box-sizing: border-box; + + // sidebar pushes content 100% down, so move image back up. + // Can't use fixed positioning on sidebar due to resizing elements. + transform: translate(0, -100%); } .previewContainer { @@ -86,14 +90,37 @@ img.image-layout { display: none; } -#webgl > #previewSettings { +#previewSettings { height: 0; + z-index: 11; + position: relative; } #settingsPane:not([selected=projection]) { display: none; } +#previewGraphContainer:not([selected=projection]) { + display: none; +} + +#previewSidebar { + // overlap image preview + z-index: 1; + width: 14rem; + position: relative; + height: inherit; + display: flex; + flex-direction: column; +} + +#previewGraphContainer { + height: 100%; + width: 100%; + flex-grow: 1; + flex-shrink: 1; +} + #previewSettings > sl-button-group > sl-button { width: 7rem; } @@ -143,13 +170,14 @@ img.image-layout { height: auto; padding-top: 0.5rem; width: 14rem; - position: fixed; border-radius: 0.9rem; background-color: var(--sl-color-primary-50); border-color: var(--sl-color-primary-500); border-style: groove; border-width: 1px; box-sizing: border-box; + flex-shrink: 0; + flex-grow: 1; } #settingsPane > button { diff --git a/webct/blueprints/preview/templates/preview.pane.html.j2 b/webct/blueprints/preview/templates/preview.pane.html.j2 index 0858a99..ed06dd5 100644 --- a/webct/blueprints/preview/templates/preview.pane.html.j2 +++ b/webct/blueprints/preview/templates/preview.pane.html.j2 @@ -5,19 +5,28 @@ -
-
- - Raw Projection - Log Projection - Gamma Projection - - - Invert Colours +
+
+
+ + Raw Projection + Log Projection + Transmission Hotspot + + Invert Colours +
+ + + +
+ +
+
-
+ +