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:
@@ -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}>
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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) };
|
||||
}
|
||||
Reference in New Issue
Block a user