1124 lines
40 KiB
Python
1124 lines
40 KiB
Python
"""
|
|
Core data types for tono.
|
|
|
|
DataField mirrors Gwyddion's GwyDataField structure:
|
|
xres, yres - pixel dimensions
|
|
xreal, yreal - physical dimensions in metres
|
|
xoff, yoff - position offset in metres
|
|
si_unit_xy - lateral unit string (e.g. "m", "nm")
|
|
si_unit_z - height/value unit string (e.g. "m", "V", "A")
|
|
domain - "spatial" or "frequency" (set by FFT nodes)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from copy import deepcopy
|
|
from dataclasses import dataclass, field
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
from typing import Any
|
|
import numpy as np
|
|
|
|
|
|
COLORMAPS = ("viridis", "gray", "hot", "jet", "plasma", "inferno", "terrain",
|
|
"cividis", "magma", "copper", "afmhot")
|
|
DEFAULT_CUSTOM_COLORMAP_STOPS = (
|
|
{"position": 0.0, "color": "#440154"},
|
|
{"position": 1.0, "color": "#fde725"},
|
|
)
|
|
SYSTEM_DEFAULT_FONT = "System Default"
|
|
CUSTOM_FILE_FONT = "Custom File"
|
|
PREVIEW_MARKUP_REFERENCE_DIM = 512
|
|
|
|
|
|
class DataTable(list):
|
|
"""Tabular rows with a shared schema, e.g. grain statistics."""
|
|
|
|
|
|
class RecordTable(list):
|
|
"""Named scalar measurements, typically rows of quantity/value/unit."""
|
|
|
|
|
|
@dataclass
|
|
class LineData:
|
|
data: np.ndarray
|
|
x_axis: np.ndarray | None = None
|
|
x_unit: str = ""
|
|
y_unit: str = ""
|
|
|
|
def __post_init__(self) -> None:
|
|
self.data = np.asarray(self.data, dtype=np.float64).ravel()
|
|
if self.x_axis is not None:
|
|
axis = np.asarray(self.x_axis, dtype=np.float64).ravel()
|
|
self.x_axis = axis[: len(self.data)]
|
|
else:
|
|
self.x_axis = None
|
|
|
|
def __array__(self, dtype=None):
|
|
return np.asarray(self.data, dtype=dtype) if dtype is not None else self.data
|
|
|
|
def __len__(self) -> int:
|
|
return len(self.data)
|
|
|
|
def __iter__(self):
|
|
return iter(self.data)
|
|
|
|
def __getitem__(self, item):
|
|
return self.data[item]
|
|
|
|
|
|
@dataclass
|
|
class MeshModel:
|
|
vertices: np.ndarray
|
|
faces: np.ndarray
|
|
colors: np.ndarray | None = None
|
|
|
|
def __post_init__(self) -> None:
|
|
self.vertices = np.asarray(self.vertices, dtype=np.float32).reshape(-1, 3)
|
|
self.faces = np.asarray(self.faces, dtype=np.int32).reshape(-1, 3)
|
|
if self.colors is not None:
|
|
self.colors = np.asarray(self.colors, dtype=np.uint8).reshape(-1, 3)
|
|
if len(self.colors) != len(self.vertices):
|
|
raise ValueError("MeshModel.colors must have one RGB triplet per vertex.")
|
|
|
|
|
|
class ImageData(np.ndarray):
|
|
def __new__(cls, data: Any, metadata: dict[str, Any] | None = None):
|
|
obj = np.asarray(data).view(cls)
|
|
obj.metadata = deepcopy(metadata) if isinstance(metadata, dict) else {}
|
|
return obj
|
|
|
|
def __array_finalize__(self, obj):
|
|
self.metadata = deepcopy(getattr(obj, "metadata", {})) if obj is not None else {}
|
|
|
|
def copy_with_metadata(self, *, data: Any | None = None, metadata: dict[str, Any] | None = None) -> "ImageData":
|
|
base = np.asarray(self if data is None else data)
|
|
merged = deepcopy(self.metadata)
|
|
if isinstance(metadata, dict):
|
|
merged.update(deepcopy(metadata))
|
|
return ImageData(base, metadata=merged)
|
|
|
|
|
|
def image_metadata(image: Any) -> dict[str, Any]:
|
|
metadata = getattr(image, "metadata", None)
|
|
return deepcopy(metadata) if isinstance(metadata, dict) else {}
|
|
|
|
|
|
def _normalize_hex_color(color: Any, default: str = "#000000") -> str:
|
|
if isinstance(color, str):
|
|
text = color.strip()
|
|
if len(text) == 4 and text.startswith("#"):
|
|
text = "#" + "".join(ch * 2 for ch in text[1:])
|
|
if len(text) == 7 and text.startswith("#"):
|
|
try:
|
|
int(text[1:], 16)
|
|
return text.lower()
|
|
except ValueError:
|
|
pass
|
|
return default
|
|
|
|
|
|
def _hex_to_rgb01(color: str) -> tuple[float, float, float]:
|
|
normalized = _normalize_hex_color(color)
|
|
return (
|
|
int(normalized[1:3], 16) / 255.0,
|
|
int(normalized[3:5], 16) / 255.0,
|
|
int(normalized[5:7], 16) / 255.0,
|
|
)
|
|
|
|
|
|
def _normalize_custom_colormap_stops(raw_stops: Any) -> list[dict[str, float | str]] | None:
|
|
if not isinstance(raw_stops, list):
|
|
return None
|
|
|
|
parsed = []
|
|
for stop in raw_stops:
|
|
if not isinstance(stop, dict):
|
|
continue
|
|
try:
|
|
position = float(stop.get("position"))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if not np.isfinite(position):
|
|
continue
|
|
parsed.append({
|
|
"position": float(np.clip(position, 0.0, 1.0)),
|
|
"color": _normalize_hex_color(stop.get("color"), "#000000"),
|
|
})
|
|
|
|
if len(parsed) < 2:
|
|
return None
|
|
|
|
parsed.sort(key=lambda stop: stop["position"])
|
|
unique: list[dict[str, float | str]] = []
|
|
for stop in parsed:
|
|
if unique and abs(float(stop["position"]) - float(unique[-1]["position"])) < 1e-9:
|
|
unique[-1] = stop
|
|
else:
|
|
unique.append(stop)
|
|
|
|
if len(unique) < 2:
|
|
return None
|
|
|
|
unique[0] = {"position": 0.0, "color": unique[0]["color"]}
|
|
unique[-1] = {"position": 1.0, "color": unique[-1]["color"]}
|
|
return unique
|
|
|
|
|
|
def normalize_colormap_spec(colormap: Any, fallback: Any = "viridis") -> str | dict[str, Any]:
|
|
if isinstance(colormap, str):
|
|
if colormap in COLORMAPS:
|
|
return colormap
|
|
elif isinstance(colormap, dict):
|
|
mode = str(colormap.get("mode", "")).strip().lower()
|
|
preset = colormap.get("preset")
|
|
if isinstance(preset, str) and preset in COLORMAPS:
|
|
if mode == "preset" or "stops" not in colormap:
|
|
return {"mode": "preset", "preset": preset}
|
|
stops = _normalize_custom_colormap_stops(colormap.get("stops"))
|
|
if stops is not None:
|
|
return {"mode": "custom", "stops": stops}
|
|
|
|
if fallback is colormap:
|
|
return "viridis"
|
|
return normalize_colormap_spec(fallback, fallback="viridis")
|
|
|
|
|
|
def resolve_colormap_input(
|
|
selection: Any = "auto",
|
|
*,
|
|
colormap_input: Any = None,
|
|
inherited: Any = None,
|
|
default: Any = "gray",
|
|
) -> str | dict[str, Any]:
|
|
if colormap_input is not None:
|
|
return normalize_colormap_spec(colormap_input, fallback=inherited if inherited is not None else default)
|
|
|
|
if isinstance(selection, str) and selection != "auto":
|
|
return normalize_colormap_spec(selection, fallback=inherited if inherited is not None else default)
|
|
|
|
if inherited is not None:
|
|
return normalize_colormap_spec(inherited, fallback=default)
|
|
|
|
return normalize_colormap_spec(default, fallback="gray")
|
|
|
|
|
|
def normalize_font_spec(font: Any) -> dict[str, str] | None:
|
|
if isinstance(font, str):
|
|
family = font.strip()
|
|
return {"family": family, "path": ""} if family else None
|
|
|
|
if isinstance(font, dict):
|
|
family = str(font.get("family", "")).strip()
|
|
path = str(font.get("path", "")).strip()
|
|
if family or path:
|
|
return {"family": family, "path": path}
|
|
|
|
return None
|
|
|
|
|
|
def colormap_to_uint8(normalized: np.ndarray, colormap: Any = "gray") -> np.ndarray:
|
|
normalized = np.asarray(normalized, dtype=np.float64)
|
|
spec = normalize_colormap_spec(colormap, fallback="gray")
|
|
|
|
if spec == "gray":
|
|
grey = np.rint(normalized * 255.0).astype(np.uint8)
|
|
rgb = np.empty(grey.shape + (3,), dtype=np.uint8)
|
|
rgb[..., 0] = grey
|
|
rgb[..., 1] = grey
|
|
rgb[..., 2] = grey
|
|
return rgb
|
|
|
|
if isinstance(spec, dict) and spec.get("mode") == "custom":
|
|
stops = spec["stops"]
|
|
positions = np.array([float(stop["position"]) for stop in stops], dtype=np.float64)
|
|
colors = np.array([_hex_to_rgb01(str(stop["color"])) for stop in stops], dtype=np.float64)
|
|
flat = normalized.reshape(-1)
|
|
rgb = np.empty((flat.shape[0], 3), dtype=np.uint8)
|
|
for channel in range(3):
|
|
rgb[:, channel] = np.rint(np.interp(flat, positions, colors[:, channel]) * 255.0).astype(np.uint8)
|
|
return rgb.reshape(normalized.shape + (3,))
|
|
|
|
cmap_name = spec["preset"] if isinstance(spec, dict) else spec
|
|
return _apply_named_colormap(normalized, cmap_name)
|
|
|
|
@dataclass
|
|
class DataField:
|
|
data: np.ndarray # shape (yres, xres), dtype float64
|
|
xres: int = 0
|
|
yres: int = 0
|
|
xreal: float = 1e-6 # physical width in metres
|
|
yreal: float = 1e-6 # physical height in metres
|
|
xoff: float = 0.0
|
|
yoff: float = 0.0
|
|
si_unit_xy: str = "m"
|
|
si_unit_z: str = "m"
|
|
domain: str = "spatial" # "spatial" or "frequency"
|
|
colormap: str | dict[str, Any] = "viridis"
|
|
display_offset: float = 0.0
|
|
display_scale: float = 1.0
|
|
overlays: list[dict[str, Any]] = field(default_factory=list)
|
|
|
|
def __post_init__(self) -> None:
|
|
self.data = np.asarray(self.data, dtype=np.float64)
|
|
if self.data.ndim != 2:
|
|
raise ValueError(f"DataField.data must be 2-D, got shape {self.data.shape}")
|
|
self.yres, self.xres = self.data.shape
|
|
self.overlays = deepcopy(self.overlays) if isinstance(self.overlays, list) else []
|
|
|
|
def copy(self) -> "DataField":
|
|
"""Return a deep copy with independent data array."""
|
|
return DataField(
|
|
data=self.data.copy(),
|
|
xres=self.xres,
|
|
yres=self.yres,
|
|
xreal=self.xreal,
|
|
yreal=self.yreal,
|
|
xoff=self.xoff,
|
|
yoff=self.yoff,
|
|
si_unit_xy=self.si_unit_xy,
|
|
si_unit_z=self.si_unit_z,
|
|
domain=self.domain,
|
|
colormap=self.colormap,
|
|
display_offset=self.display_offset,
|
|
display_scale=self.display_scale,
|
|
overlays=deepcopy(self.overlays),
|
|
)
|
|
|
|
def replace(self, **kwargs) -> "DataField":
|
|
"""Return a copy with selected fields replaced. data is deep-copied unless provided."""
|
|
base = {
|
|
"data": self.data.copy(),
|
|
"xres": self.xres,
|
|
"yres": self.yres,
|
|
"xreal": self.xreal,
|
|
"yreal": self.yreal,
|
|
"xoff": self.xoff,
|
|
"yoff": self.yoff,
|
|
"si_unit_xy": self.si_unit_xy,
|
|
"si_unit_z": self.si_unit_z,
|
|
"domain": self.domain,
|
|
"colormap": self.colormap,
|
|
"display_offset": self.display_offset,
|
|
"display_scale": self.display_scale,
|
|
"overlays": deepcopy(self.overlays),
|
|
}
|
|
base.update(kwargs)
|
|
return DataField(**base)
|
|
|
|
@property
|
|
def dx(self) -> float:
|
|
"""Physical pixel size in x (metres)."""
|
|
return self.xreal / self.xres if self.xres else 1.0
|
|
|
|
@property
|
|
def dy(self) -> float:
|
|
"""Physical pixel size in y (metres)."""
|
|
return self.yreal / self.yres if self.yres else 1.0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Utility helpers shared across nodes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def normalize_for_colormap(
|
|
data: np.ndarray,
|
|
*,
|
|
offset: float = 0.0,
|
|
scale: float = 1.0,
|
|
data_min: float | None = None,
|
|
data_max: float | None = None,
|
|
) -> np.ndarray:
|
|
"""
|
|
Normalize an array to [0, 1] for colormap lookup, then apply a display window.
|
|
|
|
offset/scale operate in normalized data coordinates:
|
|
output = clip((base_norm - offset) / scale, 0, 1)
|
|
So offset=0, scale=1 maps the full data range 1:1 into the colormap.
|
|
"""
|
|
data = np.asarray(data, dtype=np.float64)
|
|
dmin = float(data.min()) if data_min is None else float(data_min)
|
|
dmax = float(data.max()) if data_max is None else float(data_max)
|
|
|
|
if dmax > dmin:
|
|
base_norm = (data - dmin) / (dmax - dmin)
|
|
else:
|
|
base_norm = np.zeros_like(data)
|
|
|
|
offset = float(offset)
|
|
scale = float(scale)
|
|
if not np.isfinite(offset):
|
|
offset = 0.0
|
|
if not np.isfinite(scale) or scale <= 0.0:
|
|
scale = 1.0
|
|
|
|
return np.clip((base_norm - offset) / scale, 0.0, 1.0)
|
|
|
|
|
|
def datafield_to_uint8(df: DataField, colormap: Any = "gray") -> np.ndarray:
|
|
"""
|
|
Normalize a DataField to a uint8 (H, W, 3) RGB array using a colormap.
|
|
Returns shape (H, W, 3) uint8.
|
|
"""
|
|
normalized = normalize_for_colormap(
|
|
df.data,
|
|
offset=df.display_offset,
|
|
scale=df.display_scale,
|
|
)
|
|
return colormap_to_uint8(normalized, colormap)
|
|
|
|
|
|
_SI_PREFIXES = [
|
|
(1e24, "Y"),
|
|
(1e21, "Z"),
|
|
(1e18, "E"),
|
|
(1e15, "P"),
|
|
(1e12, "T"),
|
|
(1e9, "G"),
|
|
(1e6, "M"),
|
|
(1e3, "k"),
|
|
(1.0, ""),
|
|
(1e-3, "m"),
|
|
(1e-6, "u"),
|
|
(1e-9, "n"),
|
|
(1e-12, "p"),
|
|
(1e-15, "f"),
|
|
(1e-18, "a"),
|
|
(1e-21, "z"),
|
|
(1e-24, "y"),
|
|
]
|
|
_PREFIXABLE_UNITS = {"m", "s", "A", "V", "W", "Hz", "F", "C", "J", "N", "Pa", "T", "H", "S", "g", "K", "Ohm", "ohm", "Ω"}
|
|
|
|
|
|
def _format_numeric(value: float) -> str:
|
|
if not np.isfinite(value):
|
|
return str(value)
|
|
abs_value = abs(value)
|
|
if abs_value == 0:
|
|
return "0"
|
|
if abs_value >= 1e4 or abs_value < 1e-3:
|
|
return f"{value:.3e}"
|
|
return f"{value:.4g}"
|
|
|
|
|
|
def _format_with_unit(value: float, unit: str) -> str:
|
|
unit = (unit or "").strip()
|
|
if not unit:
|
|
return _format_numeric(value)
|
|
if unit in _PREFIXABLE_UNITS and np.isfinite(value) and value != 0:
|
|
abs_value = abs(value)
|
|
for scale, prefix in _SI_PREFIXES:
|
|
scaled = abs_value / scale
|
|
if 1 <= scaled < 1000:
|
|
signed = value / scale
|
|
return f"{_format_numeric(signed)} {prefix}{unit}"
|
|
return f"{_format_numeric(value)} {unit}"
|
|
|
|
|
|
def _nice_length(target: float) -> float:
|
|
if not np.isfinite(target) or target <= 0:
|
|
return 0.0
|
|
exponent = np.floor(np.log10(target))
|
|
base = 10.0 ** exponent
|
|
for step in (5.0, 2.0, 1.0):
|
|
candidate = step * base
|
|
if candidate <= target:
|
|
return candidate
|
|
return base
|
|
|
|
|
|
def _display_value_range(field: DataField) -> tuple[float, float, float]:
|
|
data = np.asarray(field.data, dtype=np.float64)
|
|
dmin = float(data.min())
|
|
dmax = float(data.max())
|
|
if not np.isfinite(dmin) or not np.isfinite(dmax) or dmax <= dmin:
|
|
return dmin, dmin, dmin
|
|
|
|
offset = float(field.display_offset)
|
|
scale = float(field.display_scale)
|
|
if not np.isfinite(offset):
|
|
offset = 0.0
|
|
if not np.isfinite(scale) or scale <= 0.0:
|
|
scale = 1.0
|
|
|
|
low_norm = float(np.clip(offset, 0.0, 1.0))
|
|
high_norm = float(np.clip(offset + scale, 0.0, 1.0))
|
|
if high_norm < low_norm:
|
|
low_norm, high_norm = high_norm, low_norm
|
|
mid_norm = 0.5 * (low_norm + high_norm)
|
|
|
|
span = dmax - dmin
|
|
return (
|
|
dmin + low_norm * span,
|
|
dmin + mid_norm * span,
|
|
dmin + high_norm * span,
|
|
)
|
|
|
|
|
|
def _normalize_font_match_key(text: str) -> str:
|
|
return "".join(ch for ch in text.lower() if ch.isalnum())
|
|
|
|
|
|
def _render_overlay_text(
|
|
text: str,
|
|
size_px: int,
|
|
color: tuple[int, int, int],
|
|
font_spec: Any = None,
|
|
):
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
size_px = max(8, int(round(size_px)))
|
|
font = _load_overlay_font(size_px, ImageFont, font_spec=font_spec)
|
|
if font is not None:
|
|
probe = Image.new("RGBA", (1, 1), (0, 0, 0, 0))
|
|
probe_draw = ImageDraw.Draw(probe)
|
|
bbox = probe_draw.textbbox((0, 0), text, font=font)
|
|
width = max(1, bbox[2] - bbox[0])
|
|
height = max(1, bbox[3] - bbox[1])
|
|
text_image = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
|
text_draw = ImageDraw.Draw(text_image)
|
|
text_draw.text((-bbox[0], -bbox[1]), text, font=font, fill=(*color, 255))
|
|
return text_image
|
|
|
|
font = ImageFont.load_default()
|
|
probe = Image.new("L", (1, 1), 0)
|
|
probe_draw = ImageDraw.Draw(probe)
|
|
bbox = probe_draw.textbbox((0, 0), text, font=font)
|
|
width = max(1, bbox[2] - bbox[0])
|
|
height = max(1, bbox[3] - bbox[1])
|
|
mask = Image.new("L", (width, height), 0)
|
|
mask_draw = ImageDraw.Draw(mask)
|
|
mask_draw.text((-bbox[0], -bbox[1]), text, font=font, fill=255)
|
|
scale = max(1.0, size_px / max(1, height))
|
|
scaled_width = max(1, int(round(width * scale)))
|
|
scaled_height = max(1, int(round(height * scale)))
|
|
resampling = getattr(Image, "Resampling", Image)
|
|
# Preserve edge sharpness if we ever have to scale the bitmap fallback font.
|
|
scaled_mask = mask.resize((scaled_width, scaled_height), resample=resampling.NEAREST)
|
|
text_image = Image.new("RGBA", (scaled_width, scaled_height), (*color, 0))
|
|
text_image.putalpha(scaled_mask)
|
|
return text_image
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def _overlay_font_candidates() -> tuple[str, ...]:
|
|
candidates: list[str] = [
|
|
"/System/Library/Fonts/HelveticaNeue.ttc",
|
|
"/System/Library/Fonts/Helvetica.ttc",
|
|
"/System/Library/Fonts/Supplemental/Arial.ttf",
|
|
"/System/Library/Fonts/Supplemental/Helvetica.ttc",
|
|
"/System/Library/Fonts/Supplemental/Times New Roman.ttf",
|
|
"/Library/Fonts/Arial.ttf",
|
|
"/Library/Fonts/Helvetica.ttc",
|
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
|
"/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf",
|
|
]
|
|
|
|
try:
|
|
import PIL
|
|
|
|
pil_dir = Path(PIL.__file__).resolve().parent
|
|
candidates.extend([
|
|
str(pil_dir / "fonts" / "DejaVuSans.ttf"),
|
|
str(pil_dir / "Tests" / "fonts" / "DejaVuSans.ttf"),
|
|
])
|
|
except Exception:
|
|
pass
|
|
|
|
unique: list[str] = []
|
|
for candidate in candidates:
|
|
if candidate not in unique and Path(candidate).exists():
|
|
unique.append(candidate)
|
|
return tuple(unique)
|
|
|
|
|
|
def list_overlay_font_choices() -> tuple[str, ...]:
|
|
labels: list[str] = []
|
|
for candidate in _overlay_font_candidates():
|
|
label = Path(candidate).stem
|
|
if label and label not in labels:
|
|
labels.append(label)
|
|
return tuple(labels)
|
|
|
|
|
|
def _matching_overlay_font_candidates(family: str) -> list[str]:
|
|
key = _normalize_font_match_key(family)
|
|
if not key:
|
|
return []
|
|
|
|
matches: list[str] = []
|
|
for candidate in _overlay_font_candidates():
|
|
stem_key = _normalize_font_match_key(Path(candidate).stem)
|
|
if key == stem_key or key in stem_key or stem_key in key:
|
|
matches.append(candidate)
|
|
return matches
|
|
|
|
|
|
def _load_overlay_font(size_px: int, image_font_module, font_spec: Any = None) -> Any:
|
|
normalized = normalize_font_spec(font_spec)
|
|
|
|
if normalized is not None:
|
|
requested_path = normalized.get("path", "")
|
|
requested_family = normalized.get("family", "")
|
|
|
|
if requested_path:
|
|
try:
|
|
return image_font_module.truetype(requested_path, size_px)
|
|
except Exception:
|
|
pass
|
|
|
|
if requested_family:
|
|
try:
|
|
return image_font_module.truetype(requested_family, size_px)
|
|
except Exception:
|
|
pass
|
|
|
|
for candidate in _matching_overlay_font_candidates(requested_family):
|
|
try:
|
|
return image_font_module.truetype(candidate, size_px)
|
|
except Exception:
|
|
continue
|
|
|
|
for candidate in _overlay_font_candidates():
|
|
try:
|
|
return image_font_module.truetype(candidate, size_px)
|
|
except Exception:
|
|
continue
|
|
|
|
try:
|
|
return image_font_module.truetype("DejaVuSans.ttf", size_px)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _normalize_markup_color(color: object, default: str = "#ffd54f") -> str:
|
|
if isinstance(color, str):
|
|
text = color.strip()
|
|
if len(text) == 4 and text.startswith("#"):
|
|
text = "#" + "".join(ch * 2 for ch in text[1:])
|
|
if len(text) == 7 and text.startswith("#"):
|
|
try:
|
|
int(text[1:], 16)
|
|
return text.lower()
|
|
except ValueError:
|
|
pass
|
|
return default
|
|
|
|
|
|
def _draw_arrow(draw, start: tuple[float, float], end: tuple[float, float], color: str, width: int):
|
|
dx = end[0] - start[0]
|
|
dy = end[1] - start[1]
|
|
length = float(np.hypot(dx, dy))
|
|
if length <= 1e-6:
|
|
radius = max(1.0, width / 2.0)
|
|
draw.ellipse(
|
|
(start[0] - radius, start[1] - radius, start[0] + radius, start[1] + radius),
|
|
fill=color,
|
|
)
|
|
return
|
|
|
|
ux = dx / length
|
|
uy = dy / length
|
|
head_length = max(10.0, width * 4.0)
|
|
head_width = max(8.0, width * 3.0)
|
|
shaft_end = (
|
|
end[0] - ux * head_length,
|
|
end[1] - uy * head_length,
|
|
)
|
|
draw.line((start, shaft_end), fill=color, width=width)
|
|
px = -uy
|
|
py = ux
|
|
left = (shaft_end[0] + px * head_width / 2.0, shaft_end[1] + py * head_width / 2.0)
|
|
right = (shaft_end[0] - px * head_width / 2.0, shaft_end[1] - py * head_width / 2.0)
|
|
draw.polygon([end, left, right], fill=color)
|
|
|
|
|
|
def _preview_markup_stroke_width(width: int, image_width: int, image_height: int) -> int:
|
|
width = max(1, int(width))
|
|
longest_dim = max(1, int(image_width), int(image_height))
|
|
scale = max(1.0, longest_dim / float(PREVIEW_MARKUP_REFERENCE_DIM))
|
|
return max(1, int(round(width * scale)))
|
|
|
|
|
|
def _sanitize_markup_shapes(shapes: Any) -> list[dict[str, Any]]:
|
|
if not isinstance(shapes, list):
|
|
return []
|
|
|
|
parsed = []
|
|
for shape in shapes:
|
|
if not isinstance(shape, dict):
|
|
continue
|
|
kind = str(shape.get("kind", "")).strip().lower()
|
|
if kind not in {"line", "rectangle", "circle", "arrow"}:
|
|
continue
|
|
try:
|
|
x1 = float(shape.get("x1"))
|
|
y1 = float(shape.get("y1"))
|
|
x2 = float(shape.get("x2"))
|
|
y2 = float(shape.get("y2"))
|
|
width = int(round(float(shape.get("width", 3))))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if not all(np.isfinite(value) for value in (x1, y1, x2, y2)):
|
|
continue
|
|
parsed.append({
|
|
"kind": kind,
|
|
"x1": float(np.clip(x1, 0.0, 1.0)),
|
|
"y1": float(np.clip(y1, 0.0, 1.0)),
|
|
"x2": float(np.clip(x2, 0.0, 1.0)),
|
|
"y2": float(np.clip(y2, 0.0, 1.0)),
|
|
"width": max(1, min(128, width)),
|
|
"color": _normalize_markup_color(shape.get("color")),
|
|
})
|
|
return parsed
|
|
|
|
|
|
def _annotation_context_from_field(field: DataField, colormap: Any) -> dict[str, Any]:
|
|
legend_min, legend_mid, legend_max = _display_value_range(field)
|
|
return {
|
|
"xreal": float(field.xreal),
|
|
"si_unit_xy": str(field.si_unit_xy),
|
|
"legend_min": float(legend_min),
|
|
"legend_mid": float(legend_mid),
|
|
"legend_max": float(legend_max),
|
|
"legend_unit": str(field.si_unit_z),
|
|
"colormap": normalize_colormap_spec(colormap, fallback=field.colormap),
|
|
}
|
|
|
|
|
|
def _annotation_context_from_image(image: Any) -> dict[str, Any] | None:
|
|
metadata = image_metadata(image)
|
|
context = metadata.get("annotation_context")
|
|
return deepcopy(context) if isinstance(context, dict) else None
|
|
|
|
|
|
def _apply_annotation_overlay_from_context(
|
|
image: np.ndarray,
|
|
context: dict[str, Any],
|
|
spec: dict[str, Any],
|
|
) -> np.ndarray:
|
|
from PIL import Image, ImageDraw
|
|
|
|
show_scale_bar = bool(spec.get("show_scale_bar", True))
|
|
show_color_map = bool(spec.get("show_color_map", True))
|
|
text_size = float(spec.get("text_size", 14.0))
|
|
text_size = float(np.clip(text_size, 6.0, 96.0)) if np.isfinite(text_size) else 14.0
|
|
font_spec = normalize_font_spec(spec.get("font"))
|
|
|
|
current = np.asarray(image, dtype=np.uint8)
|
|
if current.ndim == 2:
|
|
current = np.repeat(current[:, :, np.newaxis], 3, axis=2)
|
|
|
|
base_font_px = max(6, int(round(text_size)))
|
|
|
|
xreal_raw = context.get("xreal")
|
|
xreal = float(xreal_raw) if xreal_raw is not None else 0.0
|
|
si_unit_xy = str(context.get("si_unit_xy", "") or "")
|
|
legend_unit = str(context.get("legend_unit", "") or "")
|
|
legend_min_raw = context.get("legend_min")
|
|
legend_mid_raw = context.get("legend_mid")
|
|
legend_max_raw = context.get("legend_max")
|
|
legend_min = float(legend_min_raw) if legend_min_raw is not None else 0.0
|
|
legend_mid = float(legend_mid_raw) if legend_mid_raw is not None else 0.0
|
|
legend_max = float(legend_max_raw) if legend_max_raw is not None else 0.0
|
|
colormap = normalize_colormap_spec(context.get("colormap", "gray"), fallback="gray")
|
|
has_scale_bar = np.isfinite(xreal) and xreal > 0 and bool(si_unit_xy)
|
|
has_color_legend = (
|
|
np.isfinite(legend_min)
|
|
and np.isfinite(legend_mid)
|
|
and np.isfinite(legend_max)
|
|
and bool(legend_unit)
|
|
)
|
|
height, current_width = current.shape[:2]
|
|
|
|
legend_pad_x = max(8, int(round(base_font_px * 0.45)))
|
|
legend_gap_x = max(8, int(round(base_font_px * 0.35)))
|
|
legend_gradient_width = max(12, int(round(max(current_width * 0.05, base_font_px * 0.75))))
|
|
legend_label_images: list[Image.Image] = []
|
|
if show_color_map and has_color_legend:
|
|
legend_label_images = [
|
|
_render_overlay_text(
|
|
_format_with_unit(value, legend_unit),
|
|
base_font_px,
|
|
(20, 20, 20),
|
|
font_spec=font_spec,
|
|
)
|
|
for value in (legend_max, legend_mid, legend_min)
|
|
]
|
|
max_legend_label_width = max((label.size[0] for label in legend_label_images), default=0)
|
|
default_legend_width = max(72, int(round(current_width * 0.18))) if show_color_map else 0
|
|
legend_width = max(
|
|
default_legend_width,
|
|
legend_pad_x * 2 + legend_gradient_width + legend_gap_x + max_legend_label_width,
|
|
) if show_color_map else 0
|
|
|
|
canvas_width = current_width + legend_width
|
|
canvas = np.full((height, canvas_width, 3), 255, dtype=np.uint8)
|
|
canvas[:, :current_width] = current
|
|
|
|
pil_image = Image.fromarray(canvas)
|
|
draw = ImageDraw.Draw(pil_image)
|
|
|
|
if show_scale_bar and has_scale_bar and current_width > 0:
|
|
target_real = xreal / 5.0
|
|
bar_real = _nice_length(target_real)
|
|
if bar_real > 0:
|
|
px_per_real = current_width / xreal
|
|
bar_px = max(1, int(round(bar_real * px_per_real)))
|
|
margin_x = max(8, current_width // 24)
|
|
margin_y = max(8, height // 24)
|
|
bar_height = max(3, int(round(height * 0.012)))
|
|
bar_px = min(bar_px, max(1, current_width - 2 * margin_x))
|
|
x0 = margin_x
|
|
x1 = x0 + bar_px
|
|
y1 = height - margin_y
|
|
y0 = y1 - bar_height
|
|
text = _format_with_unit(bar_real, si_unit_xy)
|
|
text_image = _render_overlay_text(text, base_font_px, (255, 255, 255), font_spec=font_spec)
|
|
text_w, text_h = text_image.size
|
|
box_pad_x = max(4, int(round(base_font_px * 0.35)))
|
|
box_pad_y = max(3, int(round(base_font_px * 0.22)))
|
|
label_gap_y = max(2, int(round(base_font_px * 0.18)))
|
|
bar_pad_y = max(4, int(round(base_font_px * 0.25)))
|
|
bg_left = max(0, x0 - box_pad_x)
|
|
bg_top = max(0, y0 - text_h - label_gap_y - box_pad_y * 2)
|
|
bg_right = min(canvas_width, max(x1 + box_pad_x, bg_left + text_w + box_pad_x * 2))
|
|
bg_bottom = min(height, y1 + bar_pad_y)
|
|
draw.rectangle((bg_left, bg_top, bg_right, bg_bottom), fill=(0, 0, 0))
|
|
draw.rectangle((x0, y0, x1, y1), fill=(255, 255, 255))
|
|
pil_image.paste(text_image, (bg_left + box_pad_x, bg_top + box_pad_y), text_image)
|
|
|
|
if show_color_map and has_color_legend and legend_width > 0:
|
|
panel_x0 = current_width
|
|
draw.rectangle((panel_x0, 0, canvas_width, height), fill=(245, 245, 245))
|
|
grad_x0 = panel_x0 + legend_pad_x
|
|
grad_w = legend_gradient_width
|
|
grad_y0 = max(10, max(height // 18, int(round(base_font_px * 0.5))))
|
|
grad_y1 = max(grad_y0 + 10, height - grad_y0)
|
|
grad_h = grad_y1 - grad_y0
|
|
gradient = np.linspace(1.0, 0.0, grad_h, dtype=np.float64)[:, np.newaxis]
|
|
gradient = np.repeat(gradient, grad_w, axis=1)
|
|
gradient_rgb = colormap_to_uint8(gradient, colormap)
|
|
pil_image.paste(Image.fromarray(gradient_rgb), (grad_x0, grad_y0))
|
|
draw.rectangle((grad_x0, grad_y0, grad_x0 + grad_w, grad_y1), outline=(40, 40, 40), width=1)
|
|
|
|
label_centers = [grad_y0, grad_y0 + grad_h // 2, grad_y1]
|
|
text_x = grad_x0 + grad_w + legend_gap_x
|
|
for text_image, y_center in zip(legend_label_images, label_centers):
|
|
text_y = int(round(y_center - text_image.size[1] / 2))
|
|
text_y = max(0, min(height - text_image.size[1], text_y))
|
|
pil_image.paste(text_image, (text_x, text_y), text_image)
|
|
|
|
return np.asarray(pil_image, dtype=np.uint8)
|
|
|
|
|
|
def _apply_annotation_overlay(
|
|
image: np.ndarray,
|
|
field: DataField,
|
|
colormap: Any,
|
|
spec: dict[str, Any],
|
|
) -> np.ndarray:
|
|
return _apply_annotation_overlay_from_context(
|
|
image,
|
|
_annotation_context_from_field(field, colormap),
|
|
spec,
|
|
)
|
|
|
|
|
|
def _apply_markup_overlay(image: np.ndarray, field: DataField | None, spec: dict[str, Any]) -> np.ndarray:
|
|
from PIL import Image, ImageDraw
|
|
|
|
current = np.asarray(image, dtype=np.uint8)
|
|
if current.ndim == 2:
|
|
current = np.repeat(current[:, :, np.newaxis], 3, axis=2)
|
|
|
|
pil_image = Image.fromarray(current.copy())
|
|
draw = ImageDraw.Draw(pil_image)
|
|
field_width = max(1, int(field.xres)) if isinstance(field, DataField) else max(1, current.shape[1])
|
|
field_height = max(1, int(field.yres)) if isinstance(field, DataField) else max(1, current.shape[0])
|
|
|
|
for shape in _sanitize_markup_shapes(spec.get("shapes")):
|
|
x1 = float(shape["x1"]) * field_width
|
|
y1 = float(shape["y1"]) * field_height
|
|
x2 = float(shape["x2"]) * field_width
|
|
y2 = float(shape["y2"]) * field_height
|
|
color = str(shape["color"])
|
|
stroke_width = _preview_markup_stroke_width(int(shape["width"]), field_width, field_height)
|
|
kind = str(shape["kind"])
|
|
|
|
if kind == "line":
|
|
draw.line(((x1, y1), (x2, y2)), fill=color, width=stroke_width)
|
|
elif kind == "rectangle":
|
|
draw.rectangle((x1, y1, x2, y2), outline=color, width=stroke_width)
|
|
elif kind == "circle":
|
|
draw.ellipse((x1, y1, x2, y2), outline=color, width=stroke_width)
|
|
elif kind == "arrow":
|
|
_draw_arrow(draw, (x1, y1), (x2, y2), color, stroke_width)
|
|
|
|
return np.asarray(pil_image, dtype=np.uint8)
|
|
|
|
|
|
def _angle_label_base_position(spec: dict[str, Any]) -> tuple[float, float]:
|
|
x1 = float(np.clip(spec.get("x1", 0.0), 0.0, 1.0))
|
|
y1 = float(np.clip(spec.get("y1", 0.0), 0.0, 1.0))
|
|
xm = float(np.clip(spec.get("xm", 0.5), 0.0, 1.0))
|
|
ym = float(np.clip(spec.get("ym", 0.5), 0.0, 1.0))
|
|
x2 = float(np.clip(spec.get("x2", 1.0), 0.0, 1.0))
|
|
y2 = float(np.clip(spec.get("y2", 0.0), 0.0, 1.0))
|
|
|
|
va = np.array([x1 - xm, y1 - ym], dtype=np.float64)
|
|
vb = np.array([x2 - xm, y2 - ym], dtype=np.float64)
|
|
len_a = float(np.hypot(va[0], va[1]))
|
|
len_b = float(np.hypot(vb[0], vb[1]))
|
|
if len_a <= 1e-6 or len_b <= 1e-6:
|
|
return (float(np.clip(xm, 0.0, 1.0)), float(np.clip(ym - 0.14, 0.0, 1.0)))
|
|
|
|
unit = np.array([
|
|
va[0] / len_a + vb[0] / len_b,
|
|
va[1] / len_a + vb[1] / len_b,
|
|
], dtype=np.float64)
|
|
unit_len = float(np.hypot(unit[0], unit[1]))
|
|
if unit_len <= 1e-6:
|
|
bisector = np.array([0.0, -1.0], dtype=np.float64)
|
|
else:
|
|
bisector = unit / unit_len
|
|
|
|
return (
|
|
float(np.clip(xm + bisector[0] * 0.14, 0.0, 1.0)),
|
|
float(np.clip(ym + bisector[1] * 0.14, 0.0, 1.0)),
|
|
)
|
|
|
|
|
|
def _sanitize_angle_overlay_stroke_width(value: Any, default: float = 1.35) -> float:
|
|
try:
|
|
numeric = float(value)
|
|
except (TypeError, ValueError):
|
|
numeric = default
|
|
if not np.isfinite(numeric):
|
|
numeric = default
|
|
return float(np.clip(numeric, 0.35, 6.0))
|
|
|
|
|
|
def _sanitize_angle_overlay_color(value: Any, default: str = "#ff0000") -> str:
|
|
if isinstance(value, str):
|
|
text = value.strip()
|
|
if len(text) == 4 and text.startswith("#"):
|
|
text = "#" + "".join(ch * 2 for ch in text[1:])
|
|
if len(text) == 7 and text.startswith("#"):
|
|
try:
|
|
int(text[1:], 16)
|
|
return text.lower()
|
|
except ValueError:
|
|
pass
|
|
return default
|
|
|
|
|
|
def _hex_to_rgb(color: str) -> tuple[int, int, int]:
|
|
return tuple(int(color[index:index + 2], 16) for index in (1, 3, 5))
|
|
|
|
|
|
def _mix_rgb(color_a: tuple[int, int, int], color_b: tuple[int, int, int], weight: float) -> tuple[int, int, int]:
|
|
alpha = float(np.clip(weight, 0.0, 1.0))
|
|
return tuple(
|
|
int(round(a * (1.0 - alpha) + b * alpha))
|
|
for a, b in zip(color_a, color_b)
|
|
)
|
|
|
|
|
|
def _draw_round_line(draw, start: tuple[float, float], end: tuple[float, float], fill, width: int) -> None:
|
|
width = max(1, int(round(width)))
|
|
draw.line((start, end), fill=fill, width=width)
|
|
radius = max(1, int(np.ceil(width / 2.0)))
|
|
for px, py in (start, end):
|
|
draw.ellipse((px - radius, py - radius, px + radius, py + radius), fill=fill)
|
|
|
|
|
|
def _draw_dashed_polyline(
|
|
draw,
|
|
points: list[tuple[float, float]],
|
|
fill,
|
|
width: int,
|
|
dash_length: float,
|
|
gap_length: float,
|
|
) -> None:
|
|
if len(points) < 2:
|
|
return
|
|
|
|
dash_length = max(1.0, float(dash_length))
|
|
gap_length = max(1.0, float(gap_length))
|
|
cycle_length = dash_length + gap_length
|
|
traversed = 0.0
|
|
|
|
for start, end in zip(points[:-1], points[1:]):
|
|
seg_len = float(np.hypot(end[0] - start[0], end[1] - start[1]))
|
|
if seg_len <= 1e-6:
|
|
continue
|
|
if ((traversed + 0.5 * seg_len) % cycle_length) < dash_length:
|
|
_draw_round_line(draw, start, end, fill=fill, width=width)
|
|
traversed += seg_len
|
|
|
|
|
|
def _apply_angle_measure_overlay(image: np.ndarray, field: DataField | None, spec: dict[str, Any]) -> np.ndarray:
|
|
from PIL import Image, ImageDraw
|
|
|
|
current = np.asarray(image, dtype=np.uint8)
|
|
if current.ndim == 2:
|
|
current = np.repeat(current[:, :, np.newaxis], 3, axis=2)
|
|
|
|
pil_image = Image.fromarray(current.copy()).convert("RGBA")
|
|
draw = ImageDraw.Draw(pil_image, "RGBA")
|
|
field_width = max(1, int(field.xres)) if isinstance(field, DataField) else max(1, current.shape[1])
|
|
field_height = max(1, int(field.yres)) if isinstance(field, DataField) else max(1, current.shape[0])
|
|
longest_dim = max(field_width, field_height)
|
|
|
|
x1 = float(np.clip(spec.get("x1", 0.0), 0.0, 1.0))
|
|
y1 = float(np.clip(spec.get("y1", 0.0), 0.0, 1.0))
|
|
xm = float(np.clip(spec.get("xm", 0.5), 0.0, 1.0))
|
|
ym = float(np.clip(spec.get("ym", 0.5), 0.0, 1.0))
|
|
x2 = float(np.clip(spec.get("x2", 1.0), 0.0, 1.0))
|
|
y2 = float(np.clip(spec.get("y2", 0.0), 0.0, 1.0))
|
|
label_dx = float(spec.get("label_dx", 0.0) or 0.0)
|
|
label_dy = float(spec.get("label_dy", 0.0) or 0.0)
|
|
angle_deg = float(spec.get("angle_deg", 0.0) or 0.0)
|
|
color_hex = _sanitize_angle_overlay_color(spec.get("color", "#ff0000"))
|
|
stroke_width = _sanitize_angle_overlay_stroke_width(spec.get("stroke_width", spec.get("line_thickness", 1.35)))
|
|
base_rgb = _hex_to_rgb(color_hex)
|
|
arc_rgb = _mix_rgb(base_rgb, (255, 255, 255), 0.42)
|
|
badge_text_rgb = _mix_rgb(base_rgb, (255, 255, 255), 0.72)
|
|
badge_border_rgb = _mix_rgb(base_rgb, (255, 255, 255), 0.32)
|
|
|
|
line_width = max(1, int(round(longest_dim * stroke_width / 100.0)))
|
|
arc_width = line_width
|
|
line_color = (*base_rgb, 255)
|
|
arc_color = (*arc_rgb, 242)
|
|
|
|
points = [
|
|
(x1 * field_width, y1 * field_height),
|
|
(xm * field_width, ym * field_height),
|
|
(x2 * field_width, y2 * field_height),
|
|
]
|
|
_draw_round_line(draw, points[0], points[1], fill=line_color, width=line_width)
|
|
_draw_round_line(draw, points[1], points[2], fill=line_color, width=line_width)
|
|
|
|
va = np.array([x1 - xm, y1 - ym], dtype=np.float64)
|
|
vb = np.array([x2 - xm, y2 - ym], dtype=np.float64)
|
|
len_a = float(np.hypot(va[0], va[1]))
|
|
len_b = float(np.hypot(vb[0], vb[1]))
|
|
if len_a > 1e-6 and len_b > 1e-6:
|
|
radius = min(0.12, 0.38 * min(len_a, len_b))
|
|
start_angle = float(np.arctan2(va[1], va[0]))
|
|
delta = float(np.arctan2(va[0] * vb[1] - va[1] * vb[0], np.dot(va, vb)))
|
|
arc_points = []
|
|
for theta in np.linspace(start_angle, start_angle + delta, 48):
|
|
arc_points.append((
|
|
(xm + radius * np.cos(theta)) * field_width,
|
|
(ym + radius * np.sin(theta)) * field_height,
|
|
))
|
|
if len(arc_points) >= 2:
|
|
_draw_dashed_polyline(
|
|
draw,
|
|
arc_points,
|
|
fill=arc_color,
|
|
width=arc_width,
|
|
dash_length=max(4.0, longest_dim * 0.05),
|
|
gap_length=max(3.0, longest_dim * 0.03),
|
|
)
|
|
|
|
base_label_x, base_label_y = _angle_label_base_position(spec)
|
|
label_x = float(np.clip(base_label_x + label_dx, 0.0, 1.0))
|
|
label_y = float(np.clip(base_label_y + label_dy, 0.0, 1.0))
|
|
label_text = f"{angle_deg:.1f} deg"
|
|
text_image = _render_overlay_text(
|
|
label_text,
|
|
max(10, int(round(longest_dim / 26.0))),
|
|
badge_text_rgb,
|
|
font_spec={"family": "Helvetica Neue"},
|
|
)
|
|
|
|
bg_pad_x = max(5, int(round(text_image.size[0] * 0.16)))
|
|
bg_pad_y = max(3, int(round(text_image.size[1] * 0.18)))
|
|
bg_width = text_image.size[0] + 2 * bg_pad_x
|
|
bg_height = text_image.size[1] + 2 * bg_pad_y
|
|
center_x = int(round(label_x * field_width))
|
|
center_y = int(round(label_y * field_height))
|
|
bg_left = max(0, min(field_width - bg_width, center_x - bg_width // 2))
|
|
bg_top = max(0, min(field_height - bg_height, center_y - bg_height // 2))
|
|
bg_right = bg_left + bg_width
|
|
bg_bottom = bg_top + bg_height
|
|
|
|
shadow_offset = max(1, int(round(bg_height * 0.08)))
|
|
draw.rounded_rectangle(
|
|
(
|
|
bg_left,
|
|
min(field_height, bg_top + shadow_offset),
|
|
bg_right,
|
|
min(field_height, bg_bottom + shadow_offset),
|
|
),
|
|
radius=max(6, bg_height // 2),
|
|
fill=(15, 23, 42, 86),
|
|
)
|
|
draw.rounded_rectangle(
|
|
(bg_left, bg_top, bg_right, bg_bottom),
|
|
radius=max(6, bg_height // 2),
|
|
fill=(15, 23, 42, 230),
|
|
outline=(*badge_border_rgb, 115),
|
|
width=1,
|
|
)
|
|
pil_image.paste(text_image, (bg_left + bg_pad_x, bg_top + bg_pad_y), text_image)
|
|
|
|
return np.asarray(pil_image.convert("RGB"), dtype=np.uint8)
|
|
|
|
|
|
def render_datafield_preview(df: DataField, colormap: Any = "gray") -> np.ndarray:
|
|
current = datafield_to_uint8(df, colormap)
|
|
for overlay in df.overlays:
|
|
if not isinstance(overlay, dict):
|
|
continue
|
|
kind = str(overlay.get("kind", "")).strip().lower()
|
|
if kind == "annotation":
|
|
current = _apply_annotation_overlay(current, df, colormap, overlay)
|
|
elif kind == "markup":
|
|
current = _apply_markup_overlay(current, df, overlay)
|
|
elif kind == "angle_measure":
|
|
current = _apply_angle_measure_overlay(current, df, overlay)
|
|
return current
|
|
|
|
|
|
def image_to_uint8(image: np.ndarray) -> np.ndarray:
|
|
"""
|
|
Convert an IMAGE (float or uint8, 2-D or 3-D) to uint8 (H,W,3) or (H,W) for PIL.
|
|
"""
|
|
if image.dtype == np.uint8:
|
|
return image
|
|
# float — normalize to [0, 255]
|
|
imin, imax = image.min(), image.max()
|
|
if imax > imin:
|
|
out = (image - imin) / (imax - imin) * 255.0
|
|
else:
|
|
out = np.zeros_like(image)
|
|
return out.astype(np.uint8)
|
|
|
|
|
|
def encode_preview(arr: np.ndarray) -> str:
|
|
"""
|
|
Encode a uint8 numpy array as a base64 data URI (PNG).
|
|
arr: (H, W) grayscale or (H, W, 3) RGB, uint8.
|
|
"""
|
|
import base64
|
|
import io
|
|
from PIL import Image
|
|
|
|
img = Image.fromarray(arr)
|
|
buf = io.BytesIO()
|
|
img.save(buf, format="PNG", compress_level=1, optimize=False)
|
|
b64 = base64.b64encode(buf.getvalue()).decode()
|
|
return f"data:image/png;base64,{b64}"
|
|
|
|
|
|
def _apply_named_colormap(normalized: np.ndarray, name: str) -> np.ndarray:
|
|
"""Map a [0, 1] float array to (H, W, 3) uint8 via a baked 256-entry LUT."""
|
|
from backend.baked_colormaps import get_colormap_lut
|
|
lut = get_colormap_lut(name) # (256, 3) uint8
|
|
indices = np.rint(np.clip(normalized, 0.0, 1.0) * 255.0).astype(np.uint8)
|
|
return lut[indices.ravel()].reshape(normalized.shape + (3,))
|