frontend: visualize mask + pedestal TIFFs in-browser

Add an in-browser viewer for the broker's TIFF endpoints so the pixel mask
and JUNGFRAU pedestal can be inspected without downloading files.

- tiffDecode.ts: dependency-free decoder for the broker's baseline TIFFs
  (uncompressed, single-strip, single-channel; 8/16/32/64-bit uint/int/float).
- TiffImageViewer.tsx: manual Load -> fetch -> decode -> canvas, with
  react-zoom-pan-pinch zoom/pan and a rect-based (zoom-agnostic) hover
  readout. `value` variant = colormap + editable min/max bounds + Auto +
  colorbar; `mask` variant = discrete fixed colours per bit-flag value with
  a decoded-flag legend (mask bits mirror common/PixelMask.h).
- MaskVisualization.tsx: full-width panel on the Masks page (full/user mask
  toggle) fetching /config/mask.tiff and /config/user_mask.tiff.
- Calibration.tsx: rebuilt with the shared card header; adds a pedestal
  viewer (gain-level + storage-cell selectors -> /preview/pedestal.tiff)
  above the per-module statistics grid, and degrades gracefully with no data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-10 14:17:57 +02:00
co-authored by Claude Opus 4.8
parent 314caacee7
commit 04bce94f4e
5 changed files with 450 additions and 21 deletions
+8 -4
View File
@@ -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() {
<DetectorSelection s={s.detector_list}/>
)},
{ id: 'masks', label: 'Masks', icon: <GridOnIcon/>, render: () => (
<PanelColumns>
<PixelMask s={s.pixel_mask}/>
<DarkMaskSettings s={s.dark_mask}/>
</PanelColumns>
<Stack spacing={2}>
<PanelColumns>
<PixelMask s={s.pixel_mask}/>
<DarkMaskSettings s={s.dark_mask}/>
</PanelColumns>
<MaskVisualization/>
</Stack>
)},
{ id: 'processing', label: 'On-the-fly processing', icon: <BoltIcon/>, render: () => (
<Stack spacing={2}>
+46 -17
View File
@@ -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 <Paper style={{textAlign: 'center'}} sx={{ height: 527, width: '100%' }}>
{((s !== undefined) && (s.length > 0)) ?
<DataGrid
getRowId={(row) => Number(row.module_number) * 64 + Number(row.storage_cell_number)}
rows={s}
columns={columns}
initialState={{
pagination: { paginationModel: { pageSize: 8 } },
}}
pageSizeOptions={[4,8,16]}
/> : <div/>
}
</Paper>
const [gain, setGain] = useState(0);
const [sc, setSc] = useState(0);
const hasStats = (s !== undefined) && (s.length > 0);
return (
<SettingsPanel title="JUNGFRAU calibration">
<b>Pedestal image</b>
<Stack direction="row" spacing={2} justifyContent="center">
<FormControl size="small" sx={{ width: 160 }}>
<InputLabel id="ped-gain">Gain level</InputLabel>
<Select labelId="ped-gain" label="Gain level" value={gain}
onChange={(e) => setGain(Number(e.target.value))}>
<MenuItem value={0}>G0</MenuItem>
<MenuItem value={1}>G1</MenuItem>
<MenuItem value={2}>G2</MenuItem>
</Select>
</FormControl>
<TextField label="Storage cell" type="number" size="small" value={sc}
onChange={(e) => setSc(Math.max(0, Number(e.target.value)))}
inputProps={{ min: 0, max: 15 }} sx={{ width: 160 }} />
</Stack>
<TiffImageViewer url={`/preview/pedestal.tiff?gain_level=${gain}&sc=${sc}`} variant="value" />
<b>Calibration statistics</b>
{hasStats
? <Box sx={{ width: "100%", height: 380 }}>
<DataGrid
getRowId={(row) => Number(row.module_number) * 64 + Number(row.storage_cell_number)}
rows={s}
columns={columns}
initialState={{ pagination: { paginationModel: { pageSize: 8 } } }}
pageSizeOptions={[4, 8, 16]}
/>
</Box>
: <Typography color="text.secondary">No calibration statistics available.</Typography>}
</SettingsPanel>
);
}
export default memo(Calibration);
@@ -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 (
<SettingsPanel title="Mask visualization">
<ToggleButtonGroup color="primary" exclusive size="small" value={src}
onChange={(_e, v) => { if (v) setSrc(v); }}>
<ToggleButton value="full">Full mask</ToggleButton>
<ToggleButton value="user">User mask</ToggleButton>
</ToggleButtonGroup>
<TiffImageViewer
url={src === "full" ? "/config/mask.tiff" : "/config/user_mask.tiff"}
variant="mask"/>
</SettingsPanel>
);
}
export default memo(MaskVisualization);
+256
View File
@@ -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<string, Stop[]> = {
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<HTMLCanvasElement>(null);
const [img, setImg] = useState<DecodedImage | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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<MaskLegend | null>(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<number, number>();
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<number, number[]>();
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<HTMLCanvasElement>) => {
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 (
<Box sx={{ width: "100%" }}>
<Stack direction="row" spacing={2} alignItems="center" flexWrap="wrap" useFlexGap>
<Button variant="contained" disableElevation onClick={load} disabled={loading}>
{img ? "Reload" : "Load"}
</Button>
{loading && <CircularProgress size={20} />}
{img && (
<Typography variant="body2" color="text.secondary">
{img.width} × {img.height}
</Typography>
)}
{img && variant === "value" && (
<>
<TextField label="Min" type="number" size="small" value={lo}
onChange={(e) => setLo(Number(e.target.value))} sx={{ width: 110 }} />
<TextField label="Max" type="number" size="small" value={hi}
onChange={(e) => setHi(Number(e.target.value))} sx={{ width: 110 }} />
<Button size="small" onClick={() => { setLo(range[0]); setHi(range[1]); }}>Auto</Button>
<Select size="small" value={cmapName} onChange={(e) => setCmapName(e.target.value)}>
{Object.keys(CMAPS).map((k) => <MenuItem key={k} value={k}>{k}</MenuItem>)}
</Select>
</>
)}
</Stack>
<Paper variant="outlined" sx={{ mt: 2, height, overflow: "hidden",
display: "flex", alignItems: "center", justifyContent: "center",
bgcolor: variant === "mask" ? "#fafafa" : "#111" }}>
{error ? <Typography color="error">{error}</Typography>
: img ? (
<TransformWrapper maxScale={40} doubleClick={{ mode: "reset" }}>
<TransformComponent wrapperStyle={{ width: "100%", height: "100%" }}>
<canvas ref={canvasRef} onMouseMove={onMove} onMouseLeave={() => setHover(null)}
style={{ maxWidth: "100%", maxHeight: height, imageRendering: "pixelated" }} />
</TransformComponent>
</TransformWrapper>
) : <Typography color="text.secondary">Press Load to fetch the image.</Typography>}
</Paper>
{img && (
<Typography variant="body2" sx={{ mt: 1, fontFamily: "monospace", color: "text.secondary",
textAlign: "left", minHeight: 22 }}>
{hoverText()}
</Typography>
)}
{img && variant === "value" && (
<Box sx={{ mt: 1, display: "flex", alignItems: "center", gap: 1 }}>
<Typography variant="caption" color="text.secondary">{lo}</Typography>
<Box sx={{ flexGrow: 1, height: 12, borderRadius: 1,
background: `linear-gradient(to right, ${(CMAPS[cmapName] ?? CMAPS.Viridis)
.map((s) => `rgb(${s[1]},${s[2]},${s[3]}) ${s[0] * 100}%`).join(",")})` }} />
<Typography variant="caption" color="text.secondary">{hi}</Typography>
</Box>
)}
{img && variant === "mask" && legend && (
<Box sx={{ mt: 1, display: "flex", flexWrap: "wrap", gap: 1, justifyContent: "center" }}>
<LegendChip color={GOOD} label="good (0)" />
{legend.map((e) => (
<LegendChip key={e.value} color={e.color}
label={`${e.value} · ${e.labels.join(", ")}`} />
))}
</Box>
)}
</Box>
);
}
function LegendChip({ color, label }: { color: number[]; label: string }) {
return (
<Box sx={{ display: "inline-flex", alignItems: "center", gap: 0.75, px: 1, py: 0.25,
border: "1px solid", borderColor: "divider", borderRadius: 1 }}>
<Box sx={{ width: 14, height: 14, borderRadius: 0.5, bgcolor: cssRgb(color),
border: "1px solid rgba(0,0,0,.2)" }} />
<Typography variant="caption">{label}</Typography>
</Box>
);
}
export default memo(TiffImageViewer);
+115
View File
@@ -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<number, number> = {
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<number, number[]>();
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) };
}