diff --git a/AGENTS.md b/AGENTS.md index 0cbc82225c..cba511005e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ AGAVE (Advanced GPU Accelerated Volume Explorer) is a C++17/Qt6 desktop applicat | `agave_app/` | Qt6 GUI layer — widgets, dialogs, dock panels, OpenGL viewport (`GLView3D`) | | `renderlib/` | Core rendering engine — image I/O (`io/`), graphics abstraction (`gfxapi/`) with OpenGL and Vulkan backends (`gfxOpenGL/`, `gfxVulkan/`), camera, scene, gesture handling (`gesture/`), JSON serialization (`json/`) | | `agave_pyclient/` | Python WebSocket client for remote control of AGAVE in server mode | -| `agave_pyvk/` | Standalone Python client including all data loading and rendering functionality via the Vulkan graphics backend | +| `agave_pyvk/` | Standalone Python client wrapping renderlib, including all data loading and rendering functionality via the Vulkan graphics backend | | `test/` | C++ unit tests (Catch2) | | `webclient/` | JavaScript client | diff --git a/README.md b/README.md index 6ca3923346..06b3dfe88e 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,17 @@ or searches the platform's usual Vulkan SDK installation directory. You may also refer to the github actions workflows for reference building AGAVE. +### Activate your Python environment first + +Before configuring CMake, activate the Python virtual environment you want +`agave_pyvk` installed into. CMake locates the interpreter from your `PATH` +and records it in the build tree; the ABI-specific `agave_py2` module +(`_native..so`) is compiled against **that** interpreter. Later +`cmake --build` invocations never re-detect Python, so building with a +different env active will silently produce a wrong-ABI extension. If you +switch envs, reconfigure explicitly (see +[`agave_pyvk/README.md`](agave_pyvk/README.md)). + ### Windows Make sure you are in an environment where vsvarsall has been run, e.g. a "VS2026 x64 Native Tools Command Prompt" @@ -121,10 +132,10 @@ If cmake fails please refer to the Dockerfile or the github actions workflows fo ### Iterative standalone Python development -The platform commands above create a persistent `build` directory. CMake uses -the active Python interpreter and C++ compiler environment, and its default -build compiles the shared static renderlib, the desktop application, -and the ABI-specific `agave_py2` native module: +With your target Python environment activated, the platform commands above +create a persistent `build` directory. CMake records that interpreter, and +its default build compiles the shared static renderlib, the desktop +application, and the ABI-specific `agave_py2` native module: ```console cmake --build . @@ -142,6 +153,10 @@ in the Python package. The editable install performs only normal Python packaging; it does not invoke CMake or rebuild renderlib. Python source changes are visible immediately. +If you later need to build the Python module for a _different_ interpreter +without rebuilding the rest of the tree, see the retargeting recipe in +[`agave_pyvk/README.md`](agave_pyvk/README.md). + Optional: To save a little time in building the Python module only, use the same platform-specific CMake configure command with these options: diff --git a/agave_app/ViewerState.cpp b/agave_app/ViewerState.cpp index f206601a01..07881d0297 100644 --- a/agave_app/ViewerState.cpp +++ b/agave_app/ViewerState.cpp @@ -94,30 +94,16 @@ stateToPythonScript(const Serialize::ViewerState& s) std::ostringstream ss; ss << "# AGAVE Python script (standalone)" << std::endl; ss << "# Generated by AGAVE version " << qApp->applicationVersion().toStdString() << std::endl; - ss << "# We recommend exiting any AGAVE instances before running this script." << std::endl; - ss << "# pip install agave_pyclient" << std::endl; + ss << "# AGAVE application path: " << qApp->applicationFilePath().toStdString() << std::endl; + ss << "# pip install agave_pyvk" << std::endl; ss << "# python myscript.py" << std::endl << std::endl; - ss << "import agave_pyclient as agave" << std::endl << std::endl; + ss << "import agave_pyvk as agave" << std::endl << std::endl; renderlib::RendererType rendererType = renderlib::RendererType_Pathtrace; if (s.rendererType == Serialize::RendererType_PID::RAYMARCH) { rendererType = renderlib::RendererType_Raymarch; } std::string mode = renderlib::rendererTypeToString(rendererType); - std::string appPath = qApp->applicationFilePath().toStdString(); - // Escape the path for embedding inside a Python double-quoted string literal. - std::string escapedAppPath; - escapedAppPath.reserve(appPath.size()); - for (char c : appPath) { - if (c == '\\' || c == '"') { - escapedAppPath.push_back('\\'); - } - escapedAppPath.push_back(c); - } - // TODO give user option to save a self-contained script or require agave server - // For now, most users will probably not mind that we are putting the executable - // path in the Python script. - // Pass auto_launch=False or omit agave_path to customize this behavior. - ss << "r = agave.AgaveRenderer(agave_path=\"" << escapedAppPath << "\", mode=\"" << mode << "\")" << std::endl; + ss << "r = agave.AgaveRenderer(mode=\"" << mode << "\")" << std::endl; ss << "with r:" << std::endl; std::string obj = " r."; diff --git a/agave_app/agaveGui.cpp b/agave_app/agaveGui.cpp index efbc598f82..b0ed4b1cbe 100644 --- a/agave_app/agaveGui.cpp +++ b/agave_app/agaveGui.cpp @@ -248,7 +248,7 @@ agaveGui::createActions() connect(m_dumpJsonAction, SIGNAL(triggered()), this, SLOT(saveJson())); m_dumpPythonAction = new QAction(tr("&Save to Python script"), this); - m_dumpPythonAction->setStatusTip(tr("Save a Python script usable with agave_pyclient")); + m_dumpPythonAction->setStatusTip(tr("Save a Python script usable with agave_pyvk")); connect(m_dumpPythonAction, SIGNAL(triggered()), this, SLOT(savePython())); m_testMeshAction = new QAction(tr("&Open mesh..."), this); @@ -1407,8 +1407,7 @@ agaveGui::appToViewerState() v.pathTracer.primaryStepSize = m_renderSettings.m_RenderSettings.m_StepSizeFactor; v.pathTracer.secondaryStepSize = m_renderSettings.m_RenderSettings.m_StepSizeFactorShadow; - v.pathTracer.multichannelBlendMode = - static_cast(m_renderSettings.m_RenderSettings.m_MultichannelBlendMode); + v.pathTracer.multichannelBlendMode = static_cast(m_renderSettings.m_RenderSettings.m_MultichannelBlendMode); if (m_appScene.m_volume) { for (size_t i = 0; i < m_appScene.m_volume->sizeC(); ++i) { diff --git a/agave_pyclient/agave_pyclient/agave.py b/agave_pyclient/agave_pyclient/agave.py index 8921919505..e0cd6324f9 100644 --- a/agave_pyclient/agave_pyclient/agave.py +++ b/agave_pyclient/agave_pyclient/agave.py @@ -967,18 +967,28 @@ def load_data_and_get_info( flushes the command buffer to the server, waits for the JSON text frame the server sends back, and returns the parsed dictionary. - The returned dict contains at least the following keys (see - ``LoadDataCommand::execute`` in ``renderlib/command.cpp``): + The returned dict is built by ``volumeInfoToJson`` in + ``renderlib/VolumeInfoToJson.cpp``, shared with every other volume + loading path, and contains at least the following keys: - ``commandId`` : int, id of the LOAD_DATA command + - ``name`` : str, the file path the volume came from - ``x``, ``y``, ``z``, ``c``, ``t`` : int, image extents - ``pixel_size_x/y/z`` : float, physical voxel size + - ``spatial_units`` : str - ``channel_names`` : list[str] - ``channel_min_intensity``: list[int], one value per channel - ``channel_max_intensity``: list[int], one value per channel - ``volume_dimensions`` : nested dict mirroring the C++ ``VolumeDimensions`` struct + The top-level fields describe the volume that was actually loaded, + after any ``channels``, ``region``, or ``multiresolution_level`` + subsetting. ``volume_dimensions`` describes the source file as the + reader reported it, so it may be larger. ``commandId`` is a transport + field and is the only key not also returned by the in-process + ``agave_pyvk`` loaders. + Note: any other commands queued in the buffer prior to calling this method will be flushed at the same time. diff --git a/agave_pyvk/README.md b/agave_pyvk/README.md index e3c508768a..f6d1692cd9 100644 --- a/agave_pyvk/README.md +++ b/agave_pyvk/README.md @@ -41,29 +41,83 @@ remaining native dependencies are the same ones used by renderlib. ## Editable development install -Configure the normal top-level AGAVE build using the Python environment where -the package will be installed. Then build renderlib, the AGAVE application, and -the ABI-specific native module from that build directory: +### How the build is organized + +`agave_pyvk` produces a single Python-linked artifact: the nanobind extension +`_native..so` (target `agave_py2`). Everything below it — `renderlib`, +libCZI, tensorstore, curl, and the AGAVE app — is plain C++ that does **not** +link `libpython` and therefore does **not** need to be rebuilt when you switch +Python interpreters. Only `agave_py2` is ABI-specific. + +`pip` never builds the extension in this project. The `pyproject.toml` uses +plain `setuptools`; the `.so` is produced by CMake and staged into +`agave_pyvk/agave_pyvk/` by the `stage_agave_pyvk` target. `pip install -e .` +just registers the pure-Python package and picks up whatever `.so` CMake last +staged. + +### First-time build + +Configure the top-level AGAVE build against the Python environment you intend +to install into. The interpreter is baked into the CMake cache at configure +time — it is not re-detected by `cmake --build`. ```console -cmake --build . +cmake -S -B /build -G Ninja \ + -DPython_EXECUTABLE="$(python -c 'import sys; print(sys.executable)')" \ + -DCMAKE_BUILD_TYPE=Release +cmake --build /build --target stage_agave_pyvk ``` -From the repository's `agave_pyvk` directory, install the package into that -same Python environment: +Then from `agave_pyvk/`: ```console python -m pip install -e . ``` -The CMake build stages `_native` and its runtime dependencies into the package -directory. Pip performs only normal Python packaging and does not invoke CMake. -Python source edits are visible immediately. +Python source edits are visible immediately; native edits require re-running +the `stage_agave_pyvk` target. + +### Switching Python interpreters — minimal rebuild + +The recommended workflow is: **activate the target Python environment before +you touch CMake.** `cmake --build .` alone will never re-detect the interpreter +— `Python_EXECUTABLE` is pinned in `CMakeCache.txt` at first configure — so +building from the wrong env silently produces a wrong-ABI extension. + +If you want to retarget an existing `build` tree at a different Python without +rebuilding `renderlib` or the app, activate the new env and then run two +commands from the build directory: + +```console +cd /build +cmake -DPython_EXECUTABLE="$(python -c 'import sys; print(sys.executable)')" .. +cmake --build . --target stage_agave_pyvk +``` + +FindPython re-detects because `Python_EXECUTABLE` changed; nanobind's ABI +suffix cache is already cleared on every reconfigure by +`agave_pyvk/CMakeLists.txt`. Only `agave_py2` recompiles (a few seconds). +`stage_agave_pyvk` deletes any previously staged `_native*.so` / `_native*.pyd` +from the package directory and installs the fresh one, so the package never +ends up with two mismatched extensions. + +If `import agave_pyvk` reports an ABI mismatch, the current interpreter and the +staged `.so` disagree. Re-run the two commands above from the environment you +want to use. + +### Standalone build (skip the AGAVE app and tests) + +```console +cmake -S -B /build -G Ninja \ + -DPython_EXECUTABLE="$(python -c 'import sys; print(sys.executable)')" \ + -DAGAVE_BUILD_APP=OFF -DAGAVE_BUILD_TESTS=OFF -DAGAVE_BUILD_PYVK=ON \ + -DCMAKE_BUILD_TYPE=Release +cmake --build /build --target stage_agave_pyvk +``` -From this point you can import agave_pyvk and its AgaveRenderer class. +### Building a wheel -To produce a wheel file from the same staged native build, install the `build` -frontend and run: +Once the native module is staged for the desired interpreter: ```console python -m pip install build diff --git a/agave_pyvk/agave_pyvk/__init__.py b/agave_pyvk/agave_pyvk/__init__.py index 4e45a98e6d..c866830366 100644 --- a/agave_pyvk/agave_pyvk/__init__.py +++ b/agave_pyvk/agave_pyvk/__init__.py @@ -1,6 +1,7 @@ """Headless in-process Vulkan bindings for AGAVE.""" import os +import sys from pathlib import Path @@ -8,6 +9,58 @@ if os.name == "nt": _dll_directory_handle = os.add_dll_directory(str(Path(__file__).resolve().parent)) + +def _check_native_abi() -> None: + """Fail with a clear message if the staged _native extension is ABI-incompatible. + + The extension is built by CMake and staged into this package; there is no + automatic rebuild when the active interpreter changes. Without this check, + a mismatch surfaces as a misleading "circular import" ImportError. + """ + pkg_dir = Path(__file__).resolve().parent + current_tag = sys.implementation.cache_tag # e.g. "cpython-314" + if os.name == "nt": + ext_glob, ext_suffix = "_native*.pyd", ".pyd" + else: + ext_glob, ext_suffix = "_native*.so", ".so" + + candidates = sorted(pkg_dir.glob(ext_glob)) + if not candidates: + return # let the normal import machinery raise ModuleNotFoundError + + def _tag(path: Path) -> str: + # For example: + # "_native.cpython-312-x86_64-linux-gnu.so" + # -> "cpython-312-x86_64-linux-gnu" + name = path.name[: -len(ext_suffix)] + prefix = "_native." + + if not name.startswith(prefix): + return "" + + return name[len(prefix):] + + current_tag = sys.implementation.cache_tag + + def is_compatible(path: Path) -> bool: + tag = _tag(path) + return tag == current_tag or tag.startswith(f"{current_tag}-") + + if any(is_compatible(path) for path in candidates): + return + + found = ", ".join(sorted({_tag(p) or p.name for p in candidates})) + raise ImportError( + f"agave_pyvk native extension ABI mismatch: found [{found}] in {pkg_dir}, " + f"but current interpreter is {current_tag} ({sys.executable}). " + "Reconfigure the CMake build with " + "-DPython_EXECUTABLE=$(python -c 'import sys; print(sys.executable)') " + "and rebuild the stage_agave_pyvk target." + ) + + +_check_native_abi() + from .agave import AgaveRenderer __author__ = "Daniel Toloudis, Allen Institute" diff --git a/agave_pyvk/agave_pyvk/animation.py b/agave_pyvk/agave_pyvk/animation.py new file mode 100644 index 0000000000..11624e7c66 --- /dev/null +++ b/agave_pyvk/agave_pyvk/animation.py @@ -0,0 +1,452 @@ +"""Animation timeline for AGAVE scenes. + +Three concepts, following the vocabulary of DCC animation systems: + +``Channel`` + The timeline of values for one named parameter set, plus the single AGAVE + call that applies it. ``camera.eye`` holds 3 floats applied with ``r.eye(*v)``. + +``Driver`` + Occupies an inclusive frame span and produces values for one or more + channels. A driver may be a higher-level construct: ``CameraOrbit`` takes one + angle and writes both ``camera.eye`` and ``camera.up``. + +``Timeline`` + Owns the drivers, resolves seconds to integer frames, samples every channel + each frame, and emits only the channels whose value changed. + +Derived math lives upstream in the driver, which keeps every channel a 1:1 +mapping onto a single AGAVE call. +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Dict, Sequence, Tuple, Union + +Value = Tuple[float, ...] +Ease = Callable[[float], float] + +Point = Tuple[int, Union[float, Sequence[float]]] + + +# --- errors ----------------------------------------------------------------- + + +class AnimationError(Exception): + """Base class for every timeline error.""" + + +class UnknownChannelError(AnimationError): + """A channel name is not in the registry.""" + + +class ArityError(AnimationError): + """A value has the wrong number of components for its channel.""" + + +class MissingInitialError(AnimationError): + """A driven channel has no declared initial value.""" + + +# --- channel specs ---------------------------------------------------------- + + +@dataclass(frozen=True) +class ChannelSpec: + """How many floats a channel carries, and the AGAVE call that applies them.""" + + arity: int + setter: Callable[[Any, Value], None] + + +# --- easing ----------------------------------------------------------------- +# Unitless: they map the unit interval onto itself, so they are independent of +# both fps and duration. + + +def linear(u: float) -> float: + return u + + +def ease_in(u: float) -> float: + return u * u + + +def ease_out(u: float) -> float: + return 1.0 - (1.0 - u) * (1.0 - u) + + +def ease_in_out(u: float) -> float: + """Smoothstep.""" + return u * u * (3.0 - 2.0 * u) + + +def as_value(v: Union[float, Sequence[float]]) -> Value: + """Promote a bare scalar to a 1-tuple; leave sequences as float tuples.""" + if isinstance(v, (int, float)): + return (float(v),) + return tuple(float(x) for x in v) + + +def lerp(a: Value, b: Value, u: float) -> Value: + return tuple(x + (y - x) * u for x, y in zip(a, b)) + + +# --- drivers ---------------------------------------------------------------- + + +class Driver(ABC): + """Produces values for one or more channels over an inclusive frame span. + + A driver also reports a weight at each frame. Where several drivers cover + the same channel at the same frame, the channel's value is their weighted + mean, ``sum(w * v) / sum(w)``. The default weight is 1.0, so equally + weighted drivers simply average; a weight that varies over time crossfades. + """ + + start: int + end: int + _weight: Union[float, Callable[[int], float]] = 1.0 + + def weight(self, frame: int) -> float: + """This driver's blending weight at ``frame``. Defaults to 1.0.""" + w = self._weight + return float(w(frame)) if callable(w) else float(w) + + @abstractmethod + def writes(self) -> Tuple[str, ...]: + """Names of the channels this driver drives.""" + + @abstractmethod + def evaluate(self, frame: int) -> Dict[str, Value]: + """Values for this driver's channels at ``frame``, which lies in span.""" + + def progress(self, frame: int) -> float: + """Un-eased position within the span, clamped to [0, 1].""" + if self.end == self.start: + return 0.0 + u = (frame - self.start) / (self.end - self.start) + return min(1.0, max(0.0, u)) + + +class Keyframes(Driver): + """One channel, interpolated through a list of ``(frame, value)`` points. + + The same easing function is applied independently between each consecutive + pair of points. + """ + + def __init__( + self, + channel: str, + points: Sequence[Point], + ease: Ease = linear, + weight: Union[float, Callable[[int], float]] = 1.0, + ): + if len(points) < 2: + raise ValueError( + f"channel {channel!r}: a Keyframes driver needs at least two points" + ) + + self.channel = channel + self.ease = ease + self._weight = weight + self.points = [(int(f), as_value(v)) for f, v in points] + + frames = [f for f, _ in self.points] + if any(b <= a for a, b in zip(frames, frames[1:])): + raise ValueError( + f"channel {channel!r}: keyframe frames must be strictly " + f"increasing, got {frames}" + ) + + arities = {len(v) for _, v in self.points} + if len(arities) > 1: + raise ValueError( + f"channel {channel!r}: every keyframe must have the same arity, " + f"got {sorted(arities)}" + ) + + self.arity = arities.pop() + self.start = frames[0] + self.end = frames[-1] + + def writes(self) -> Tuple[str, ...]: + return (self.channel,) + + def evaluate(self, frame: int) -> Dict[str, Value]: + return {self.channel: self._sample(frame)} + + def _sample(self, frame: int) -> Value: + if frame <= self.start: + return self.points[0][1] + if frame >= self.end: + return self.points[-1][1] + + for (f0, v0), (f1, v1) in zip(self.points, self.points[1:]): + if f0 <= frame <= f1: + u = (frame - f0) / (f1 - f0) + return lerp(v0, v1, self.ease(u)) + + raise AssertionError("unreachable: frame lies inside the span") + + +# --- channels --------------------------------------------------------------- + + +class Channel: + """One parameter's timeline: an initial value plus the drivers covering it.""" + + def __init__(self, name: str, spec: ChannelSpec, initial: Value): + if len(initial) != spec.arity: + raise ArityError( + f"channel {name!r}: initial value needs {spec.arity} " + f"component(s), got {len(initial)}" + ) + self.name = name + self.spec = spec + self.initial = initial + self.drivers: list = [] + + def freeze(self) -> None: + """Order the drivers by span. Overlap is allowed; it blends.""" + self.drivers.sort(key=lambda d: (d.start, d.end)) + + def blend(self, frame: int) -> Union[Value, None]: + """Weighted mean of every driver covering ``frame``. + + ``None`` when nothing is driving this channel there -- either no driver + covers the frame, or the covering drivers' weights sum to zero. + """ + total = 0.0 + accumulated = None + + for driver in self.drivers: + if not (driver.start <= frame <= driver.end): + continue + weight = driver.weight(frame) + if weight == 0.0: + continue + + value = driver.evaluate(frame)[self.name] + if accumulated is None: + accumulated = [0.0] * len(value) + elif len(value) != len(accumulated): + raise ArityError( + f"channel {self.name!r}: drivers disagree on arity at frame " + f"{frame}, got {len(accumulated)} and {len(value)}" + ) + for i, component in enumerate(value): + accumulated[i] += component * weight + total += weight + + if accumulated is None or total == 0.0: + return None + return tuple(component / total for component in accumulated) + + def sample(self, frame: int) -> Value: + value = self.blend(frame) + + if value is None: + # Nothing is driving the channel here, so hold the last value it was + # actually driven to. Walk driver ends backwards and take the most + # recent one that blends to something: a zero-weight driver is + # skipped entirely, so it must not shadow an earlier real value. + ended = sorted({d.end for d in self.drivers if d.end < frame}, reverse=True) + for end in ended: + value = self.blend(end) + if value is not None: + break + if value is None: + value = self.initial + + if len(value) != self.spec.arity: + raise ArityError( + f"channel {self.name!r}: expected {self.spec.arity} " + f"component(s) at frame {frame}, got {len(value)}" + ) + return value + + +# --- timeline --------------------------------------------------------------- + + +@dataclass +class Segment: + """An unplaced driver: a duration plus how to build it once positioned.""" + + duration_secs: Union[float, None] + duration_frames: Union[int, None] + build: Callable[[int, int], Driver] + + +def _one_duration(secs, frames, fps: int, what: str) -> float: + if (secs is None) == (frames is None): + raise ValueError(f"{what}: pass exactly one of secs= or frames=") + return float(secs) if secs is not None else frames / fps + + +class Timeline: + """Absolute-time keyframe tracks, authored in seconds, evaluated on frames. + + Public entry points take a position or duration as either ``secs=`` or + ``frames=``. Internally everything is integer frames. + """ + + def __init__(self, fps: int, initial: Dict[str, Value], resolve=None): + if fps <= 0: + raise ValueError(f"fps must be positive, got {fps}") + if resolve is None: + from . import channels + + resolve = channels.resolve + + self.fps = fps + self.initial = {k: as_value(v) for k, v in initial.items()} + self._resolve = resolve + self._drivers: list = [] + self._cursor = 0.0 # seconds; only cumulative positions convert to frames + + # -- placement ---------------------------------------------------------- + + def at(self, *, secs=None, frames=None) -> "Timeline": + """Park the cursor at an absolute position.""" + self._cursor = _one_duration(secs, frames, self.fps, "at()") + return self + + def add(self, driver: Driver) -> "Timeline": + """Place a pre-built driver at its own absolute frames.""" + if driver.end < driver.start: + raise ValueError( + f"driver span ends before it starts: [{driver.start}, {driver.end}]" + ) + self._drivers.append(driver) + return self + + def then(self, *segments: Segment) -> "Timeline": + """Place segments one after another, advancing the cursor after each.""" + for segment in segments: + self._cursor += self._place(segment) + return self + + def together(self, *segments: Segment) -> "Timeline": + """Place segments from the same cursor; advance by the longest.""" + durations = [self._place(segment) for segment in segments] + if durations: + self._cursor += max(durations) + return self + + def _place(self, segment: Segment) -> float: + duration = _one_duration( + segment.duration_secs, segment.duration_frames, self.fps, "segment" + ) + start = round(self._cursor * self.fps) + end = round((self._cursor + duration) * self.fps) + self.add(segment.build(start, end)) + return duration + + # -- inspection --------------------------------------------------------- + + @property + def last_frame(self) -> int: + return max((d.end for d in self._drivers), default=0) + + def spans(self): + """``(channel, start, end)`` for every driver, in placement order.""" + return [(name, d.start, d.end) for d in self._drivers for name in d.writes()] + + def describe(self) -> str: + lines = [f"{self.fps} fps, {self.last_frame + 1} frames"] + for name, start, end in self.spans(): + secs = (end - start) / self.fps + lines.append(f" {name:<24} frames {start:>5}-{end:<5} ({secs:g}s)") + return "\n".join(lines) + + # -- evaluation --------------------------------------------------------- + + def _channels(self) -> Dict[str, Channel]: + driven: Dict[str, list] = {} + for driver in self._drivers: + for name in driver.writes(): + driven.setdefault(name, []).append(driver) + + missing = sorted(name for name in driven if name not in self.initial) + if missing: + raise MissingInitialError( + "no initial value declared for driven channel(s): " + + ", ".join(repr(name) for name in missing) + ) + + built = {} + for name, value in self.initial.items(): + channel = Channel(name, self._resolve(name), value) + channel.drivers = driven.get(name, []) + channel.freeze() + built[name] = channel + return built + + def sample(self, frame: int) -> Dict[str, Value]: + """Every channel's value at ``frame``.""" + return { + name: channel.sample(frame) for name, channel in self._channels().items() + } + + def render( + self, + renderer, + out_dir, + prefix: str, + *, + drop_last: bool = False, + flatten_alpha: bool = True, + progress: Union[Callable[[int, int], None], None] = None, + ) -> None: + """Render every frame to ``out_dir/prefix_NNNN.png``. + + A channel is pushed to the renderer only when its value changes, so held + channels cost nothing. Because nothing has been emitted yet at frame 0, + that frame pushes every channel -- the rendered scene therefore matches + the timeline's declared initial state whatever the scene setup left + behind. + + ``drop_last`` omits the final frame, giving exactly ``duration * fps`` + frames; use it for a seamless loop, where the last frame would duplicate + the first. + """ + out_dir = Path(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + + channels = self._channels() + stop = self.last_frame if drop_last else self.last_frame + 1 + emitted: Dict[str, Value] = {} + + for frame in range(stop): + for name, channel in channels.items(): + value = channel.sample(frame) + if name not in emitted or emitted[name] != value: + channel.spec.setter(renderer, value) + emitted[name] = value + + path = out_dir / f"{prefix}_{frame:04d}.png" + renderer.session(str(path)) + renderer.redraw() + if flatten_alpha: + flatten_alpha_channel(path) + if progress is not None: + progress(frame, stop) + + +def flatten_alpha_channel(path) -> None: + """Rewrite a rendered PNG without its alpha channel. + + renderlib's headless ``PythonRenderer::redraw()`` passes + ``backgroundAlpha = 0.0f``, so frames carry correct RGB -- already blended + over the background colour -- but zero alpha wherever no ray hit anything. + Dropping alpha yields the intended opaque image. + """ + from PIL import Image + + with Image.open(path) as image: + opaque = image.convert("RGB") + opaque.save(path) diff --git a/agave_pyvk/agave_pyvk/channels.py b/agave_pyvk/agave_pyvk/channels.py new file mode 100644 index 0000000000..58e77fe273 --- /dev/null +++ b/agave_pyvk/agave_pyvk/channels.py @@ -0,0 +1,100 @@ +"""The animatable channel namespace. + +Each entry maps a channel name onto its arity and the single ``AgaveRenderer`` +call that applies it. The namespace is hand-maintained: adding a new animatable +AGAVE command means adding an entry here. + +Names are dotted and grouped by subsystem. Indexed families put the index on the +family root, so ``mat[0].opacity`` reads as "volume channel 0's opacity". + + resolve("camera.eye") -> ChannelSpec(3, r.eye) + resolve("mat[1].opacity") -> ChannelSpec(1, r.mat_opacity bound to index 1) +""" + +import re +from typing import Callable, Dict, List, Tuple + +from .animation import ChannelSpec, UnknownChannelError + +# Plain channels: name -> (arity, setter) +CHANNELS: Dict[str, ChannelSpec] = { + "camera.eye": ChannelSpec(3, lambda r, v: r.eye(*v)), + "camera.target": ChannelSpec(3, lambda r, v: r.target(*v)), + "camera.up": ChannelSpec(3, lambda r, v: r.up(*v)), + "camera.aperture": ChannelSpec(1, lambda r, v: r.aperture(*v)), + "camera.focaldist": ChannelSpec(1, lambda r, v: r.focaldist(*v)), + "camera.exposure": ChannelSpec(1, lambda r, v: r.exposure(*v)), + "render.density": ChannelSpec(1, lambda r, v: r.density(*v)), + "clip.plane": ChannelSpec(4, lambda r, v: r.set_clip_plane(*v)), + "clip.region": ChannelSpec(6, lambda r, v: r.set_clip_region(*v)), + "background.color": ChannelSpec(3, lambda r, v: r.background_color(*v)), + "boundingbox.color": ChannelSpec(3, lambda r, v: r.bounding_box_color(*v)), + "skylight.top": ChannelSpec(3, lambda r, v: r.skylight_top_color(*v)), + "skylight.middle": ChannelSpec(3, lambda r, v: r.skylight_middle_color(*v)), + "skylight.bottom": ChannelSpec(3, lambda r, v: r.skylight_bottom_color(*v)), +} + +# Indexed families: templated name -> (arity, index -> setter) +INDEXED: Dict[str, Tuple[int, Callable[[int], Callable]]] = { + "light[].pos": (3, lambda i: lambda r, v: r.light_pos(i, *v)), + "light[].color": (3, lambda i: lambda r, v: r.light_color(i, *v)), + "light[].size": (2, lambda i: lambda r, v: r.light_size(i, *v)), + "mat[].diffuse": (4, lambda i: lambda r, v: r.mat_diffuse(i, *v)), + "mat[].specular": (4, lambda i: lambda r, v: r.mat_specular(i, *v)), + "mat[].emissive": (4, lambda i: lambda r, v: r.mat_emissive(i, *v)), + "mat[].glossiness": (1, lambda i: lambda r, v: r.mat_glossiness(i, *v)), + "mat[].opacity": (1, lambda i: lambda r, v: r.mat_opacity(i, *v)), + "mat[].window_level": (2, lambda i: lambda r, v: r.set_window_level(i, *v)), + "mat[].percentile": ( + 2, + lambda i: lambda r, v: r.set_percentile_threshold(i, *v), + ), + "mat[].isovalue": ( + 2, + lambda i: lambda r, v: r.set_isovalue_threshold(i, *v), + ), +} + +# Author-defined shorthands: alias name -> target channel name +ALIASES: Dict[str, str] = {} + +_INDEXED = re.compile(r"^(\w+)\[(\d+)\]\.(.+)$") + + +def alias(name: str, target: str) -> None: + """Register ``name`` as a shorthand for ``target``. + + The target is resolved immediately, so a typo fails here rather than at + render time. + """ + resolve(target) + ALIASES[name] = target + + +def resolve(name: str) -> ChannelSpec: + """Return the spec for a channel name, alias, or indexed channel.""" + seen = set() + while name in ALIASES: + if name in seen: + raise UnknownChannelError(f"alias cycle involving {name!r}") + seen.add(name) + name = ALIASES[name] + + if name in CHANNELS: + return CHANNELS[name] + + match = _INDEXED.match(name) + if match: + family, index, leaf = match.group(1), int(match.group(2)), match.group(3) + key = f"{family}[].{leaf}" + if key in INDEXED: + arity, make_setter = INDEXED[key] + return ChannelSpec(arity, make_setter(index)) + + raise UnknownChannelError(f"unknown animation channel {name!r}") + + +def names() -> List[str]: + """Every known channel name, with indexed families shown at index 0.""" + indexed = [key.replace("[]", "[0]") for key in INDEXED] + return sorted(CHANNELS) + sorted(indexed) diff --git a/agave_pyvk/agave_pyvk/drivers.py b/agave_pyvk/agave_pyvk/drivers.py new file mode 100644 index 0000000000..99722125c5 --- /dev/null +++ b/agave_pyvk/agave_pyvk/drivers.py @@ -0,0 +1,204 @@ +"""Drivers and the segment helpers that build them. + +A driver produces values for one or more channels over a frame span. Derived +math belongs here, upstream of the channels, which keeps every channel a 1:1 +mapping onto a single AGAVE call. + +The vector helpers below are deliberately local rather than reused from +``agave.py``: those are numpy-based, and ``get_vertical_axis`` there returns the +*negated* up vector (``get_vertical_axis([1,0,0], [0,0,1])`` gives +``[0,0,-1]``), which is not the convention this module needs. +""" + +import math +from typing import Callable, Dict, Sequence, Tuple, Union + +from .animation import ( + Driver, + Ease, + Keyframes, + Segment, + Value, + as_value, + lerp, + linear, +) + +Vec3 = Tuple[float, float, float] + + +# --- vector math ------------------------------------------------------------ + + +def sub(a: Sequence[float], b: Sequence[float]) -> Tuple[float, ...]: + return tuple(x - y for x, y in zip(a, b)) + + +def add(a: Sequence[float], b: Sequence[float]) -> Tuple[float, ...]: + return tuple(x + y for x, y in zip(a, b)) + + +def scale(a: Sequence[float], k: float) -> Tuple[float, ...]: + return tuple(x * k for x in a) + + +def dot(a: Sequence[float], b: Sequence[float]) -> float: + return sum(x * y for x, y in zip(a, b)) + + +def cross(a: Sequence[float], b: Sequence[float]) -> Vec3: + return ( + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ) + + +def normalize(a: Sequence[float]) -> Tuple[float, ...]: + length = math.sqrt(dot(a, a)) + if length == 0.0: + raise ValueError("cannot normalize a zero-length vector") + return scale(a, 1.0 / length) + + +def rotate_about_axis(v: Sequence[float], axis: Sequence[float], degrees: float): + """Rodrigues rotation of ``v`` about a unit ``axis``, right-hand rule.""" + angle = math.radians(degrees) + c, s = math.cos(angle), math.sin(angle) + return add( + add(scale(v, c), scale(cross(axis, v), s)), + scale(axis, dot(axis, v) * (1.0 - c)), + ) + + +def vertical_view_axis(eye: Vec3, target: Vec3, up: Vec3) -> Vec3: + """``up`` with any component along the look direction projected out.""" + forward = normalize(sub(target, eye)) + return normalize(sub(up, scale(forward, dot(up, forward)))) + + +# --- drivers ---------------------------------------------------------------- + + +class CameraOrbit(Driver): + """Swing the camera about its own vertical view axis, target unchanged. + + Writes both ``camera.eye`` and ``camera.up`` from a single angle. Every + frame is computed from the reference view rather than nudged incrementally, + so an angle of 0 reproduces the reference exactly and a long move + accumulates no drift. + """ + + def __init__( + self, + eye: Vec3, + target: Vec3, + up: Vec3, + deg0: float, + deg1: float, + start: int, + end: int, + ease: Ease = linear, + weight: Union[float, Callable[[int], float]] = 1.0, + ): + self.eye = tuple(float(x) for x in eye) + self.target = tuple(float(x) for x in target) + self.up = tuple(float(x) for x in up) + self.axis = vertical_view_axis(self.eye, self.target, self.up) + self.deg0 = float(deg0) + self.deg1 = float(deg1) + self.ease = ease + self._weight = weight + self.start = int(start) + self.end = int(end) + + def writes(self) -> Tuple[str, ...]: + return ("camera.eye", "camera.up") + + def evaluate(self, frame: int) -> Dict[str, Value]: + u = self.ease(self.progress(frame)) + degrees = self.deg0 + (self.deg1 - self.deg0) * u + offset = rotate_about_axis(sub(self.eye, self.target), self.axis, degrees) + return { + "camera.eye": add(self.target, offset), + "camera.up": rotate_about_axis(self.up, self.axis, degrees), + } + + +# --- segment helpers -------------------------------------------------------- + + +def ramp( + channel: str, v0, v1, *, secs=None, frames=None, ease: Ease = linear, weight=1.0 +): + """Interpolate one channel from ``v0`` to ``v1``. Scalars are promoted.""" + start_value, end_value = as_value(v0), as_value(v1) + return Segment( + secs, + frames, + lambda s, e: Keyframes( + channel, [(s, start_value), (e, end_value)], ease=ease, weight=weight + ), + ) + + +def travel_clip_plane( + normal: Vec3, + d0: float, + d1: float, + *, + secs=None, + frames=None, + ease: Ease = linear, + weight=1.0, +): + """Slide the clip plane along a fixed normal, from distance d0 to d1.""" + unit = normalize(normal) + return ramp( + "clip.plane", + (*unit, float(d0)), + (*unit, float(d1)), + secs=secs, + frames=frames, + ease=ease, + weight=weight, + ) + + +def pivot_camera( + eye: Vec3, + target: Vec3, + up: Vec3, + deg0: float, + deg1: float, + *, + secs=None, + frames=None, + ease: Ease = linear, + weight=1.0, +): + """Swing the camera about its vertical view axis, from deg0 to deg1.""" + return Segment( + secs, + frames, + lambda s, e: CameraOrbit( + eye, target, up, deg0, deg1, s, e, ease=ease, weight=weight + ), + ) + + +__all__ = [ + "CameraOrbit", + "add", + "cross", + "dot", + "lerp", + "normalize", + "pivot_camera", + "ramp", + "rotate_about_axis", + "scale", + "sub", + "travel_clip_plane", + "vertical_view_axis", +] diff --git a/agave_pyvk/agave_pyvk/example4.py b/agave_pyvk/agave_pyvk/example4.py new file mode 100644 index 0000000000..ac35db6d31 --- /dev/null +++ b/agave_pyvk/agave_pyvk/example4.py @@ -0,0 +1,173 @@ +"""Load an OME-TIFF two ways and check that both render the same frame. + +The first pass hands AGAVE a file path and lets renderlib's own reader do the +loading (``load_data``). The second pass reads the file with bioio and pushes +the resulting NumPy array straight into the in-process headless Vulkan renderer +(``load_array``), so nothing is read from disk by AGAVE at all. Both passes use +identical scene settings, each writes a PNG, and the two images are compared. + +The array path works for any volume you can get into a ZYX or CZYX array, not +just files AGAVE knows how to read: segmentations, simulation output, etc. + + pip install bioio bioio-ome-tiff + python -m agave_pyvk.example4 mydata.ome.tiff out +""" + +import sys + +import numpy +from bioio import BioImage +from PIL import Image + +from agave_pyvk import AgaveRenderer + +# AGAVE's volume format is uint16; anything else gets converted on the way in. +SUPPORTED_DTYPES = (numpy.uint8, numpy.uint16, numpy.float32) + +# a few distinct colors to cycle through for the first channels +CHANNEL_COLORS = [ + (1.0, 0.0, 1.0), + (1.0, 1.0, 1.0), + (0.0, 1.0, 1.0), + (1.0, 1.0, 0.0), +] + + +def read_volume(filename, scene=0, timepoint=0): + """Return (CZYX array, (x,y,z) voxel size, spatial units, channel names).""" + img = BioImage(filename) + img.set_scene(scene) + + data = img.get_image_data("CZYX", T=timepoint) + if data.dtype not in [numpy.dtype(d) for d in SUPPORTED_DTYPES]: + # uint16 keeps full precision for the integer types AGAVE cares about + data = data.astype(numpy.uint16) + + # bioio reports pixel sizes as (Z, Y, X); any of them can be None + sizes = img.physical_pixel_sizes + voxel_size = ( + sizes.X or 1.0, + sizes.Y or 1.0, + sizes.Z or 1.0, + ) + + channel_names = [str(name) for name in img.channel_names] + return data, voxel_size, "micron", channel_names + + +def render(r, info, output_name): + """Apply identical scene settings to whatever volume is loaded, and render.""" + r.set_resolution(1024, 768) + r.background_color(0, 0, 0) + r.show_bounding_box(1) + r.bounding_box_color(1, 1, 1) + r.show_scale_bar(0) + r.render_iterations(64) + r.set_primary_ray_step_size(4) + r.set_secondary_ray_step_size(4) + r.set_interpolation(1) + r.set_clip_region(0, 1, 0, 1, 0, 1) + + # loading already sized the scene; frame_scene points the camera at it + r.camera_projection(0, 55) + r.frame_scene() + r.exposure(0.75) + r.density(50) + r.aperture(0) + r.focaldist(0.75) + + # show the first few channels, autoscaled by intensity percentile + for channel in range(info["c"]): + enabled = channel < len(CHANNEL_COLORS) + r.enable_channel(channel, 1 if enabled else 0) + if not enabled: + continue + red, green, blue = CHANNEL_COLORS[channel] + r.mat_diffuse(channel, red, green, blue, 1) + r.mat_specular(channel, 0, 0, 0, 0) + r.mat_emissive(channel, 0, 0, 0, 0) + r.mat_glossiness(channel, 1) + r.mat_opacity(channel, 1) + r.set_percentile_threshold(channel, 0.5, 0.98) + + r.skylight_top_color(1, 1, 1) + r.skylight_middle_color(1, 1, 1) + r.skylight_bottom_color(1, 1, 1) + r.light_pos(0, 1.5, 5.54884, 0.952752) + r.light_color(0, 10, 10, 10) + r.light_size(0, 0.15, 0.15) + + # session() names the file that the next redraw() writes + r.session(output_name) + r.redraw() + print(f"wrote {output_name}") + + +def describe_volume(label, info): + # both loaders return the same structure, so this works for either + voxel = (info["pixel_size_x"], info["pixel_size_y"], info["pixel_size_z"]) + print( + f"{label}: {info['c']}x{info['z']}x{info['y']}x{info['x']} (czyx)" + f" voxel {voxel} {info['spatial_units']}" + f" channels {info['channel_names']}" + f" intensity {info['channel_min_intensity']}..{info['channel_max_intensity']}" + ) + + +def compare_images(path_a, path_b): + """Report how far apart the two rendered frames are.""" + a = numpy.asarray(Image.open(path_a).convert("RGBA"), dtype=numpy.int16) + b = numpy.asarray(Image.open(path_b).convert("RGBA"), dtype=numpy.int16) + if a.shape != b.shape: + print(f"images differ in size: {a.shape} vs {b.shape}") + return + + difference = numpy.abs(a - b) + differing = int(numpy.count_nonzero(difference.any(axis=2))) + total = a.shape[0] * a.shape[1] + print( + f"comparison: {differing}/{total} pixels differ" + f" ({100.0 * differing / total:.3f}%)," + f" max channel delta {int(difference.max())}," + f" mean {difference.mean():.4f}" + ) + if differing == 0: + print("the two load paths produced identical images") + + +def main(): + if len(sys.argv) < 2: + print(__doc__) + return 1 + filename = sys.argv[1] + prefix = sys.argv[2] if len(sys.argv) > 2 else "example4" + file_output = f"{prefix}_load_data.png" + array_output = f"{prefix}_load_array.png" + + data, voxel_size, spatial_units, channel_names = read_volume(filename) + print(f"bioio read {filename}: shape {data.shape} {data.dtype}") + + # One renderer for both passes, so the only thing that varies is how the + # volume got in. Each load resets the scene bounds, and render() reapplies + # every setting afterwards. + with AgaveRenderer(mode="pathtrace") as r: + file_info = r.load_data(filename, 0, 0, 0, [], []) + describe_volume("renderlib load_data", file_info) + render(r, file_info, file_output) + + array_info = r.load_array( + data, + name=filename, + voxel_size=voxel_size, + spatial_units=spatial_units, + channel_names=channel_names, + ) + describe_volume("bioio load_array ", array_info) + render(r, array_info, array_output) + + compare_images(file_output, array_output) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/agave_pyvk/agave_pyvk/test_animation.py b/agave_pyvk/agave_pyvk/test_animation.py new file mode 100644 index 0000000000..06d807749b --- /dev/null +++ b/agave_pyvk/agave_pyvk/test_animation.py @@ -0,0 +1,237 @@ +# AGAVE Python animation (standalone, headless Vulkan) +# +# A 16 s shot declared on an animation Timeline, at 24 fps: +# +# 0- 2 s : camera pivots 45 deg, nothing else changes +# 2- 4 s : camera pivots back to the original view +# 4- 8 s : clip plane slides into the volume, camera still +# 8-10 s : membrane channel fades to 0 opacity, plane held +# 10-14 s : clip plane walks back to its original position +# 14-16 s : membrane channel fades back in, plane parked +# +# Frames 0 and 384 are both the untouched scene: the clip plane is parked at the +# far edge of the displayed data, where it clips nothing. +# +# pip install agave_pyvk +# python test_animation.py +# +# Then encode, e.g.: +# ffmpeg -framerate 24 -i frames/frame_%04d.png -c:v libx264 -pix_fmt yuv420p out.mp4 + +import itertools +from pathlib import Path + +import agave_pyvk as agave +from agave_pyvk import channels +from agave_pyvk.animation import Timeline, ease_in_out +from agave_pyvk.drivers import pivot_camera, ramp, travel_clip_plane + +AGAVE_PATH = "/Users/danielt/src/AllenCell/agave/build/agave.app/Contents/MacOS/agave" +DATA_URL = ( + "https://vast-files.int.allencell.org/users/alex.khang/repos/" + "bioio_conversion/H2B_640_CAAX_488_20250904.ome.zarr" +) + +OUTPUT_DIR = Path(__file__).resolve().parent / "frames" +OUTPUT_PREFIX = "frame" + +FPS = 24 +PIVOT_SECS = 2 +CLIP_SECS = 4 +FADE_SECS = 2 + +# Channel assignments. Source channels [2, 3] are loaded, landing as scene channels +# 0 and 1: CAAX/488 (the membrane marker) and H2B/640 (the DNA marker). The script +# prints the channel names on startup so this is easy to check. +MEMBRANE_CHANNEL = 0 +DNA_CHANNEL = 1 + +# Channel colors, (r, g, b) in 0-1. +DNA_COLOR = (0.176043, 0.353597, 0.86276) +MEMBRANE_SPECULAR = (0.0, 0.0, 0.0) +MEMBRANE_COLOR = (0.933349, 0.271885, 0.974884) +DNA_SPECULAR = DNA_COLOR # (0.970489, 0.0, 0.107485) + +# Base opacities. The membrane channel's is what the fade animates to 0 and back. +MEMBRANE_OPACITY = 2.13 +DNA_OPACITY = 4 + +# Scene background, (r, g, b) in 0-1, and the bounding box drawn over it. +BACKGROUND_COLOR = (0.0, 0.0, 0.0) +BOUNDING_BOX_COLOR = (1.0, 1.0, 1.0) + +# renderlib's headless redraw asks for a transparent background (it passes +# backgroundAlpha = 0 to gfxApi::renderToFramebuffer), so the saved PNGs carry the +# right RGB -- the volume is already alpha-blended over BACKGROUND_COLOR -- but an +# alpha of 0 wherever no ray hit anything, which is why they look transparent. +# Dropping the alpha channel leaves exactly the intended opaque image. Set this to +# False to keep the alpha, e.g. to composite the frames over something else. +FLATTEN_ALPHA = True + +# Clip plane orientation. The renderer keeps the half-space dot(normal, p) < d and +# clips away everything on the normal's side, so with +Z the cut eats down from the +# top of the stack. Flip to (0, 0, -1) to cut up from the bottom instead. +CLIP_NORMAL = (0.0, 0.0, 1.0) + +# How far the plane travels, as a fraction of the displayed volume's Z thickness. +# NOTE: the scene's clip region only shows the bottom ~31% of the 245-slice stack, +# so "half the volume's Z" is measured against that displayed slab. Half of the +# *whole* 245-slice stack is thicker than the slab itself and would sweep the data +# entirely out of frame (and the membrane fade would happen on an empty image). +CLIP_TRAVEL_FRACTION = 0.5 + +# The opening pivot: swing this far about the camera's vertical view axis over the +# first 2 s, then unwind it over the next 2 s, with nothing else changing. Negative +# pivots the other way; 0.0 still renders those 4 s, just without any movement. +CAMERA_SWING_DEGREES = 45.0 + +# The original view from test.py. Each swung frame is computed from these rather +# than nudged incrementally, so 0 degrees always lands exactly back here. +CAMERA_EYE = (0.0866744, 0.5876, 0.437086) +CAMERA_TARGET = (0.463802, 0.508346, 0.16371) +CAMERA_UP = (0.490969, 0.73768, 0.46344) + +# Scene constants also used to reconstruct the world-space bounding box. +VOXEL_SCALE = (0.32, 0.32, 0.5) +CLIP_REGION = (0.305882, 0.719453, 0.184314, 0.717647, 0.0, 0.311475) + + +def displayed_box(info): + """World-space min/max of the part of the volume the clip region shows. + + renderlib normalizes the scene bounding box to [0,0,0] .. physical/max(physical), + then intersects it with the clip region (expressed as 0-1 fractions). + """ + physical = ( + info["x"] * VOXEL_SCALE[0], + info["y"] * VOXEL_SCALE[1], + info["z"] * VOXEL_SCALE[2], + ) + longest = max(physical) + extent = [p / longest for p in physical] + + box_min = tuple(CLIP_REGION[2 * i] * extent[i] for i in range(3)) + box_max = tuple(CLIP_REGION[2 * i + 1] * extent[i] for i in range(3)) + return box_min, box_max + + +def clip_plane_sweep(info): + """Return (normal, d_start, d_end) describing the plane's travel. + + d_start puts the plane at the far corner of the displayed data along its normal, + so nothing is clipped and the opening frame matches the untouched render. d_end + is one travel distance back down that same axis. + """ + length = sum(c * c for c in CLIP_NORMAL) ** 0.5 + normal = tuple(c / length for c in CLIP_NORMAL) + + box_min, box_max = displayed_box(info) + corners = itertools.product(*zip(box_min, box_max)) + d_start = max(sum(n * c for n, c in zip(normal, corner)) for corner in corners) + + travel = CLIP_TRAVEL_FRACTION * (box_max[2] - box_min[2]) + return normal, d_start, d_start - travel + + +def setup(r): + """Everything about the scene that does not change over the animation.""" + info = r.load_data_and_get_info( + DATA_URL, 0, 1, 0, [2, 3], [0, 1024, 0, 1024, 0, 245] + ) + r.set_resolution(740 * 2, 762 * 2) + r.background_color(*BACKGROUND_COLOR) + r.show_bounding_box(1) + r.show_scale_bar(0) + r.show_time_stamp(0) + r.set_time_stamp_format(1) + r.bounding_box_color(*BOUNDING_BOX_COLOR) + r.render_iterations(512) + r.set_primary_ray_step_size(1) + r.set_secondary_ray_step_size(1) + r.set_multichannel_blend(0) + r.set_interpolation(1) + r.set_voxel_scale(*VOXEL_SCALE) + r.set_flip_axis(1, 1, 1) + r.set_clip_region(*CLIP_REGION) + r.eye(*CAMERA_EYE) + r.target(*CAMERA_TARGET) + r.up(*CAMERA_UP) + r.camera_projection(1, 0.168144) + r.exposure(0.75) + r.density(82.0515) + r.aperture(0) + r.focaldist(0.75) + r.enable_channel(MEMBRANE_CHANNEL, 1) + r.mat_diffuse(MEMBRANE_CHANNEL, *MEMBRANE_COLOR, 1) + r.mat_specular(MEMBRANE_CHANNEL, *MEMBRANE_SPECULAR, 0) + r.mat_emissive(MEMBRANE_CHANNEL, 0, 0, 0, 0) + r.mat_glossiness(MEMBRANE_CHANNEL, 1) + r.mat_opacity(MEMBRANE_CHANNEL, MEMBRANE_OPACITY) + r.set_percentile_threshold(MEMBRANE_CHANNEL, 0.989187, 0.991468) + r.set_color_ramp(MEMBRANE_CHANNEL, "none", [0, 1, 1, 1, 1, 1, 1, 1, 1, 1]) + r.enable_channel(DNA_CHANNEL, 1) + r.mat_diffuse(DNA_CHANNEL, *DNA_COLOR, 1) + r.mat_specular(DNA_CHANNEL, *DNA_SPECULAR, 0) + r.mat_emissive(DNA_CHANNEL, 0, 0, 0, 0) + r.mat_glossiness(DNA_CHANNEL, 100) + r.mat_opacity(DNA_CHANNEL, DNA_OPACITY) + r.set_percentile_threshold(DNA_CHANNEL, 0.991955, 0.994467) + r.set_color_ramp(DNA_CHANNEL, "none", [0, 1, 1, 1, 1, 1, 1, 1, 1, 1]) + r.skylight_top_color(0.99028, 1, 1) + r.skylight_middle_color(0.990311, 1, 1) + r.skylight_bottom_color(0.990311, 1, 1) + r.light_pos(0, 1.5, -0.425781, 1.515) + r.light_color(0, 10, 10, 10) + r.light_size(0, 0.15, 0.15) + return info + + +def build_timeline(normal, d_start, d_end): + """Declare the shot. Pure: no renderer, no I/O, so it is cheap to inspect.""" + channels.alias("membrane.opacity", f"mat[{MEMBRANE_CHANNEL}].opacity") + + view = (CAMERA_EYE, CAMERA_TARGET, CAMERA_UP) + timeline = Timeline( + fps=FPS, + initial={ + "camera.eye": CAMERA_EYE, + "camera.up": CAMERA_UP, + "clip.plane": (*normal, d_start), + "membrane.opacity": (MEMBRANE_OPACITY,), + }, + ) + timeline.then( + pivot_camera(*view, 0.0, CAMERA_SWING_DEGREES, secs=PIVOT_SECS), + pivot_camera(*view, CAMERA_SWING_DEGREES, 0.0, secs=PIVOT_SECS), + travel_clip_plane(normal, d_start, d_end, secs=CLIP_SECS, ease=ease_in_out), + ramp("membrane.opacity", MEMBRANE_OPACITY, 0.0, secs=FADE_SECS), + travel_clip_plane(normal, d_end, d_start, secs=CLIP_SECS, ease=ease_in_out), + ramp("membrane.opacity", 0.0, MEMBRANE_OPACITY, secs=FADE_SECS), + ) + return timeline + + +def main(): + with agave.AgaveRenderer(agave_path=AGAVE_PATH, mode="pathtrace") as r: + info = setup(r) + print("channels:", info.get("channel_names")) + + normal, d_start, d_end = clip_plane_sweep(info) + print(f"clip plane normal {normal}, d from {d_start:.5f} to {d_end:.5f}") + + timeline = build_timeline(normal, d_start, d_end) + print(timeline.describe()) + + timeline.render( + r, + OUTPUT_DIR, + OUTPUT_PREFIX, + flatten_alpha=FLATTEN_ALPHA, + progress=lambda frame, total: print( + f"frame {frame + 1}/{total}", flush=True + ), + ) + + +if __name__ == "__main__": + main() diff --git a/agave_pyvk/tests/test_timeline.py b/agave_pyvk/tests/test_timeline.py new file mode 100644 index 0000000000..d74189f5ef --- /dev/null +++ b/agave_pyvk/tests/test_timeline.py @@ -0,0 +1,767 @@ +"""Tests for the animation timeline (agave_pyvk.animation / channels / drivers). + +Named test_timeline.py rather than test_animation.py to avoid a pytest basename +collision with the agave_pyvk/agave_pyvk/test_animation.py example script. +""" + +import ast +import math +from pathlib import Path + +import pytest + +from agave_pyvk import channels +from agave_pyvk.drivers import ( + CameraOrbit, + pivot_camera, + ramp, + rotate_about_axis, + travel_clip_plane, +) +from agave_pyvk.animation import ( + ArityError, + Keyframes, + MissingInitialError, + Segment, + Timeline, + UnknownChannelError, + ease_in, + ease_in_out, + ease_out, + linear, +) + + +class Recorder: + """Stands in for AgaveRenderer, recording (method, args) for every call.""" + + def __init__(self): + self.calls = [] + + def __getattr__(self, name): + def call(*args): + self.calls.append((name, args)) + + return call + + +def agave_renderer_methods(): + """Public AgaveRenderer method names, parsed without importing _native.""" + path = Path(__file__).resolve().parents[1] / "agave_pyvk" / "agave.py" + tree = ast.parse(path.read_text(encoding="utf-8")) + cls = next( + node + for node in tree.body + if isinstance(node, ast.ClassDef) and node.name == "AgaveRenderer" + ) + return { + node.name + for node in cls.body + if isinstance(node, ast.FunctionDef) and not node.name.startswith("_") + } + + +class TestEasing: + @pytest.mark.parametrize("ease", [linear, ease_in, ease_out, ease_in_out]) + def test_maps_unit_interval_endpoints_to_themselves(self, ease): + assert ease(0.0) == 0.0 + assert ease(1.0) == 1.0 + + def test_ease_in_out_is_smoothstep(self): + assert ease_in_out(0.5) == 0.5 + assert ease_in_out(0.2) == pytest.approx(0.104) + + +class TestKeyframes: + def test_spans_from_first_to_last_point(self): + kf = Keyframes("x", [(3, (0.0,)), (7, (1.0,)), (12, (2.0,))]) + assert (kf.start, kf.end) == (3, 12) + + def test_writes_only_its_own_channel(self): + kf = Keyframes("x", [(0, (0.0,)), (10, (1.0,))]) + assert kf.writes() == ("x",) + + def test_returns_exact_values_at_the_endpoint_frames(self): + kf = Keyframes("x", [(0, (0.0,)), (10, (5.0,))]) + assert kf.evaluate(0) == {"x": (0.0,)} + assert kf.evaluate(10) == {"x": (5.0,)} + + def test_interpolates_linearly_between_two_points(self): + kf = Keyframes("x", [(0, (0.0,)), (10, (5.0,))]) + assert kf.evaluate(5) == {"x": (2.5,)} + + def test_interpolates_every_component_of_a_vector_channel(self): + kf = Keyframes("v", [(0, (0.0, 10.0)), (2, (2.0, 20.0))]) + assert kf.evaluate(1) == {"v": (1.0, 15.0)} + + def test_uses_the_bracketing_pair_on_a_multi_point_track(self): + kf = Keyframes("x", [(0, (0.0,)), (10, (10.0,)), (20, (0.0,))]) + assert kf.evaluate(15) == {"x": (5.0,)} + + def test_applies_easing_between_each_pair(self): + kf = Keyframes("x", [(0, (0.0,)), (10, (1.0,))], ease=ease_in_out) + assert kf.evaluate(2)["x"][0] == pytest.approx(0.104) + + def test_promotes_bare_scalars_to_one_tuples(self): + kf = Keyframes("x", [(0, 0.0), (10, 5.0)]) + assert kf.evaluate(5) == {"x": (2.5,)} + + def test_rejects_fewer_than_two_points(self): + with pytest.raises(ValueError, match="at least two"): + Keyframes("x", [(0, (0.0,))]) + + def test_rejects_non_increasing_frames(self): + with pytest.raises(ValueError, match="increasing"): + Keyframes("x", [(0, (0.0,)), (0, (1.0,))]) + + def test_rejects_points_of_differing_arity(self): + with pytest.raises(ValueError, match="arity"): + Keyframes("x", [(0, (0.0,)), (10, (1.0, 2.0))]) + + +class TestChannelNamespace: + def test_resolves_a_plain_channel_to_its_arity_and_call(self): + spec = channels.resolve("camera.eye") + assert spec.arity == 3 + r = Recorder() + spec.setter(r, (1.0, 2.0, 3.0)) + assert r.calls == [("eye", (1.0, 2.0, 3.0))] + + def test_resolves_the_clip_plane_as_four_floats(self): + spec = channels.resolve("clip.plane") + assert spec.arity == 4 + r = Recorder() + spec.setter(r, (0.0, 0.0, 1.0, 0.25)) + assert r.calls == [("set_clip_plane", (0.0, 0.0, 1.0, 0.25))] + + def test_resolves_an_indexed_channel_and_binds_the_index(self): + spec = channels.resolve("mat[2].opacity") + assert spec.arity == 1 + r = Recorder() + spec.setter(r, (0.5,)) + assert r.calls == [("mat_opacity", (2, 0.5))] + + def test_indexed_channels_carry_their_family_arity(self): + spec = channels.resolve("mat[0].diffuse") + assert spec.arity == 4 + r = Recorder() + spec.setter(r, (0.1, 0.2, 0.3, 1.0)) + assert r.calls == [("mat_diffuse", (0, 0.1, 0.2, 0.3, 1.0))] + + def test_alias_resolves_to_the_same_call_as_its_target(self): + channels.alias("membrane.opacity", "mat[0].opacity") + try: + r = Recorder() + channels.resolve("membrane.opacity").setter(r, (2.13,)) + assert r.calls == [("mat_opacity", (0, 2.13))] + finally: + channels.ALIASES.pop("membrane.opacity", None) + + def test_alias_to_an_unknown_target_is_rejected_at_definition(self): + with pytest.raises(UnknownChannelError, match="nope"): + channels.alias("shorthand", "nope") + + def test_unknown_channel_name_is_rejected(self): + with pytest.raises(UnknownChannelError, match="camera.nose"): + channels.resolve("camera.nose") + + def test_unknown_indexed_family_is_rejected(self): + with pytest.raises(UnknownChannelError, match=r"bogus\[0\].thing"): + channels.resolve("bogus[0].thing") + + def test_every_channel_calls_a_real_agave_renderer_method(self): + """Guards the namespace against drifting from the AgaveRenderer API.""" + available = agave_renderer_methods() + for name in channels.names(): + spec = channels.resolve(name) + r = Recorder() + spec.setter(r, tuple(0.0 for _ in range(spec.arity))) + called, _ = r.calls[0] + assert called in available, f"{name} calls missing method {called}()" + + def test_names_covers_plain_and_indexed_channels(self): + listed = set(channels.names()) + assert "camera.eye" in listed + assert "mat[0].opacity" in listed + + +def kf_segment(channel, v0, v1, *, secs=None, frames=None): + """A minimal Segment for exercising the Timeline without drivers.py.""" + return Segment(secs, frames, lambda s, e: Keyframes(channel, [(s, v0), (e, v1)])) + + +class TestTimelinePlacement: + def test_then_places_a_segment_from_the_cursor(self): + tl = Timeline(fps=24, initial={"camera.exposure": (0.0,)}) + tl.then(kf_segment("camera.exposure", 0.0, 1.0, secs=2)) + assert tl.spans() == [("camera.exposure", 0, 48)] + + def test_then_places_several_segments_sequentially(self): + tl = Timeline(fps=24, initial={"camera.exposure": (0.0,)}) + tl.then( + kf_segment("camera.exposure", 0.0, 1.0, secs=2), + kf_segment("camera.exposure", 1.0, 0.0, secs=2), + ) + assert tl.spans() == [ + ("camera.exposure", 0, 48), + ("camera.exposure", 48, 96), + ] + + def test_together_places_from_the_same_cursor(self): + tl = Timeline( + fps=24, initial={"camera.exposure": (0.0,), "render.density": (0.0,)} + ) + tl.then(kf_segment("camera.exposure", 0.0, 1.0, secs=1)) + tl.together( + kf_segment("camera.exposure", 1.0, 0.0, secs=4), + kf_segment("render.density", 0.0, 50.0, secs=2), + ) + assert tl.spans() == [ + ("camera.exposure", 0, 24), + ("camera.exposure", 24, 120), + ("render.density", 24, 72), + ] + + def test_together_advances_the_cursor_by_the_longest_segment(self): + tl = Timeline( + fps=24, initial={"camera.exposure": (0.0,), "render.density": (0.0,)} + ) + tl.together( + kf_segment("camera.exposure", 0.0, 1.0, secs=4), + kf_segment("render.density", 0.0, 50.0, secs=2), + ) + tl.then(kf_segment("camera.exposure", 1.0, 0.0, secs=1)) + assert ("camera.exposure", 96, 120) in tl.spans() + + def test_at_parks_the_cursor_in_seconds(self): + tl = Timeline(fps=24, initial={"camera.exposure": (0.0,)}) + tl.at(secs=2.0).then(kf_segment("camera.exposure", 0.0, 1.0, secs=1)) + assert tl.spans() == [("camera.exposure", 48, 72)] + + def test_at_parks_the_cursor_in_frames(self): + tl = Timeline(fps=24, initial={"camera.exposure": (0.0,)}) + tl.at(frames=48).then(kf_segment("camera.exposure", 0.0, 1.0, frames=12)) + assert tl.spans() == [("camera.exposure", 48, 60)] + + def test_last_frame_is_the_end_of_the_final_driver(self): + tl = Timeline(fps=24, initial={"camera.exposure": (0.0,)}) + tl.then(kf_segment("camera.exposure", 0.0, 1.0, secs=2)) + assert tl.last_frame == 48 + + +class TestTimelineTimeConversion: + def test_cumulative_conversion_does_not_drift(self): + """Ten 0.1s segments at 24fps must end on frame 24, not 20.""" + tl = Timeline(fps=24, initial={"camera.exposure": (0.0,)}) + for _ in range(10): + tl.then(kf_segment("camera.exposure", 0.0, 1.0, secs=0.1)) + assert tl.last_frame == 24 + + def test_frame_specified_segments_span_exactly_that_many_frames(self): + tl = Timeline(fps=24, initial={"camera.exposure": (0.0,)}) + tl.then(kf_segment("camera.exposure", 0.0, 1.0, secs=0.1)) + tl.then(kf_segment("camera.exposure", 1.0, 0.0, frames=10)) + (_, s0, e0), (_, s1, e1) = tl.spans() + assert e1 - s1 == 10 + assert s1 == e0 + + def test_duration_requires_exactly_one_of_secs_or_frames(self): + tl = Timeline(fps=24, initial={"camera.exposure": (0.0,)}) + with pytest.raises(ValueError, match="exactly one"): + tl.then(kf_segment("camera.exposure", 0.0, 1.0, secs=1, frames=24)) + with pytest.raises(ValueError, match="exactly one"): + tl.then(kf_segment("camera.exposure", 0.0, 1.0)) + + def test_at_requires_exactly_one_of_secs_or_frames(self): + tl = Timeline(fps=24, initial={"camera.exposure": (0.0,)}) + with pytest.raises(ValueError, match="exactly one"): + tl.at(secs=1.0, frames=24) + with pytest.raises(ValueError, match="exactly one"): + tl.at() + + def test_rejects_a_non_positive_fps(self): + with pytest.raises(ValueError, match="fps"): + Timeline(fps=0, initial={}) + + +class TestTimelineSampling: + def test_holds_the_initial_value_before_the_first_driver(self): + tl = Timeline(fps=24, initial={"camera.exposure": (0.75,)}) + tl.at(frames=10).then(kf_segment("camera.exposure", 0.0, 1.0, frames=10)) + assert tl.sample(0) == {"camera.exposure": (0.75,)} + assert tl.sample(9) == {"camera.exposure": (0.75,)} + + def test_holds_the_final_value_after_the_last_driver(self): + tl = Timeline(fps=24, initial={"camera.exposure": (0.0,)}) + tl.then(kf_segment("camera.exposure", 0.0, 1.0, frames=10)) + assert tl.sample(10) == {"camera.exposure": (1.0,)} + assert tl.sample(50) == {"camera.exposure": (1.0,)} + + def test_adjacent_segments_agree_on_their_shared_boundary_frame(self): + tl = Timeline(fps=24, initial={"camera.exposure": (0.0,)}) + tl.then( + kf_segment("camera.exposure", 0.0, 45.0, frames=48), + kf_segment("camera.exposure", 45.0, 0.0, frames=48), + ) + assert tl.sample(48) == {"camera.exposure": (45.0,)} + assert tl.sample(0) == {"camera.exposure": (0.0,)} + assert tl.sample(96) == {"camera.exposure": (0.0,)} + + def test_abutting_segments_produce_no_duplicate_consecutive_frames(self): + tl = Timeline(fps=24, initial={"camera.exposure": (0.0,)}) + tl.then( + kf_segment("camera.exposure", 0.0, 45.0, frames=48), + kf_segment("camera.exposure", 45.0, 0.0, frames=48), + ) + values = [tl.sample(f)["camera.exposure"] for f in range(97)] + assert all(a != b for a, b in zip(values, values[1:])) + + def test_samples_independent_channels_independently(self): + tl = Timeline( + fps=24, initial={"camera.exposure": (0.0,), "render.density": (5.0,)} + ) + tl.then(kf_segment("camera.exposure", 0.0, 1.0, frames=10)) + assert tl.sample(5) == { + "camera.exposure": (0.5,), + "render.density": (5.0,), + } + + +class TestTimelineValidation: + def test_rejects_a_driver_on_an_unknown_channel(self): + tl = Timeline(fps=24, initial={"camera.nose": (0.0,)}) + tl.then(kf_segment("camera.nose", 0.0, 1.0, frames=10)) + with pytest.raises(UnknownChannelError, match="camera.nose"): + tl.sample(0) + + def test_rejects_a_value_of_the_wrong_arity_for_its_channel(self): + tl = Timeline(fps=24, initial={"camera.eye": (0.0, 0.0, 0.0)}) + tl.then(kf_segment("camera.eye", 0.0, 1.0, frames=10)) + with pytest.raises(ArityError, match="camera.eye"): + tl.sample(0) + + def test_rejects_an_initial_value_of_the_wrong_arity(self): + tl = Timeline(fps=24, initial={"camera.eye": (0.0,)}) + tl.then(kf_segment("camera.eye", (0.0, 0.0, 0.0), (1.0, 1.0, 1.0), frames=10)) + with pytest.raises(ArityError, match="camera.eye"): + tl.sample(0) + + def test_allows_drivers_that_only_touch_at_a_boundary_frame(self): + tl = Timeline(fps=24, initial={"camera.exposure": (0.0,)}) + tl.then( + kf_segment("camera.exposure", 0.0, 1.0, frames=48), + kf_segment("camera.exposure", 1.0, 0.0, frames=48), + ) + assert tl.sample(48) == {"camera.exposure": (1.0,)} + + def test_rejects_a_driven_channel_with_no_initial_value(self): + tl = Timeline(fps=24, initial={}) + tl.then(kf_segment("camera.exposure", 0.0, 1.0, frames=10)) + with pytest.raises(MissingInitialError, match="camera.exposure"): + tl.sample(0) + + +class FakeRenderer(Recorder): + """Recorder that also writes an RGBA frame, as agave_pyvk.redraw() does. + + Background pixels carry the composited RGB but zero alpha, which is exactly + what renderlib's headless redraw produces. + """ + + BACKGROUND = (51, 77, 128) + VOLUME = (200, 100, 50) + + def session(self, name): + self.calls.append(("session", (name,))) + self._path = name + + def redraw(self): + from PIL import Image + + self.calls.append(("redraw", ())) + image = Image.new("RGBA", (4, 2), self.BACKGROUND + (0,)) + image.putpixel((1, 1), self.VOLUME + (255,)) + image.save(self._path) + + def method_calls(self, name): + return [args for called, args in self.calls if called == name] + + +class TestRender: + def build(self): + tl = Timeline( + fps=24, + initial={"camera.exposure": (0.75,), "camera.eye": (1.0, 2.0, 3.0)}, + ) + tl.then(kf_segment("camera.exposure", 0.0, 1.0, frames=4)) + return tl + + def test_renders_one_more_frame_than_the_span(self, tmp_path): + r = FakeRenderer() + self.build().render(r, tmp_path, "frame") + assert len(r.method_calls("redraw")) == 5 + assert sorted(p.name for p in tmp_path.glob("*.png")) == [ + f"frame_{i:04d}.png" for i in range(5) + ] + + def test_drop_last_renders_exactly_duration_times_fps_frames(self, tmp_path): + r = FakeRenderer() + tl = Timeline(fps=24, initial={"camera.exposure": (0.0,)}) + tl.then(kf_segment("camera.exposure", 0.0, 1.0, secs=2)) + tl.render(r, tmp_path, "frame", drop_last=True) + assert len(r.method_calls("redraw")) == 48 + + def test_emits_every_channel_on_the_first_frame(self, tmp_path): + r = FakeRenderer() + self.build().render(r, tmp_path, "frame") + before_first_redraw = r.calls[: r.calls.index(("redraw", ()))] + emitted = {called for called, _ in before_first_redraw} + assert {"exposure", "eye"} <= emitted + + def test_does_not_re_emit_a_held_channel(self, tmp_path): + r = FakeRenderer() + self.build().render(r, tmp_path, "frame") + assert r.method_calls("eye") == [(1.0, 2.0, 3.0)] + + def test_re_emits_a_channel_only_while_it_changes(self, tmp_path): + r = FakeRenderer() + self.build().render(r, tmp_path, "frame") + assert r.method_calls("exposure") == [(0.0,), (0.25,), (0.5,), (0.75,), (1.0,)] + + def test_sets_channels_before_asking_for_the_frame(self, tmp_path): + r = FakeRenderer() + self.build().render(r, tmp_path, "frame") + names = [called for called, _ in r.calls] + assert names.index("exposure") < names.index("session") < names.index("redraw") + + def test_creates_the_output_directory(self, tmp_path): + r = FakeRenderer() + out = tmp_path / "nested" / "frames" + self.build().render(r, out, "frame") + assert out.is_dir() + + def test_flattens_alpha_so_frames_are_opaque(self, tmp_path): + from PIL import Image + + r = FakeRenderer() + self.build().render(r, tmp_path, "frame") + image = Image.open(tmp_path / "frame_0000.png") + assert image.mode == "RGB" + assert image.getpixel((0, 0)) == FakeRenderer.BACKGROUND + assert image.getpixel((1, 1)) == FakeRenderer.VOLUME + + def test_keeps_alpha_when_flattening_is_disabled(self, tmp_path): + from PIL import Image + + r = FakeRenderer() + self.build().render(r, tmp_path, "frame", flatten_alpha=False) + image = Image.open(tmp_path / "frame_0000.png") + assert image.mode == "RGBA" + assert image.getpixel((0, 0))[3] == 0 + + +class TestVectorMath: + def test_rotates_a_vector_about_an_axis_by_the_right_hand_rule(self): + rotated = rotate_about_axis((0.0, 0.0, 1.0), (0.0, 1.0, 0.0), 90.0) + assert rotated == pytest.approx((1.0, 0.0, 0.0), abs=1e-12) + + def test_a_zero_degree_rotation_is_the_identity(self): + v = (0.3, -0.4, 0.5) + assert rotate_about_axis(v, (0.0, 1.0, 0.0), 0.0) == pytest.approx(v) + + +class TestCameraOrbit: + EYE = (0.0, 0.0, 1.0) + TARGET = (0.0, 0.0, 0.0) + UP = (0.0, 1.0, 0.0) + + def orbit(self, deg0, deg1, start=0, end=10): + return CameraOrbit(self.EYE, self.TARGET, self.UP, deg0, deg1, start, end) + + def test_writes_both_camera_channels(self): + assert set(self.orbit(0, 45).writes()) == {"camera.eye", "camera.up"} + + def test_reproduces_the_reference_view_at_zero_degrees(self): + values = self.orbit(0, 45).evaluate(0) + assert values["camera.eye"] == pytest.approx(self.EYE) + assert values["camera.up"] == pytest.approx(self.UP) + + def test_rotates_the_eye_about_the_vertical_view_axis(self): + values = self.orbit(0, 90).evaluate(10) + assert values["camera.eye"] == pytest.approx((1.0, 0.0, 0.0), abs=1e-12) + + def test_preserves_the_eye_to_target_distance(self): + driver = self.orbit(0, 45) + for frame in range(11): + eye = driver.evaluate(frame)["camera.eye"] + assert math.dist(eye, self.TARGET) == pytest.approx(1.0) + + def test_keeps_up_perpendicular_to_the_look_direction(self): + driver = self.orbit(0, 45) + for frame in range(11): + values = driver.evaluate(frame) + eye, up = values["camera.eye"], values["camera.up"] + forward = [t - e for t, e in zip(self.TARGET, eye)] + assert sum(a * b for a, b in zip(up, forward)) == pytest.approx(0.0) + + def test_returns_to_the_reference_view_after_a_round_trip(self): + out = self.orbit(0, 45).evaluate(10) + back = self.orbit(45, 0).evaluate(10) + assert back["camera.eye"] == pytest.approx(self.EYE) + assert out["camera.eye"] != pytest.approx(self.EYE) + + def test_handles_an_up_vector_that_is_not_perpendicular_to_the_view(self): + """The vertical axis is up with the look-direction component removed.""" + skewed = CameraOrbit(self.EYE, self.TARGET, (0.0, 1.0, 0.5), 0, 90, 0, 10) + eye = skewed.evaluate(10)["camera.eye"] + assert eye == pytest.approx((1.0, 0.0, 0.0), abs=1e-12) + + +class TestSegmentHelpers: + def test_ramp_interpolates_a_channel_between_two_values(self): + tl = Timeline(fps=24, initial={"camera.exposure": (0.0,)}) + tl.then(ramp("camera.exposure", 0.0, 1.0, frames=10)) + assert tl.sample(5) == {"camera.exposure": (0.5,)} + + def test_ramp_carries_its_easing(self): + tl = Timeline(fps=24, initial={"camera.exposure": (0.0,)}) + tl.then(ramp("camera.exposure", 0.0, 1.0, frames=10, ease=ease_in_out)) + assert tl.sample(2)["camera.exposure"][0] == pytest.approx(0.104) + + def test_ramp_handles_vector_channels(self): + tl = Timeline(fps=24, initial={"camera.eye": (0.0, 0.0, 0.0)}) + tl.then(ramp("camera.eye", (0.0, 0.0, 0.0), (2.0, 4.0, 6.0), frames=10)) + assert tl.sample(5) == {"camera.eye": (1.0, 2.0, 3.0)} + + def test_travel_clip_plane_holds_the_normal_and_moves_the_distance(self): + tl = Timeline(fps=24, initial={"clip.plane": (0.0, 0.0, 1.0, 1.0)}) + tl.then(travel_clip_plane((0.0, 0.0, 1.0), 1.0, 0.0, frames=10)) + assert tl.sample(5) == {"clip.plane": (0.0, 0.0, 1.0, 0.5)} + assert tl.sample(10) == {"clip.plane": (0.0, 0.0, 1.0, 0.0)} + + def test_travel_clip_plane_normalizes_the_normal(self): + tl = Timeline(fps=24, initial={"clip.plane": (0.0, 0.0, 1.0, 1.0)}) + tl.then(travel_clip_plane((0.0, 0.0, 2.0), 1.0, 1.0, frames=10)) + assert tl.sample(0) == {"clip.plane": (0.0, 0.0, 1.0, 1.0)} + + def test_pivot_camera_drives_both_camera_channels(self): + tl = Timeline( + fps=24, + initial={"camera.eye": (0.0, 0.0, 1.0), "camera.up": (0.0, 1.0, 0.0)}, + ) + tl.then( + pivot_camera( + (0.0, 0.0, 1.0), (0.0, 0.0, 0.0), (0.0, 1.0, 0.0), 0, 90, frames=10 + ) + ) + assert tl.sample(0)["camera.eye"] == pytest.approx((0.0, 0.0, 1.0)) + assert tl.sample(10)["camera.eye"] == pytest.approx((1.0, 0.0, 0.0), abs=1e-12) + + def test_helpers_require_exactly_one_of_secs_or_frames(self): + tl = Timeline(fps=24, initial={"camera.exposure": (0.0,)}) + with pytest.raises(ValueError, match="exactly one"): + tl.then(ramp("camera.exposure", 0.0, 1.0, secs=1, frames=24)) + + +class TestRenderProgress: + def test_reports_each_frame_and_the_total(self, tmp_path): + r = FakeRenderer() + tl = Timeline(fps=24, initial={"camera.exposure": (0.0,)}) + tl.then(kf_segment("camera.exposure", 0.0, 1.0, frames=4)) + seen = [] + tl.render(r, tmp_path, "frame", progress=lambda f, n: seen.append((f, n))) + assert seen == [(0, 5), (1, 5), (2, 5), (3, 5), (4, 5)] + + def test_reports_the_reduced_total_when_dropping_the_last_frame(self, tmp_path): + r = FakeRenderer() + tl = Timeline(fps=24, initial={"camera.exposure": (0.0,)}) + tl.then(kf_segment("camera.exposure", 0.0, 1.0, frames=4)) + seen = [] + tl.render( + r, + tmp_path, + "frame", + drop_last=True, + progress=lambda f, n: seen.append((f, n)), + ) + assert seen == [(0, 4), (1, 4), (2, 4), (3, 4)] + + +class TestExampleAnimation: + """The shot declared by the example script, checked without rendering.""" + + NORMAL = (0.0, 0.0, 1.0) + D_START = 0.11644191741943359 + D_END = 0.058220958709716795 + + def timeline(self): + from agave_pyvk import test_animation + + return test_animation, test_animation.build_timeline( + self.NORMAL, self.D_START, self.D_END + ) + + def test_runs_sixteen_seconds_at_twenty_four_fps(self): + _, tl = self.timeline() + assert tl.last_frame == 384 + + def test_places_each_phase_on_the_expected_frames(self): + _, tl = self.timeline() + assert tl.spans() == [ + ("camera.eye", 0, 48), + ("camera.up", 0, 48), + ("camera.eye", 48, 96), + ("camera.up", 48, 96), + ("clip.plane", 96, 192), + ("membrane.opacity", 192, 240), + ("clip.plane", 240, 336), + ("membrane.opacity", 336, 384), + ] + + def test_opens_on_the_original_view_with_nothing_clipped(self): + script, tl = self.timeline() + state = tl.sample(0) + assert state["camera.eye"] == pytest.approx(script.CAMERA_EYE) + assert state["camera.up"] == pytest.approx(script.CAMERA_UP) + assert state["clip.plane"] == pytest.approx((*self.NORMAL, self.D_START)) + assert state["membrane.opacity"] == (script.MEMBRANE_OPACITY,) + + def test_pivot_peaks_at_the_halfway_mark_and_returns(self): + script, tl = self.timeline() + assert tl.sample(48)["camera.eye"] != pytest.approx(script.CAMERA_EYE) + assert tl.sample(96)["camera.eye"] == pytest.approx(script.CAMERA_EYE) + + def test_camera_is_still_once_the_clip_plane_starts_moving(self): + script, tl = self.timeline() + for frame in (96, 192, 240, 336, 384): + assert tl.sample(frame)["camera.eye"] == pytest.approx(script.CAMERA_EYE) + + def test_clip_plane_travels_in_and_back(self): + _, tl = self.timeline() + assert tl.sample(96)["clip.plane"][3] == pytest.approx(self.D_START) + assert tl.sample(192)["clip.plane"][3] == pytest.approx(self.D_END) + assert tl.sample(240)["clip.plane"][3] == pytest.approx(self.D_END) + assert tl.sample(336)["clip.plane"][3] == pytest.approx(self.D_START) + + def test_membrane_fades_out_then_back_in(self): + script, tl = self.timeline() + opacity = script.MEMBRANE_OPACITY + assert tl.sample(192)["membrane.opacity"] == pytest.approx((opacity,)) + assert tl.sample(240)["membrane.opacity"] == pytest.approx((0.0,)) + assert tl.sample(336)["membrane.opacity"] == pytest.approx((0.0,)) + assert tl.sample(384)["membrane.opacity"] == pytest.approx((opacity,)) + + def test_ends_on_the_state_it_started_from(self): + _, tl = self.timeline() + assert tl.sample(384) == pytest.approx(tl.sample(0)) + + +class TestWeightedBlending: + """Overlapping drivers blend as sum(w*v) / sum(w).""" + + def flat(self, channel, value, start, end, **kw): + return Keyframes(channel, [(start, value), (end, value)], **kw) + + def test_a_driver_weighs_one_by_default(self): + assert self.flat("x", 0.0, 0, 10).weight(5) == 1.0 + + def test_overlapping_drivers_are_allowed(self): + tl = Timeline(fps=24, initial={"camera.exposure": (0.0,)}) + tl.add(self.flat("camera.exposure", 0.0, 0, 10)) + tl.add(self.flat("camera.exposure", 1.0, 5, 15)) + assert tl.sample(7) is not None + + def test_two_equally_weighted_drivers_average(self): + tl = Timeline(fps=24, initial={"camera.exposure": (0.0,)}) + tl.add(self.flat("camera.exposure", 0.0, 0, 10)) + tl.add(self.flat("camera.exposure", 1.0, 0, 10)) + assert tl.sample(5) == {"camera.exposure": (0.5,)} + + def test_weights_bias_the_blend(self): + tl = Timeline(fps=24, initial={"camera.exposure": (0.0,)}) + tl.add(self.flat("camera.exposure", 0.0, 0, 10, weight=3.0)) + tl.add(self.flat("camera.exposure", 1.0, 0, 10, weight=1.0)) + assert tl.sample(5) == pytest.approx({"camera.exposure": (0.25,)}) + + def test_a_zero_weight_driver_does_not_contribute(self): + tl = Timeline(fps=24, initial={"camera.exposure": (0.0,)}) + tl.add(self.flat("camera.exposure", 7.0, 0, 10, weight=1.0)) + tl.add(self.flat("camera.exposure", 1.0, 0, 10, weight=0.0)) + assert tl.sample(5) == {"camera.exposure": (7.0,)} + + def test_weight_may_vary_over_time_to_crossfade(self): + tl = Timeline(fps=24, initial={"camera.exposure": (0.0,)}) + tl.add(self.flat("camera.exposure", 0.0, 0, 10, weight=lambda f: 1.0 - f / 10)) + tl.add(self.flat("camera.exposure", 1.0, 0, 10, weight=lambda f: f / 10)) + assert tl.sample(0) == pytest.approx({"camera.exposure": (0.0,)}) + assert tl.sample(5) == pytest.approx({"camera.exposure": (0.5,)}) + assert tl.sample(10) == pytest.approx({"camera.exposure": (1.0,)}) + + def test_blends_each_component_of_a_vector_channel(self): + tl = Timeline(fps=24, initial={"camera.eye": (0.0, 0.0, 0.0)}) + tl.add(self.flat("camera.eye", (0.0, 0.0, 0.0), 0, 10)) + tl.add(self.flat("camera.eye", (2.0, 4.0, 6.0), 0, 10)) + assert tl.sample(5) == {"camera.eye": (1.0, 2.0, 3.0)} + + def test_only_drivers_covering_the_frame_take_part(self): + tl = Timeline(fps=24, initial={"camera.exposure": (0.0,)}) + tl.add(self.flat("camera.exposure", 0.0, 0, 10)) + tl.add(self.flat("camera.exposure", 1.0, 20, 30)) + assert tl.sample(5) == {"camera.exposure": (0.0,)} + assert tl.sample(25) == {"camera.exposure": (1.0,)} + + def test_total_weight_of_zero_holds_the_last_blended_value(self): + tl = Timeline(fps=24, initial={"camera.exposure": (9.0,)}) + tl.add(self.flat("camera.exposure", 3.0, 0, 10)) + tl.add(self.flat("camera.exposure", 5.0, 10, 20, weight=0.0)) + assert tl.sample(15) == {"camera.exposure": (3.0,)} + + def test_total_weight_of_zero_before_any_driver_uses_the_initial(self): + tl = Timeline(fps=24, initial={"camera.exposure": (9.0,)}) + tl.add(self.flat("camera.exposure", 5.0, 0, 10, weight=0.0)) + assert tl.sample(5) == {"camera.exposure": (9.0,)} + + def test_a_multi_channel_driver_weighs_both_of_its_channels(self): + tl = Timeline( + fps=24, + initial={"camera.eye": (0.0, 0.0, 1.0), "camera.up": (0.0, 1.0, 0.0)}, + ) + orbit = CameraOrbit( + (0.0, 0.0, 1.0), (0.0, 0.0, 0.0), (0.0, 1.0, 0.0), 90, 90, 0, 10 + ) + tl.add(orbit) + tl.add(self.flat("camera.eye", (0.0, 0.0, 1.0), 0, 10)) + tl.add(self.flat("camera.up", (0.0, 1.0, 0.0), 0, 10)) + # orbit at 90 deg puts the eye at (1,0,0); blended 50/50 with (0,0,1) + assert tl.sample(5)["camera.eye"] == pytest.approx((0.5, 0.0, 0.5), abs=1e-12) + + def test_abutting_segments_still_agree_at_their_shared_frame(self): + """The boundary value is now a blend of two equal values, not a tie-break.""" + tl = Timeline(fps=24, initial={"camera.exposure": (0.0,)}) + tl.then( + kf_segment("camera.exposure", 0.0, 45.0, frames=48), + kf_segment("camera.exposure", 45.0, 0.0, frames=48), + ) + assert tl.sample(48) == pytest.approx({"camera.exposure": (45.0,)}) + + def test_segment_helpers_accept_a_weight(self): + tl = Timeline(fps=24, initial={"camera.exposure": (0.0,)}) + tl.together( + ramp("camera.exposure", 0.0, 0.0, frames=10, weight=3.0), + ramp("camera.exposure", 1.0, 1.0, frames=10, weight=1.0), + ) + assert tl.sample(5) == pytest.approx({"camera.exposure": (0.25,)}) + + def test_a_zero_weight_driver_does_not_shadow_the_held_value(self): + """Skipped entirely means it cannot win the hold lookup either.""" + tl = Timeline(fps=24, initial={"camera.exposure": (9.0,)}) + tl.add(self.flat("camera.exposure", 3.0, 0, 10)) + tl.add(self.flat("camera.exposure", 5.0, 10, 20, weight=0.0)) + assert tl.sample(25) == {"camera.exposure": (3.0,)} + + def test_holds_past_several_zero_weight_drivers(self): + tl = Timeline(fps=24, initial={"camera.exposure": (9.0,)}) + tl.add(self.flat("camera.exposure", 3.0, 0, 10)) + tl.add(self.flat("camera.exposure", 5.0, 10, 20, weight=0.0)) + tl.add(self.flat("camera.exposure", 7.0, 20, 30, weight=0.0)) + assert tl.sample(35) == {"camera.exposure": (3.0,)} diff --git a/renderlib/CMakeLists.txt b/renderlib/CMakeLists.txt index ff1de219ff..8d9da83f62 100644 --- a/renderlib/CMakeLists.txt +++ b/renderlib/CMakeLists.txt @@ -99,6 +99,8 @@ target_sources(renderlib PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/ViewerWindow.h" "${CMAKE_CURRENT_SOURCE_DIR}/VolumeDimensions.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/VolumeDimensions.h" + "${CMAKE_CURRENT_SOURCE_DIR}/VolumeInfoToJson.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/VolumeInfoToJson.h" "${CMAKE_CURRENT_SOURCE_DIR}/PythonRenderer.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/PythonRenderer.h" ) diff --git a/renderlib/PythonRenderer.cpp b/renderlib/PythonRenderer.cpp index 7b75a48a63..d208a8b662 100644 --- a/renderlib/PythonRenderer.cpp +++ b/renderlib/PythonRenderer.cpp @@ -9,6 +9,7 @@ #include "SceneView.h" #include "TimeStampTool.h" #include "VolumeDimensions.h" +#include "VolumeInfoToJson.h" #include "gfxapi/Backend.h" #include "gfxapi/Framebuffer.h" #include "gfxapi/IGestureRenderer.h" @@ -22,8 +23,6 @@ #include #include -#include - namespace { std::mutex s_runtimeMutex; @@ -233,30 +232,7 @@ PythonRenderer::loadVolume(std::shared_ptr image, m_renderSettings->m_DirtyFlags.SetFlag(VolumeDataDirty); m_renderSettings->m_DirtyFlags.SetFlag(TransferFunctionDirty); - nlohmann::json result; - result["name"] = name; - result["x"] = image->sizeX(); - result["y"] = image->sizeY(); - result["z"] = image->sizeZ(); - result["c"] = image->sizeC(); - result["t"] = 1; - result["pixel_size_x"] = image->physicalSizeX(); - result["pixel_size_y"] = image->physicalSizeY(); - result["pixel_size_z"] = image->physicalSizeZ(); - result["spatial_units"] = image->spatialUnits(); - result["channel_names"] = dimensions.channelNames; - - std::vector channelMins; - std::vector channelMaxes; - channelMins.reserve(image->sizeC()); - channelMaxes.reserve(image->sizeC()); - for (uint32_t channel = 0; channel < image->sizeC(); ++channel) { - channelMins.push_back(image->channel(channel)->m_histogram.getDataMin()); - channelMaxes.push_back(image->channel(channel)->m_histogram.getDataMax()); - } - result["channel_min_intensity"] = channelMins; - result["channel_max_intensity"] = channelMaxes; - return result.dump(); + return volumeInfoToJson(image, dimensions, name).dump(); } PythonRendererResult diff --git a/renderlib/VolumeInfoToJson.cpp b/renderlib/VolumeInfoToJson.cpp new file mode 100644 index 0000000000..78b568019b --- /dev/null +++ b/renderlib/VolumeInfoToJson.cpp @@ -0,0 +1,58 @@ +#include "VolumeInfoToJson.h" + +#include "ImageXYZC.h" +#include "VolumeDimensions.h" + +nlohmann::json +volumeInfoToJson(const std::shared_ptr& image, const VolumeDimensions& dims, const std::string& name) +{ + nlohmann::json j; + j["name"] = name; + + // the volume as loaded + j["x"] = (int)image->sizeX(); + j["y"] = (int)image->sizeY(); + j["z"] = (int)image->sizeZ(); + j["c"] = (int)image->sizeC(); + j["t"] = (int)dims.sizeT; + j["pixel_size_x"] = image->physicalSizeX(); + j["pixel_size_y"] = image->physicalSizeY(); + j["pixel_size_z"] = image->physicalSizeZ(); + j["spatial_units"] = image->spatialUnits(); + + std::vector channelNames; + std::vector channelMinIntensity; + std::vector channelMaxIntensity; + channelNames.reserve(image->sizeC()); + channelMinIntensity.reserve(image->sizeC()); + channelMaxIntensity.reserve(image->sizeC()); + for (uint32_t i = 0; i < image->sizeC(); ++i) { + channelNames.push_back(image->channel(i)->m_name); + channelMinIntensity.push_back(image->channel(i)->m_histogram.getDataMin()); + channelMaxIntensity.push_back(image->channel(i)->m_histogram.getDataMax()); + } + j["channel_names"] = channelNames; + j["channel_min_intensity"] = channelMinIntensity; + j["channel_max_intensity"] = channelMaxIntensity; + + // full metadata for the source, which may describe more than was loaded + nlohmann::json vd; + vd["size_x"] = (int)dims.sizeX; + vd["size_y"] = (int)dims.sizeY; + vd["size_z"] = (int)dims.sizeZ; + vd["size_c"] = (int)dims.sizeC; + vd["size_t"] = (int)dims.sizeT; + vd["physical_size_x"] = dims.physicalSizeX; + vd["physical_size_y"] = dims.physicalSizeY; + vd["physical_size_z"] = dims.physicalSizeZ; + vd["spatial_units"] = dims.spatialUnits; + vd["time_unit"] = dims.timeUnit; + vd["time_units"] = dims.timeUnits; + vd["bits_per_pixel"] = (int)dims.bitsPerPixel; + vd["sample_format"] = (int)dims.sampleFormat; + vd["dimension_order"] = dims.dimensionOrder; + vd["channel_names"] = dims.channelNames; + j["volume_dimensions"] = vd; + + return j; +} diff --git a/renderlib/VolumeInfoToJson.h b/renderlib/VolumeInfoToJson.h new file mode 100644 index 0000000000..52b7972899 --- /dev/null +++ b/renderlib/VolumeInfoToJson.h @@ -0,0 +1,25 @@ +#pragma once + +#include "json/json.hpp" + +#include +#include + +class ImageXYZC; +struct VolumeDimensions; + +// Build the JSON description of a freshly loaded volume. +// +// Every code path that loads a volume reports it through this one function -- +// LoadDataCommand over the binary protocol, PythonRenderer in process -- so the +// structure clients see cannot drift between them. +// +// The top-level fields describe the volume that was actually loaded, after any +// channel, region, or multiresolution-level subsetting. The nested +// "volume_dimensions" object describes the source as the reader reported it, +// which may be larger. For an in-memory array the two always agree. +// +// `image` is the loaded volume, `dims` the source metadata, and `name` the +// file path or array name it came from. +nlohmann::json +volumeInfoToJson(const std::shared_ptr& image, const VolumeDimensions& dims, const std::string& name); diff --git a/renderlib/command.cpp b/renderlib/command.cpp index 11bbfce32f..d9c9a5eb3f 100644 --- a/renderlib/command.cpp +++ b/renderlib/command.cpp @@ -7,6 +7,7 @@ #include "RenderSettings.h" #include "StringUtil.h" #include "VolumeDimensions.h" +#include "VolumeInfoToJson.h" #include "io/ApplyTimeStepToScene.h" #include "json/json.hpp" @@ -731,47 +732,9 @@ LoadDataCommand::execute(ExecutionContext* c) c->m_renderSettings->m_DirtyFlags.SetFlag(TransferFunctionDirty); // fire back some json immediately... - nlohmann::json j; + nlohmann::json j = volumeInfoToJson(image, dims, m_data.m_path); + // transport envelope field: the webclient routes responses on this j["commandId"] = (int)LoadDataCommand::m_ID; - j["x"] = (int)image->sizeX(); - j["y"] = (int)image->sizeY(); - j["z"] = (int)image->sizeZ(); - j["c"] = (int)image->sizeC(); - j["t"] = (int)dims.sizeT; - j["pixel_size_x"] = image->physicalSizeX(); - j["pixel_size_y"] = image->physicalSizeY(); - j["pixel_size_z"] = image->physicalSizeZ(); - // Full VolumeDimensions metadata for the loaded image. - nlohmann::json vd; - vd["size_x"] = (int)dims.sizeX; - vd["size_y"] = (int)dims.sizeY; - vd["size_z"] = (int)dims.sizeZ; - vd["size_c"] = (int)dims.sizeC; - vd["size_t"] = (int)dims.sizeT; - vd["physical_size_x"] = dims.physicalSizeX; - vd["physical_size_y"] = dims.physicalSizeY; - vd["physical_size_z"] = dims.physicalSizeZ; - vd["spatial_units"] = dims.spatialUnits; - vd["time_unit"] = dims.timeUnit; - vd["time_units"] = dims.timeUnits; - vd["bits_per_pixel"] = (int)dims.bitsPerPixel; - vd["sample_format"] = (int)dims.sampleFormat; - vd["dimension_order"] = dims.dimensionOrder; - vd["channel_names"] = dims.channelNames; - j["volume_dimensions"] = vd; - std::vector channelNames; - for (uint32_t i = 0; i < image->sizeC(); ++i) { - channelNames.push_back((image->channel(i)->m_name)); - } - j["channel_names"] = channelNames; - std::vector channelMinIntensity; - std::vector channelMaxIntensity; - for (uint32_t i = 0; i < image->sizeC(); ++i) { - channelMinIntensity.push_back(image->channel(i)->m_histogram.getDataMin()); - channelMaxIntensity.push_back(image->channel(i)->m_histogram.getDataMax()); - } - j["channel_min_intensity"] = channelMinIntensity; - j["channel_max_intensity"] = channelMaxIntensity; c->m_message = j.dump(); }