diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f7829836..c3f749f1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -39,6 +39,7 @@ import DetectorSettings from "./components/DetectorSettings"; import DetectorSelection from "./components/DetectorSelection"; import DetectorStatus from "./components/DetectorStatus"; import PixelMask from "./components/PixelMask"; +import MaskVisualization from "./components/MaskVisualization"; import DarkMaskSettings from "./components/DarkMaskSettings"; import IndexingSettings from "./components/IndexingSettings"; import BraggIntegrationSettings from "./components/BraggIntegrationSettings"; @@ -199,10 +200,13 @@ function App() { )}, { id: 'masks', label: 'Masks', icon: , render: () => ( - - - - + + + + + + + )}, { id: 'processing', label: 'On-the-fly processing', icon: , render: () => ( diff --git a/frontend/src/components/Calibration.tsx b/frontend/src/components/Calibration.tsx index 9f569922..fca186d7 100644 --- a/frontend/src/components/Calibration.tsx +++ b/frontend/src/components/Calibration.tsx @@ -1,8 +1,13 @@ -import {memo} from 'react'; +import { memo, useState } from 'react'; -import Paper from '@mui/material/Paper'; -import {DataGrid, GridColDef} from "@mui/x-data-grid"; -import {calibration_statistics} from "../client"; +import { Box, MenuItem, Stack, TextField, Typography } from "@mui/material"; +import FormControl from "@mui/material/FormControl"; +import InputLabel from "@mui/material/InputLabel"; +import Select from "@mui/material/Select"; +import { DataGrid, GridColDef } from "@mui/x-data-grid"; +import { calibration_statistics } from "../client"; +import SettingsPanel from "./SettingsPanel"; +import TiffImageViewer from "./TiffImageViewer"; type MyProps = { s?: calibration_statistics @@ -21,19 +26,43 @@ const columns : GridColDef[] = [ ]; function Calibration({s}: MyProps) { - return - {((s !== undefined) && (s.length > 0)) ? - Number(row.module_number) * 64 + Number(row.storage_cell_number)} - rows={s} - columns={columns} - initialState={{ - pagination: { paginationModel: { pageSize: 8 } }, - }} - pageSizeOptions={[4,8,16]} - /> :
- } - + const [gain, setGain] = useState(0); + const [sc, setSc] = useState(0); + const hasStats = (s !== undefined) && (s.length > 0); + + return ( + + Pedestal image + + + Gain level + + + setSc(Math.max(0, Number(e.target.value)))} + inputProps={{ min: 0, max: 15 }} sx={{ width: 160 }} /> + + + + Calibration statistics + {hasStats + ? + Number(row.module_number) * 64 + Number(row.storage_cell_number)} + rows={s} + columns={columns} + initialState={{ pagination: { paginationModel: { pageSize: 8 } } }} + pageSizeOptions={[4, 8, 16]} + /> + + : No calibration statistics available.} + + ); } export default memo(Calibration); diff --git a/frontend/src/components/MaskVisualization.tsx b/frontend/src/components/MaskVisualization.tsx new file mode 100644 index 00000000..70361867 --- /dev/null +++ b/frontend/src/components/MaskVisualization.tsx @@ -0,0 +1,25 @@ +import { memo, useState } from "react"; +import { ToggleButton, ToggleButtonGroup } from "@mui/material"; +import SettingsPanel from "./SettingsPanel"; +import TiffImageViewer from "./TiffImageViewer"; + +/** Full-width panel that fetches and renders the detector pixel mask (bit-wise + * colours) from the broker. Full mask = every reason; user mask = the uploaded + * mask only. Manual: press Load/Reload to fetch. */ +function MaskVisualization() { + const [src, setSrc] = useState<"full" | "user">("full"); + return ( + + { if (v) setSrc(v); }}> + Full mask + User mask + + + + ); +} + +export default memo(MaskVisualization); diff --git a/frontend/src/components/TiffImageViewer.tsx b/frontend/src/components/TiffImageViewer.tsx new file mode 100644 index 00000000..8160daee --- /dev/null +++ b/frontend/src/components/TiffImageViewer.tsx @@ -0,0 +1,256 @@ +import { memo, useEffect, useMemo, useRef, useState } from "react"; +import { + Box, Button, CircularProgress, MenuItem, Paper, Select, Stack, TextField, Typography, +} from "@mui/material"; +import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch"; +import { decodeTiff, DecodedImage } from "./tiffDecode"; + +// ---- Mask (bit-wise) semantics — mirrors common/PixelMask.h -------------------- +const MASK_BITS: [number, string][] = [ + [0, "Module gap"], [1, "Error"], [4, "Noisy"], [8, "User mask"], + [30, "Module edge"], [31, "Chip gap"], +]; + +function decodeMaskBits(v: number): string[] { + const labels: string[] = []; + let rest = v >>> 0; + for (const [bit, name] of MASK_BITS) { + if (rest & (1 << bit)) { labels.push(name); rest &= ~(1 << bit); } + } + for (let b = 0; b < 32; b++) if (rest & (1 << b)) labels.push(`bit ${b}`); + return labels; +} + +// A qualitative palette for the distinct mask values actually present. Good (0) +// pixels are drawn light so masked pixels stand out. +const GOOD = [225, 226, 232]; +const OTHER = [120, 120, 120]; +const MASK_PALETTE = [ + [230, 25, 75], [60, 180, 75], [67, 99, 216], [245, 130, 49], [145, 30, 180], + [66, 212, 244], [240, 50, 230], [191, 239, 69], [70, 153, 144], [154, 99, 36], + [0, 128, 128], [128, 0, 0], +]; + +// ---- Continuous colormaps for value images (pedestal) -------------------------- +type Stop = [number, number, number, number]; +const CMAPS: Record = { + Grayscale: [[0, 0, 0, 0], [1, 255, 255, 255]], + Viridis: [[0, 68, 1, 84], [0.25, 59, 82, 139], [0.5, 33, 145, 140], [0.75, 94, 201, 98], [1, 253, 231, 37]], + Heat: [[0, 0, 0, 0], [0.33, 178, 0, 0], [0.66, 255, 178, 0], [1, 255, 255, 255]], +}; + +function sampleCmap(stops: Stop[], t: number): [number, number, number] { + const x = Math.max(0, Math.min(1, t)); + for (let i = 1; i < stops.length; i++) { + if (x <= stops[i][0]) { + const [t0, r0, g0, b0] = stops[i - 1]; + const [t1, r1, g1, b1] = stops[i]; + const f = t1 === t0 ? 0 : (x - t0) / (t1 - t0); + return [r0 + (r1 - r0) * f, g0 + (g1 - g0) * f, b0 + (b1 - b0) * f]; + } + } + const last = stops[stops.length - 1]; + return [last[1], last[2], last[3]]; +} + +function cssRgb(c: number[]) { return `rgb(${c[0]},${c[1]},${c[2]})`; } + +type MaskLegend = { color: number[]; value: number; count: number; labels: string[] }[]; + +type MyProps = { + /** Endpoint to fetch the TIFF from (may carry query params). */ + url: string; + /** `mask` = discrete bit-flag colours + legend; `value` = colormap + adjustable bounds. */ + variant: "mask" | "value"; + /** Height of the image viewport in px. */ + height?: number; +}; + +function TiffImageViewer({ url, variant, height = 460 }: MyProps) { + const canvasRef = useRef(null); + const [img, setImg] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [range, setRange] = useState<[number, number]>([0, 1]); // data min/max + const [lo, setLo] = useState(0); + const [hi, setHi] = useState(1); + const [cmapName, setCmapName] = useState("Viridis"); + const [legend, setLegend] = useState(null); + const [hover, setHover] = useState<{ x: number; y: number; v: number } | null>(null); + + const load = () => { + setLoading(true); setError(null); setHover(null); + fetch(url, { method: "GET" }) + .then((r) => { + if (r.status === 404) throw new Error("No data available yet."); + if (!r.ok) throw new Error(`Request failed (${r.status}).`); + return r.arrayBuffer(); + }) + .then((buf) => { + const decoded = decodeTiff(buf); + if (variant === "value") { + let mn = Infinity, mx = -Infinity; + for (let i = 0; i < decoded.data.length; i++) { + const v = decoded.data[i]; + if (!Number.isFinite(v)) continue; + if (v < mn) mn = v; if (v > mx) mx = v; + } + if (!Number.isFinite(mn)) { mn = 0; mx = 1; } + setRange([mn, mx]); setLo(mn); setHi(mx); setLegend(null); + } else { + const counts = new Map(); + for (let i = 0; i < decoded.data.length; i++) { + const v = decoded.data[i] >>> 0; + if (v !== 0) counts.set(v, (counts.get(v) ?? 0) + 1); + } + const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]); + setLegend(sorted.map(([value, count], i) => ({ + value, count, labels: decodeMaskBits(value), + color: i < MASK_PALETTE.length ? MASK_PALETTE[i] : OTHER, + }))); + } + setImg(decoded); + }) + .catch((e) => { setError(e.message ?? "Failed to load image."); setImg(null); }) + .finally(() => setLoading(false)); + }; + + const colorByValue = useMemo(() => { + if (!legend) return new Map(); + return new Map(legend.map((e) => [e.value, e.color])); + }, [legend]); + + // Repaint whenever the image, colour bounds or colormap change. + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || !img) return; + canvas.width = img.width; canvas.height = img.height; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + const out = ctx.createImageData(img.width, img.height); + const px = out.data; + const data = img.data; + + if (variant === "value") { + const stops = CMAPS[cmapName] ?? CMAPS.Viridis; + const span = hi - lo || 1; + for (let i = 0; i < data.length; i++) { + const v = data[i]; + const j = i * 4; + if (!Number.isFinite(v)) { px[j] = px[j + 1] = px[j + 2] = 60; px[j + 3] = 255; continue; } + const [r, g, b] = sampleCmap(stops, (v - lo) / span); + px[j] = r; px[j + 1] = g; px[j + 2] = b; px[j + 3] = 255; + } + } else { + for (let i = 0; i < data.length; i++) { + const v = data[i] >>> 0; + const c = v === 0 ? GOOD : (colorByValue.get(v) ?? OTHER); + const j = i * 4; + px[j] = c[0]; px[j + 1] = c[1]; px[j + 2] = c[2]; px[j + 3] = 255; + } + } + ctx.putImageData(out, 0, 0); + }, [img, lo, hi, cmapName, variant, colorByValue]); + + // Rect-based hover so the pixel lookup is correct at any zoom/pan level. + const onMove = (e: React.MouseEvent) => { + const canvas = canvasRef.current; + if (!canvas || !img) return; + const rect = canvas.getBoundingClientRect(); + const x = Math.floor((e.clientX - rect.left) / rect.width * img.width); + const y = Math.floor((e.clientY - rect.top) / rect.height * img.height); + if (x < 0 || y < 0 || x >= img.width || y >= img.height) { setHover(null); return; } + setHover({ x, y, v: img.data[y * img.width + x] }); + }; + + const hoverText = () => { + if (!hover) return " "; + if (variant === "mask") { + const flags = hover.v === 0 ? "good" : decodeMaskBits(hover.v).join(", "); + return `x ${hover.x}, y ${hover.y} — ${hover.v >>> 0} (${flags})`; + } + return `x ${hover.x}, y ${hover.y} — ${hover.v}`; + }; + + return ( + + + + {loading && } + {img && ( + + {img.width} × {img.height} + + )} + {img && variant === "value" && ( + <> + setLo(Number(e.target.value))} sx={{ width: 110 }} /> + setHi(Number(e.target.value))} sx={{ width: 110 }} /> + + + + )} + + + + {error ? {error} + : img ? ( + + + setHover(null)} + style={{ maxWidth: "100%", maxHeight: height, imageRendering: "pixelated" }} /> + + + ) : Press “Load” to fetch the image.} + + + {img && ( + + {hoverText()} + + )} + + {img && variant === "value" && ( + + {lo} + `rgb(${s[1]},${s[2]},${s[3]}) ${s[0] * 100}%`).join(",")})` }} /> + {hi} + + )} + + {img && variant === "mask" && legend && ( + + + {legend.map((e) => ( + + ))} + + )} + + ); +} + +function LegendChip({ color, label }: { color: number[]; label: string }) { + return ( + + + {label} + + ); +} + +export default memo(TiffImageViewer); diff --git a/frontend/src/components/tiffDecode.ts b/frontend/src/components/tiffDecode.ts new file mode 100644 index 00000000..266d874f --- /dev/null +++ b/frontend/src/components/tiffDecode.ts @@ -0,0 +1,115 @@ +// Minimal decoder for the baseline TIFFs the broker produces (uncompressed, +// single-channel, contiguous — see preview/JFJochTIFF.cpp). Handles 8/16/32/64-bit +// samples in unsigned / signed / IEEE-float formats and both byte orders. Kept +// dependency-free on purpose: the format is fully under our control, so a full +// TIFF library would be overkill. + +export type SampleFormat = "uint" | "int" | "float"; +export type SampleArray = + Uint8Array | Uint16Array | Uint32Array | + Int8Array | Int16Array | Int32Array | + Float32Array | Float64Array; + +export type DecodedImage = { + width: number; + height: number; + bits: number; + format: SampleFormat; + data: SampleArray; // length = width * height, row-major, top-left origin +}; + +// Bytes per TIFF field type (index = type code). +const TYPE_SIZE: Record = { + 1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 6: 1, 7: 1, 8: 2, 9: 4, 10: 8, 11: 4, 12: 8, +}; + +const platformLittleEndian = new Uint8Array(new Uint32Array([1]).buffer)[0] === 1; + +function makeSampleArray(buf: ArrayBuffer, bits: number, format: SampleFormat): SampleArray { + if (format === "float") { + if (bits === 32) return new Float32Array(buf); + if (bits === 64) return new Float64Array(buf); + } else if (format === "int") { + if (bits === 8) return new Int8Array(buf); + if (bits === 16) return new Int16Array(buf); + if (bits === 32) return new Int32Array(buf); + } else { + if (bits === 8) return new Uint8Array(buf); + if (bits === 16) return new Uint16Array(buf); + if (bits === 32) return new Uint32Array(buf); + } + throw new Error(`Unsupported TIFF sample: ${bits}-bit ${format}`); +} + +// In-place byte swap so a foreign-endian file can be viewed as a native typed array. +function byteSwap(bytes: Uint8Array, wordSize: number) { + if (wordSize < 2) return; + for (let i = 0; i + wordSize <= bytes.length; i += wordSize) + for (let a = 0, b = wordSize - 1; a < b; a++, b--) { + const t = bytes[i + a]; bytes[i + a] = bytes[i + b]; bytes[i + b] = t; + } +} + +export function decodeTiff(buffer: ArrayBuffer): DecodedImage { + const dv = new DataView(buffer); + const bo = dv.getUint16(0, false); + const little = bo === 0x4949; // "II" + if (!little && bo !== 0x4d4d) throw new Error("Not a TIFF (bad byte-order mark)"); + if (dv.getUint16(2, little) !== 42) throw new Error("Not a TIFF (bad magic)"); + + const readVals = (type: number, count: number, offset: number): number[] => { + const size = TYPE_SIZE[type] ?? 0; + const total = size * count; + const base = total <= 4 ? offset : dv.getUint32(offset, little); // inline vs pointer + const out: number[] = []; + for (let i = 0; i < count; i++) { + const p = base + i * size; + switch (type) { + case 3: out.push(dv.getUint16(p, little)); break; // SHORT + case 4: out.push(dv.getUint32(p, little)); break; // LONG + case 1: case 6: case 7: out.push(dv.getUint8(p)); break; // BYTE + case 8: out.push(dv.getInt16(p, little)); break; // SSHORT + case 9: out.push(dv.getInt32(p, little)); break; // SLONG + default: out.push(dv.getUint32(p, little)); break; + } + } + return out; + }; + + const ifd = dv.getUint32(4, little); + const n = dv.getUint16(ifd, little); + const tags = new Map(); + for (let i = 0; i < n; i++) { + const e = ifd + 2 + i * 12; + tags.set(dv.getUint16(e, little), readVals(dv.getUint16(e + 2, little), dv.getUint32(e + 4, little), e + 8)); + } + const tag = (id: number, def?: number) => (tags.get(id) ? tags.get(id)![0] : def); + + const width = tag(256)!; + const height = tag(257)!; + const bits = tag(258, 8)!; + const samplesPerPixel = tag(277, 1)!; + const compression = tag(259, 1)!; + const sf = tag(339, 1)!; // 1=uint 2=int 3=float + const format: SampleFormat = sf === 3 ? "float" : sf === 2 ? "int" : "uint"; + const stripOffsets = tags.get(273) ?? []; + const stripCounts = tags.get(279) ?? []; + + if (compression !== 1) throw new Error("Only uncompressed TIFF is supported"); + if (samplesPerPixel !== 1) throw new Error("Only single-channel TIFF is supported"); + if (!width || !height || !stripOffsets.length) throw new Error("Malformed TIFF (missing size/strips)"); + + // Concatenate strips into one contiguous, word-aligned buffer. + const bytesPerSample = bits / 8; + const totalBytes = width * height * bytesPerSample; + const out = new Uint8Array(totalBytes); + let pos = 0; + for (let i = 0; i < stripOffsets.length; i++) { + const len = stripCounts[i] ?? (totalBytes - pos); + out.set(new Uint8Array(buffer, stripOffsets[i], len), pos); + pos += len; + } + if (little !== platformLittleEndian) byteSwap(out, bytesPerSample); + + return { width, height, bits, format, data: makeSampleArray(out.buffer, bits, format) }; +}