Compare commits

..
48 Commits
Author SHA1 Message Date
w.pomp 645c75535c Merge remote-tracking branch 'origin/main'
pytest / Publish (pypi.org) (push) Failing after 32s
2026-11-04 17:10:29 +01:00
w.pomp 4a5a538bf3 - a lot of todo methods for Imread 2026-11-04 17:10:21 +01:00
wim 564e59cd9f Update .gitignore
pytest / Publish (pypi.org) (push) Successful in 4m9s
2026-10-30 22:09:23 +01:00
wim fcd9aa4e5d Update .github/workflows/publish_crates.yml
pytest / Publish (pypi.org) (push) Successful in 4m12s
2026-10-30 21:37:19 +01:00
wim 1809ec859f Update .gitignore
pytest / Publish (pypi.org) (push) Successful in 4m7s
2026-10-30 21:25:33 +01:00
wim 6da2cb5636 Update .github/workflows/publish_crates.yml
pytest / Publish (pypi.org) (push) Successful in 4m12s
2026-10-30 21:24:36 +01:00
Wim Pomp 6fc6b6dfc3 - fix workflow
pytest / Publish (pypi.org) (push) Successful in 4m56s
2026-10-30 21:04:15 +01:00
Wim Pomp 1574bad208 - fix workflow
pytest / Publish (pypi.org) (push) Successful in 4m26s
2026-10-30 20:02:03 +01:00
Wim Pomp 651e6dd25a - fix README
pytest / Publish (pypi.org) (push) Successful in 5m16s
2026-10-30 19:52:39 +01:00
Wim Pomp 9871a98946 - fix workflow
pytest / Publish (pypi.org) (push) Successful in 4m13s
2026-10-30 18:55:12 +01:00
Wim Pomp 4fbf5b23e2 - fix workflow
pytest / Publish (pypi.org) (push) Failing after 4m8s
2026-10-30 18:38:47 +01:00
Wim Pomp 9416e774ad - fix workflow
pytest / Publish (pypi.org) (push) Failing after 1m1s
2026-10-30 18:28:15 +01:00
Wim Pomp 60b4e0fe7d - fix slicing bug
pytest / Publish (pypi.org) (push) Failing after 15s
- add pytest workflow
2026-10-30 18:25:05 +01:00
w.pomp 825bc99523 - remove libczirw-sys submodule 2026-10-29 18:55:38 +01:00
w.pomp 05ed9112d8 build: keep debug info in the test profile only, not release
[profile.release] debug = true forced full DWARF into release builds.
On ELF targets (the manylinux/musllinux wheels) the debug sections are
embedded in the .so (~160 MB, over PyPI's 100 MB file limit); MSVC emits
a sidecar .pdb instead and the apple/zig link path drops it, so only the
Linux wheels were bloated. Move debug=true to [profile.test] so cargo
still gets readable backtraces without fattening the release wheels.
2026-10-29 17:20:13 +01:00
w.pomp 8338af454f build: bump libczirw-sys: Eigen is now a shallow git submodule (tag 3.4.0) 2026-10-29 16:06:09 +01:00
w.pomp 546e022cfa - bump libczirw-sys to fix Windowscodecs.lib case-symlink for aarch64-pc-windows-msvc 2026-10-29 14:19:05 +01:00
w.pomp d8696bdb04 - use submodules: recursive in checkout so libczirw-sys's nested libczi submodule (CMake source tree) is actually fetched in CI 2026-10-29 13:28:55 +01:00
w.pomp aadf235ca5 - fix clang detection loop: apt-cache search prints 'name - desc', so use awk field 1 (not 2) and strip the clang- prefix from the loop variable 2026-10-29 13:22:35 +01:00
w.pomp 120453582c - install the newest actually-installable versioned clang/llvm/lld/libclang toolchain from apt.llvm.org (the unversioned metas are currently broken: clang wants clang-23 which does not resolve); pass LLVM_BIN/LIBCLANG_PATH to the later steps 2026-10-29 13:02:00 +01:00
w.pomp 592e9a944b - pypi wheels against the libczirw-sys submodule (self-contained Eigen build, msvc runtime alignment, bindgen flags) - and slim down publish_pypi.yml: no pinned zig/clang versions, no cmake shim, no bindgen env vars; only the ring /imsvc clang shim remains 2026-10-29 12:00:24 +01:00
w.pomp aff6dfb51b - try fix publish_pypi 2026-10-28 17:24:47 +01:00
w.pomp 1020df649a - try fix publish_pypi 2026-10-28 16:34:38 +01:00
w.pomp d4a5bca8ba - try fix publish_pypi 2026-10-28 15:20:12 +01:00
w.pomp f20f82889d - try fix publish_pypi 2026-10-28 14:04:37 +01:00
w.pomp b049939214 - try fix publish_pypi 2026-10-28 13:22:28 +01:00
w.pomp d0c06256f1 - try fix publish_pypi 2026-10-28 12:51:59 +01:00
w.pomp 3c787e8d29 - install clang 2026-10-27 19:46:13 +01:00
w.pomp 3f8d1311aa - install cmake 2026-10-27 19:37:41 +01:00
w.pomp a400fbd800 - fix publish_pypi 2026-10-27 19:32:28 +01:00
w.pomp c5d77fe289 - remove doc_auto_cfg 2026-10-27 19:25:52 +01:00
w.pomp 2d13851a38 - remove redundant homepage 2026-10-27 19:23:37 +01:00
w.pomp 7db52bb63b - remove redundant homepage
- remove doc_auto_cfg
2026-10-27 19:22:30 +01:00
w.pomp d19a8d2d69 - repo link fix 2026-10-27 19:14:22 +01:00
w.pomp a055e08a20 - repo link fix 2026-10-27 19:12:05 +01:00
w.pomp 5f70c4152b - update workflows 2026-10-27 19:05:31 +01:00
w.pomp 02cfa39677 - pin zip dependency of bioformats 2026-10-27 19:01:15 +01:00
w.pomp 3c7af5da4d - return Error, raise Exception when trying to use f128 data
- clean parsing of python types into rust types
- finish PyView.reshape
2026-10-27 18:23:37 +01:00
Wim Pomp 8a4c965477 - default value argument for interpolation 2026-10-25 10:23:57 +01:00
Wim Pomp 22e8ab1dd2 - some more macro use
- implement reshape for python
- make Reader and View Sync
- register_translation_fft
- calculate_transforms_from and functions it needs
2026-10-24 11:58:06 +02:00
w.pomp 3e33fe18c1 - improve Imread slicing
- can_open function for Reader trait
2026-10-22 19:50:20 +02:00
w.pomp f0404d0419 - remove center and shape from Transform 2026-10-22 11:47:26 +02:00
w.pomp e1caa1c0b0 - fix Imread.transpose
- implement with_transform
- implement 2d transforms in View.as_array_dyn
2026-10-21 20:08:42 +02:00
Wim Pomp 453237cae9 - testing 2d transforms 2026-10-10 12:44:37 +02:00
Wim Pomp 4815690621 - read ome xml tiff 2026-10-03 23:08:23 +02:00
Wim Pomp 7f07f82fdb - readme 2026-10-03 19:40:24 +02:00
Wim Pomp e55152c55d - use a fork of the tiff crate to open RGBPalette tiffs 2026-10-03 19:35:01 +02:00
Wim Pomp aef3a2d502 Patch tiff crate to use fork with PhotometricInterpretation fix 2026-10-03 15:06:09 +02:00
60 changed files with 10410 additions and 3800 deletions

No files matched your search

+12 -9
View File
@@ -1,6 +1,6 @@
name: Publish name: Publish_rs
on: [workflow_dispatch] on: [ workflow_dispatch ]
permissions: permissions:
contents: read contents: read
@@ -10,23 +10,27 @@ jobs:
name: Publish (crates.io) name: Publish (crates.io)
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 25 timeout-minutes: 25
env:
CARGO_HOME: ${{ github.workspace }}/.cargo
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
with:
submodules: recursive
- name: Restore cache - name: Restore cache
uses: actions/cache/restore@v4 uses: actions/cache/restore@v4
with: with:
path: | path: ${{ env.CARGO_HOME }}
~/.cargo
key: cache-ubuntu-cargo-publish key: cache-ubuntu-cargo-publish
- name: Install Rust - name: Install Rust
run: |- run: |-
export PATH="$HOME/.cargo/bin:$PATH" export PATH="$CARGO_HOME/bin:$PATH"
if ! command -v rustc >/dev/null 2>&1; then if ! command -v rustc >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
else else
rustup update rustup update
fi fi
echo "$CARGO_HOME/bin" >> "$GITHUB_PATH"
cargo install cargo-release cargo install cargo-release
shell: bash shell: bash
@@ -46,9 +50,9 @@ jobs:
# which is reasonable, since this job only runs after the Linux, Windows, and WASM builds # which is reasonable, since this job only runs after the Linux, Windows, and WASM builds
# have passed. # have passed.
- name: "cargo release publish" - name: "cargo release publish"
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_API_TOKEN }}
run: |- run: |-
export PATH="$HOME/.osxcross/bin:$PATH"
cargo login ${{ secrets.CRATES_IO_API_TOKEN }}
cargo release \ cargo release \
publish \ publish \
--workspace \ --workspace \
@@ -61,6 +65,5 @@ jobs:
- name: Store cache - name: Store cache
uses: actions/cache/save@v4 uses: actions/cache/save@v4
with: with:
path: | path: ${{ env.CARGO_HOME }}
~/.cargo
key: cache-ubuntu-cargo-publish key: cache-ubuntu-cargo-publish
+121 -31
View File
@@ -1,6 +1,6 @@
name: CI name: Publish_py
on: [workflow_dispatch] on: [ workflow_dispatch ]
permissions: permissions:
contents: read contents: read
@@ -9,8 +9,14 @@ jobs:
pypi_publish: pypi_publish:
name: Publish (pypi.org) name: Publish (pypi.org)
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
CARGO_HOME: ${{ github.workspace }}/.cargo
RUSTUP_HOME: ${{ github.workspace }}/.rustup
RUSTUP_TOOLCHAIN: stable
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
with:
submodules: recursive
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
with: with:
python-version: 3.x python-version: 3.x
@@ -23,33 +29,62 @@ jobs:
~/.cache/pip-wheel ~/.cache/pip-wheel
~/.cache/sccache ~/.cache/sccache
~/.cache/cargo-xwin ~/.cache/cargo-xwin
~/.cargo ${{ env.CARGO_HOME }}
~/.osxcross ~/.osxcross
key: cache-ubuntu-maturin-cross-compile key: cache-ubuntu-maturin-cross-compile
- name: Install llvm - name: Install build dependencies
run: | run: |
if ! command -v llvm-dlltool >/dev/null 2>&1; then # the stock clang on noble (18) is too old for the 2025+ MSVC STL that
# cargo-xwin downloads (yvals_core.h: "error STL1000: expected Clang
# 19.0.0 or newer"), so take a newer clang toolchain from apt.llvm.org.
# apt.llvm.org sometimes half-publishes a new version, in which case the
# unversioned meta packages become uninstallable ("clang: Depends:
# clang-NN ... not installable"); so pick the newest versioned set that
# actually resolves (a dry-run checks the whole dependency closure) -
# nothing is hardcoded, this tracks the newest working release.
sudo apt update sudo apt update
sudo apt install -y llvm echo "deb [trusted=yes] https://apt.llvm.org/noble/ llvm-toolchain-noble main" | sudo tee /etc/apt/sources.list.d/llvm-toolchain-noble.list >/dev/null
sudo apt update
sudo apt install -y build-essential cmake
NEWEST=""
for name in $(apt-cache search '^clang-[0-9]+$' | awk '{print $1}' | sort -rV); do
v=${name#clang-}
if sudo apt-get install --dry-run -y "clang-$v" "llvm-$v" "lld-$v" "libclang-$v-dev" >/dev/null 2>&1; then
NEWEST=$v
break
fi fi
done
[ -n "$NEWEST" ] || { echo "no installable clang toolchain found in apt.llvm.org" >&2; exit 1; }
echo "using clang/llvm toolchain $NEWEST"
sudo apt install -y "clang-$NEWEST" "llvm-$NEWEST" "lld-$NEWEST" "libclang-$NEWEST-dev"
# expose the toolchain's own bin/lib dirs (clang, clang-cl, lld-link,
# llvm-lib, libclang) to the later steps
echo "LLVM_BIN=/usr/lib/llvm-$NEWEST/bin" >> "$GITHUB_ENV"
echo "LIBCLANG_PATH=/usr/lib/llvm-$NEWEST/lib" >> "$GITHUB_ENV"
export PATH="/usr/lib/llvm-$NEWEST/bin:$PATH"
clang --version | head -1
command -v clang clang-cl lld-link llvm-lib
shell: bash shell: bash
- name: Install Rust - name: Install Rust
run: | run: |
export PATH="$HOME/.cargo/bin:$PATH" export PATH="$CARGO_HOME/bin:$PATH"
if ! command -v rustc >/dev/null 2>&1; then if ! command -v rustup >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
else
rustup update
fi fi
rustup toolchain install stable --profile minimal
rustup default stable
echo "$CARGO_HOME/bin" >> "$GITHUB_PATH"
cargo --version
shell: bash shell: bash
- name: Install sccache and maturin - name: Install sccache and maturin
run: | run: |
export PATH="$HOME/.cargo/bin:$PATH" python -m pip install --upgrade pip maturin ziglang cargo-zigbuild
python -m pip install --upgrade pip # in case an older copy was cargo-installed into the CARGO_HOME cache,
pip install maturin ziglang # prevent it from shadowing the pip-installed one
rm -f "${CARGO_HOME:?}/bin/cargo-zigbuild" || true
if ! command -v sccache >/dev/null 2>&1; then if ! command -v sccache >/dev/null 2>&1; then
cargo install sccache || pip install sccache cargo install sccache || pip install sccache
fi fi
@@ -57,7 +92,6 @@ jobs:
- name: Install xwin - name: Install xwin
run: | run: |
export PATH="$HOME/.cargo/bin:$PATH"
if ! command -v cargo-xwin >/dev/null 2>&1; then if ! command -v cargo-xwin >/dev/null 2>&1; then
cargo install cargo-xwin || pip install cargo-xwin cargo install cargo-xwin || pip install cargo-xwin
cargo xwin cache xwin cargo xwin cache xwin
@@ -67,32 +101,87 @@ jobs:
- name: Install osxcross - name: Install osxcross
run: | run: |
export PATH="$HOME/.osxcross/bin:$PATH" export PATH="$HOME/.osxcross/bin:$PATH"
if ! command -v osxcross >/dev/null 2>&1; then if [ ! -d "$HOME/.osxcross/bin" ]; then
wget ${{ secrets.OSXCROSS_LINK }} -O osxcross.tar.gz wget ${{ secrets.OSXCROSS_LINK }} -O osxcross.tar.gz
tar -xzf osxcross.tar.gz -C ~/ tar -xzf osxcross.tar.gz -C ~/
mv ~/osxcross ~/.osxcross mv ~/osxcross ~/.osxcross
fi fi
shell: bash
- name: Shim clang for ring's aarch64-windows build
run: |
# ring 0.17.14 forces a plain `clang` for its C build on
# aarch64-pc-windows-msvc (its "must use Clang on Win AArch64"
# workaround), while the CFLAGS injected by cargo-xwin carry
# clang-cl-style `/imsvc <dir>` pairs that plain clang rejects.
# This shim rewrites them to `-I <dir>` and execs the real clang
# found further down PATH; all other invocations pass through
# untouched, and tools with other names (clang-cl, lld-link) are not
# affected.
mkdir -p /root/ci-shims
cat > /root/ci-shims/clang <<'EOF'
#!/usr/bin/env bash
args=()
prev_imsvc=0
for a in "$@"; do
if [ "$prev_imsvc" = "1" ]; then
args+=("-I$a")
prev_imsvc=0
continue
fi
case "$a" in
/imsvc) prev_imsvc=1 ;;
/imsvc:*) args+=("-I${a#/imsvc:}") ;;
*) args+=("$a") ;;
esac
done
self_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
real=""
IFS=":" read -r -a _dirs <<< "$PATH"
IFS=""
for d in "${_dirs[@]}"; do
[ "$d" = "$self_dir" ] && continue
if [ -x "$d/clang" ]; then real="$d/clang"; break; fi
done
[ -n "$real" ] || real=/usr/bin/clang
exec "$real" "${args[@]}"
EOF
chmod +x /root/ci-shims/clang
shell: bash
- name: Build wheels - name: Build wheels
shell: bash
run: | run: |
export PATH="$HOME/.cargo/bin:$HOME/.osxcross/bin:$PATH" export PATH="$HOME/.osxcross/bin:$PATH"
maturin sdist --out dist # LLVM_BIN and LIBCLANG_PATH were set by the "Install build
rustup default nightly # dependencies" step; put the shim (for ring's forced plain `clang`)
# in front of the toolchain so clang-cl / lld-link (distinct names)
# still resolve to the real toolchain
export PATH="/root/ci-shims:$LLVM_BIN:$PATH"
rm -rf dist
maturin sdist --out dist -m ndbioimage/Cargo.toml
rustup target add x86_64-unknown-linux-gnu --toolchain nightly rustup target add x86_64-apple-darwin
maturin build --release --out dist --target x86_64-unknown-linux-gnu maturin build --release --out dist --target x86_64-apple-darwin --zig --auditwheel repair -m ndbioimage/Cargo.toml
rustup target add aarch64-unknown-linux-gnu --toolchain nightly rustup target add aarch64-apple-darwin
maturin build --release --out dist --target aarch64-unknown-linux-gnu --zig maturin build --release --out dist --target aarch64-apple-darwin --zig --auditwheel repair -m ndbioimage/Cargo.toml
rustup target add x86_64-pc-windows-msvc --toolchain nightly rustup target add x86_64-unknown-linux-gnu
maturin build --release --out dist --target x86_64-pc-windows-msvc # the x86_64-gnu build links the runner's host liblzma (lzma-sys),
rustup target add aarch64-pc-windows-msvc --toolchain nightly # which needs glibc symbols up to 2.34 - higher than the
maturin build --release --out dist --target aarch64-pc-windows-msvc # manylinux_2_17 baseline
maturin build --release --out dist --target x86_64-unknown-linux-gnu --zig --auditwheel repair --compatibility manylinux_2_34 -m ndbioimage/Cargo.toml
rustup target add aarch64-unknown-linux-gnu
maturin build --release --out dist --target aarch64-unknown-linux-gnu --zig --auditwheel repair --compatibility manylinux_2_34 -m ndbioimage/Cargo.toml
rustup target add x86_64-unknown-linux-musl
maturin build --release --out dist --target x86_64-unknown-linux-musl --zig --auditwheel repair -m ndbioimage/Cargo.toml
rustup target add aarch64-unknown-linux-musl
maturin build --release --out dist --target aarch64-unknown-linux-musl --zig --auditwheel repair -m ndbioimage/Cargo.toml
rustup target add x86_64-apple-darwin --toolchain nightly rustup target add x86_64-pc-windows-msvc
maturin build --release --out dist --target x86_64-apple-darwin --zig maturin build --release --out dist --target x86_64-pc-windows-msvc --auditwheel repair -m ndbioimage/Cargo.toml
rustup target add aarch64-apple-darwin --toolchain nightly rustup target add aarch64-pc-windows-msvc
maturin build --release --out dist --target aarch64-apple-darwin --zig maturin build --release --out dist --target aarch64-pc-windows-msvc --auditwheel repair -m ndbioimage/Cargo.toml
- name: Store cache - name: Store cache
uses: actions/cache/save@v4 uses: actions/cache/save@v4
@@ -102,7 +191,7 @@ jobs:
~/.cache/pip-wheel ~/.cache/pip-wheel
~/.cache/sccache ~/.cache/sccache
~/.cache/cargo-xwin ~/.cache/cargo-xwin
~/.cargo ${{ env.CARGO_HOME }}
~/.osxcross ~/.osxcross
key: cache-ubuntu-maturin-cross-compile key: cache-ubuntu-maturin-cross-compile
@@ -110,5 +199,6 @@ jobs:
uses: pypa/gh-action-pypi-publish@release/v1 uses: pypa/gh-action-pypi-publish@release/v1
with: with:
password: ${{ secrets.PYPI_API_TOKEN }} password: ${{ secrets.PYPI_API_TOKEN }}
skip-existing: true
env: env:
GITHUB_WORKFLOW_REF: 1.10.1 GITHUB_WORKFLOW_REF: 1.10.1
+79
View File
@@ -0,0 +1,79 @@
name: pytest
on: [ workflow_dispatch, workflow_call, push, pull_request ]
permissions:
contents: read
jobs:
pypi_publish:
name: Publish (pypi.org)
runs-on: ubuntu-latest
env:
CARGO_HOME: ${{ github.workspace }}/.cargo
RUSTUP_HOME: ${{ github.workspace }}/.rustup
RUSTUP_TOOLCHAIN: stable
steps:
- uses: actions/checkout@v6
with:
submodules: recursive
- uses: actions/setup-python@v5
with:
python-version: 3.x
- name: Restore cache
uses: actions/cache/restore@v4
with:
path: |
~/.cache/pip
~/.cache/pip-wheel
~/.cache/sccache
${{ env.CARGO_HOME }}
key: cache-ubuntu-maturin-cross-compile
- name: Install build dependencies
run: |
sudo apt update
sudo apt install -y llvm cmake build-essential libclang-dev clang
shell: bash
- name: Install Rust
run: |
export PATH="$CARGO_HOME/bin:$PATH"
if ! command -v rustup >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
fi
rustup toolchain install stable --profile minimal
rustup default stable
echo "$CARGO_HOME/bin" >> "$GITHUB_PATH"
cargo --version
shell: bash
- name: Install sccache and maturin
run: |
python -m pip install --upgrade pip maturin ziglang cargo-zigbuild
# in case an older copy was cargo-installed into the CARGO_HOME cache,
# prevent it from shadowing the pip-installed one
rm -f "${CARGO_HOME:?}/bin/cargo-zigbuild" || true
if ! command -v sccache >/dev/null 2>&1; then
cargo install sccache || pip install sccache
fi
shell: bash
- name: PyTest
run: |
cd ndbioimage
python -m pip install .[test]
python -m pytest
- name: Store cache
uses: actions/cache/save@v4
with:
path: |
~/.cache/pip
~/.cache/pip-wheel
~/.cache/sccache
~/.cache/cargo-xwin
${{ env.CARGO_HOME }}
~/.osxcross
key: cache-ubuntu-maturin-cross-compile
+4 -3
View File
@@ -29,6 +29,7 @@ venv/
*.egg-info/ *.egg-info/
.installed.cfg .installed.cfg
*.egg *.egg
.cargo
# Installer logs # Installer logs
pip-log.txt pip-log.txt
@@ -72,11 +73,11 @@ docs/_build/
# Pyenv # Pyenv
.python-version .python-version
/tests/files/*
AGENTS.md AGENTS.md
.agentbridge .agentbridge
.agent-work .agent-work
py/ndbioimage/jassets /ndbioimage/tests/files/*
py/ndbioimage/deps /ndbioimage/py/ndbioimage/jassets
/ndbioimage/py/ndbioimage/deps
/build_wheels.sh /build_wheels.sh
+6
View File
@@ -0,0 +1,6 @@
[submodule "image-tiff"]
path = image-tiff
url = https://git.pomppervova.nl/wim/image-tiff.git
[submodule "image-registration"]
path = image-registration
url = https://git.pomppervova.nl/wim/image-registration.git
+10 -74
View File
@@ -1,90 +1,26 @@
[package] [workspace]
name = "ndbioimage" resolver = "3"
version = "0.2.0" members = ["ndbioimage", "image-tiff", "image-registration"]
[workspace.package]
version = "0.3.2"
edition = "2024" edition = "2024"
rust-version = "1.94.0" rust-version = "1.94.1"
authors = ["Wim Pomp <w.pomp@nki.nl>"] authors = ["Wim Pomp <w.pomp@nki.nl>"]
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
description = "Read bio image formats using the bio-formats java package." description = "Read bio image formats using the bio-formats java package."
homepage = "https://git.pomppervova.nl/wim/ndbioimage/src/branch/rs" repository = "https://git.pomppervova.nl/wim/ndbioimage"
repository = "https://git.pomppervova.nl/wim/ndbioimage/src/branch/rs"
documentation = "https://docs.rs/ndbioimage" documentation = "https://docs.rs/ndbioimage"
readme = "README.md" readme = "README.md"
keywords = ["bioformats", "imread", "ndarray", "metadata"] keywords = ["bioformats", "imread", "ndarray", "metadata"]
categories = ["multimedia::images", "science"] categories = ["multimedia::images", "science"]
exclude = ["/tests"]
[lib]
name = "ndbioimage"
crate-type = ["cdylib", "rlib"]
[dependencies]
bioformats = { version = "0.1", optional = true, default-features = false, features = ["zarr", "tissuefaxs"] }
clap = { version = "4", features = ["derive"] }
console = { version = "0.16", optional = true }
downloader = { version = "0.2", optional = true, default-features = false, features = ["rustls-tls"] }
ffmpeg-sidecar = { version = "2", optional = true }
itertools = "0.15"
image-registration = { path = "../image-registration", optional = true }
indexmap = { version = "2", features = ["serde"] }
indicatif = { version = "0.18", features = ["rayon"], optional = true }
j4rs = { version = "0.25", optional = true }
libczirw-sys = { version = "0.5", optional = true }
ndarray = { version = "0.17", features = ["serde"] }
num = "0.4"
numpy = { version = "0.29", optional = true }
ome-metadata = "0.5"
ordered-float = { version = "5", optional = true }
phf = { version = "0.14", features = ["macros"] }
postcard = { version = "1", features = ["use-std"], optional = true }
pyo3 = { version = "0.29", features = ["abi3-py310", "anyhow", "generate-import-lib"], optional = true }
pyo3-stub-gen = { version = "0.23", optional = true }
rayon = { version = "1", optional = true }
regex = "1"
serde = { version = "1", features = ["rc", "derive"] }
serde_with = "3"
strum = { version = "0.28", features = ["derive"] }
thiserror = "2"
tiff = { version = "0.11", features = ["zstd"], optional = true }
tiffwrite = { version = "2026.6.0", optional = true }
tokio = { version = "1", features = ["rt", "rt-multi-thread"], optional = true }
thread_local = { version = "1", optional = true }
xmltree = { version = "0.12", optional = true }
yaml_serde = { version = "0.10", optional = true }
[dev-dependencies]
rayon = "1"
regex = "1"
[build-dependencies]
j4rs = "0.25"
ffmpeg-sidecar = "2"
retry = "2"
toml = "1"
[features]
default = ["bioformats_java", "gpl-formats", "czi", "tiff", "tiffseq", "movie", "tiffwrite"]
all = ["bioformats_java", "bioformats_rust", "czi", "gpl-formats", "movie", "tiffseq", "tiffwrite", "tiff", "transforms"]
gpl-formats = []
python = ["dep:pyo3", "dep:numpy", "dep:pyo3-stub-gen", "dep:postcard", "ome-metadata/python"]
czi = ["dep:libczirw-sys", "dep:xmltree", "dep:thread_local"]
bioformats_rust = ["dep:bioformats", "dep:thread_local"]
bioformats_java = ["dep:j4rs", "dep:thread_local", "dep:downloader"]
tiffwrite = ["dep:tiffwrite", "dep:indicatif", "dep:console", "dep:rayon"]
tiffseq = ["dep:tiff", "dep:yaml_serde"]
tiff = ["dep:tiff", "dep:thread_local"]
movie = ["dep:ffmpeg-sidecar", "dep:tokio", "dep:ordered-float", "dep:indicatif", "dep:console"]
transforms = ["dep:image-registration", "dep:yaml_serde"]
[package.metadata.docs.rs]
no-default-features = true
features = ["bioformats_java", "bioformats_rust", "czi", "movie", "tiffseq", "tiffwrite", "tiff", "transforms"]
[profile.test] [profile.test]
inherits = "release" inherits = "release"
debug = true
[profile.release] [profile.release]
debug = true #debug = true
[profile.dev.package.backtrace] [profile.dev.package.backtrace]
opt-level = 3 opt-level = 3
+4 -19
View File
@@ -1,11 +1,10 @@
# ndbioimage [![pytest](https://git.pomppervova.nl/wim/ndbioimage/actions/workflows/pytest.yml/badge.svg)](https://git.pomppervova.nl/wim/ndbioimage/actions?workflow=pytest.yml)
[![Pytest](https://github.com/pomppervova/ndbioimage/actions/workflows/pytest.yml/badge.svg)](https://github.com/pomppervova/ndbioimage/actions/workflows/pytest.yml)
# NDBioImage
## Work in progress ## Work in progress
Rust rewrite of python version. Read bio image formats using the bio-formats java package. Rust rewrite of python version. Read bio image formats using some Rust code, the libczi api, and if needed the
[https://www.openmicroscopy.org/bio-formats/](https://www.openmicroscopy.org/bio-formats/) bio-formats java package.
Exposes (bio) images as a numpy ndarray-like object (Python) or a struct that can be sliced like an ndarray Array Exposes (bio) images as a numpy ndarray-like object (Python) or a struct that can be sliced like an ndarray Array
(Rust), but without loading the whole image into memory, reading from the file only when needed. Some metadata is read (Rust), but without loading the whole image into memory, reading from the file only when needed. Some metadata is read
@@ -18,20 +17,6 @@ Currently, it supports imagej tif files, czi files, micromanager tif sequences a
To transition to semver, versions before 0.1.0 were yanked from crates.io. To transition to semver, versions before 0.1.0 were yanked from crates.io.
## Installation
```sh
pip install ndbioimage
```
### Installation with option to write mp4 or mkv:
Work in progress! Make sure ffmpeg is installed.
```sh
pip install ndbioimage[write]
```
## Usage ## Usage
### Python ### Python
Submodule image-registration added at 2b01f65cc9.
Submodule
+1
Submodule image-tiff added at c462915a61.
+84
View File
@@ -0,0 +1,84 @@
[package]
name = "ndbioimage"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
license.workspace = true
description.workspace = true
repository.workspace = true
documentation.workspace = true
keywords.workspace = true
categories.workspace = true
readme = "../README.md"
exclude = ["/tests"]
[lib]
name = "ndbioimage"
crate-type = ["cdylib", "rlib"]
[dependencies]
bioformats = { version = "0.1", optional = true, default-features = false, features = ["zarr", "tissuefaxs"] }
clap = { version = "4", features = ["derive"] }
console = { version = "0.16", optional = true }
downloader = { version = "0.2", optional = true, default-features = false, features = ["rustls-tls"] }
ffmpeg-sidecar = { version = "2", optional = true }
itertools = "0.15"
image-registration = { path = "../image-registration", version = "0.1", optional = true }
indexmap = { version = "2", features = ["serde"] }
indicatif = { version = "0.18", features = ["rayon"], optional = true }
j4rs = { version = "0.25", optional = true }
libczirw-sys = { version = "0.5.2", optional = true }
ndarray = { version = "0.17", features = ["serde"] }
num = "0.4"
numpy = { version = "0.29", optional = true }
ome-metadata = "0.6"
ordered-float = { version = "5", optional = true }
phf = { version = "0.14", features = ["macros"] }
postcard = { version = "1", features = ["use-std"], optional = true }
pyo3 = { version = "0.29", features = ["abi3-py310", "anyhow", "generate-import-lib", "multiple-pymethods"], optional = true }
pyo3-stub-gen = { version = "0.23", optional = true }
rayon = { version = "1", optional = true }
regex = "1"
serde = { version = "1", features = ["rc", "derive"] }
serde_with = "3"
strum = { version = "0.28", features = ["derive"] }
thiserror = "2"
tiff = { package = "ndbioimage-tiff", path = "../image-tiff", version = "0.11", features = ["zstd"], optional = true }
tiffwrite = { version = "2026.6.0", optional = true }
tokio = { version = "1", features = ["rt", "rt-multi-thread"], optional = true }
thread_local = { version = "1", optional = true }
xmltree = { version = "0.12", optional = true }
yaml_serde = { version = "0.10", optional = true }
# "bioformats 0.1.8" is written for "zip 2.4.x", where zip::read::ZipFile has a single lifetime parameter.
# "zip >= 2.5" added a second "R: Read" generic (2.6.1 breaks bioformats), so the build fails.
zip = { version = "=2.4.2", optional = true, default-features = false }
[dev-dependencies]
rayon = "1"
regex = "1"
postcard = { version = "1", features = ["use-std"] }
[build-dependencies]
j4rs = "0.25"
ffmpeg-sidecar = "2"
retry = "2"
toml = "1"
[features]
default = ["bioformats_java", "gpl-formats", "czi", "tiff", "tiffseq", "movie", "tiffwrite"]
all = ["bioformats_java", "bioformats_rust", "czi", "gpl-formats", "movie", "tiffseq", "tiffwrite", "tiff", "transforms"]
gpl-formats = []
python = ["dep:pyo3", "dep:numpy", "dep:pyo3-stub-gen", "dep:postcard", "ome-metadata/python"]
czi = ["dep:libczirw-sys", "dep:xmltree", "dep:thread_local"]
bioformats_rust = ["dep:bioformats", "dep:thread_local", "dep:zip"]
bioformats_java = ["dep:j4rs", "dep:thread_local", "dep:downloader"]
tiffwrite = ["dep:tiffwrite", "dep:indicatif", "dep:console", "dep:rayon"]
tiffseq = ["dep:tiff", "dep:yaml_serde"]
tiff = ["dep:tiff", "dep:thread_local"]
movie = ["dep:ffmpeg-sidecar", "dep:tokio", "dep:ordered-float", "dep:indicatif", "dep:console"]
transforms = ["dep:image-registration", "dep:yaml_serde"]
[package.metadata.docs.rs]
no-default-features = true
features = ["bioformats_java", "bioformats_rust", "czi", "movie", "tiffseq", "tiffwrite", "tiff", "transforms"]
+22 -2
View File
@@ -9,21 +9,41 @@ mod bioformats {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub(crate) enum BuildError { pub(crate) enum BuildError {
J4rsVersionNotFound, J4rsVersionNotFound,
LockFileNotFound,
} }
impl Display for BuildError { impl Display for BuildError {
fn fmt(&self, fmt: &mut Formatter) -> Result<(), std::fmt::Error> { fn fmt(&self, fmt: &mut Formatter) -> Result<(), std::fmt::Error> {
match self { match self {
Self::J4rsVersionNotFound => write!(fmt, "J4rsVersion not found in Cargo.lock"), Self::J4rsVersionNotFound => write!(fmt, "J4rsVersion not found in Cargo.lock"),
Self::LockFileNotFound => write!(
fmt,
"Cargo.lock not found in any parent of CARGO_MANIFEST_DIR ({})",
std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default()
),
} }
} }
} }
impl Error for BuildError {} impl Error for BuildError {}
/// the `Cargo.lock` that cargo resolved for this build: next to the manifest for a standalone
/// package, or at the workspace root when this crate is a workspace member
fn lock_file() -> Result<std::path::PathBuf, BuildError> {
let manifest_dir = std::path::PathBuf::from(
std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"),
);
manifest_dir
.ancestors()
.map(|dir| dir.join("Cargo.lock"))
.find(|lock| lock.is_file())
.ok_or(BuildError::LockFileNotFound)
}
fn get_j4rs_version() -> Result<String, Box<dyn Error>> { fn get_j4rs_version() -> Result<String, Box<dyn Error>> {
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"); let lock_path = lock_file()?;
let lock_path = std::path::Path::new(&manifest_dir).join("Cargo.lock"); // the resolved j4rs version is baked into the generated constants, so rerun when it changes
println!("cargo::rerun-if-changed={}", lock_path.display());
let lock_toml = std::fs::read_to_string(&lock_path)?; let lock_toml = std::fs::read_to_string(&lock_path)?;
let value: toml::Value = toml::from_str(&lock_toml)?; let value: toml::Value = toml::from_str(&lock_toml)?;
if let Some(packages) = value.get("package").and_then(|v| v.as_array()) { if let Some(packages) = value.get("package").and_then(|v| v.as_array()) {
@@ -5,7 +5,7 @@ import sys
import warnings import warnings
from importlib.metadata import version from importlib.metadata import version
from pathlib import Path from pathlib import Path
from typing import TypeVar from typing import TYPE_CHECKING
import numpy as np import numpy as np
from numpy.typing import ArrayLike from numpy.typing import ArrayLike
@@ -13,13 +13,17 @@ from numpy.typing import ArrayLike
os.environ["RUST_BACKTRACE"] = "full" os.environ["RUST_BACKTRACE"] = "full"
os.environ["COLORBT_SHOW_HIDDEN"] = "1" os.environ["COLORBT_SHOW_HIDDEN"] = "1"
from .ndbioimage_rs import Imread, generate_stub from .ndbioimage_rs import Imread, Shape, generate_stub
try: if TYPE_CHECKING:
from .transforms import Transform as Transform
else:
try:
from .ndbioimage_rs import transforms from .ndbioimage_rs import transforms
sys.modules["ndbioimage.transforms"] = transforms sys.modules["ndbioimage.transforms"] = transforms
except (ImportError, ModuleNotFoundError): Transform = transforms.Transform
except (ImportError, ModuleNotFoundError):
pass pass
try: try:
@@ -50,7 +54,16 @@ except Exception: # noqa
__git_commit_hash__ = "unknown" __git_commit_hash__ = "unknown"
warnings.filterwarnings("ignore", "Reference to unknown ID") warnings.filterwarnings("ignore", "Reference to unknown ID")
Number = int | float | np.integer | np.floating
__all__ = [
"Imread",
"JVMException",
"ReaderNotFoundError",
"Shape",
"Transform",
"get_positions",
"is_noise",
]
# backwards compatibility # backwards compatibility
@@ -90,9 +103,6 @@ def ndbioimage_generate_stub():
(nd / "ndbioimage.pyi").rename(nd / "__init__.pyi") (nd / "ndbioimage.pyi").rename(nd / "__init__.pyi")
R = TypeVar("R")
def get_positions(path: str | Path) -> set[int]: def get_positions(path: str | Path) -> set[int]:
return Imread.get_available_series(path) return Imread.get_available_series(path)
@@ -2,6 +2,8 @@
# ruff: noqa: E501, F401, F403, F405 # ruff: noqa: E501, F401, F403, F405
import builtins import builtins
import collections
import collections.abc
import os import os
import pathlib import pathlib
import typing import typing
@@ -52,6 +54,8 @@ class Flags:
def __new__(cls) -> Flags: ... def __new__(cls) -> Flags: ...
def __getnewargs__(self) -> None: ... def __getnewargs__(self) -> None: ...
def __getitem__(self, item: builtins.str) -> builtins.bool: ... def __getitem__(self, item: builtins.str) -> builtins.bool: ...
def __str__(self) -> builtins.str: ...
def __repr__(self) -> builtins.str: ...
class Imread: class Imread:
r""" r"""
@@ -96,16 +100,6 @@ class Imread:
the name of the reader used to open the file the name of the reader used to open the file
""" """
@property @property
def transforms(self) -> transforms.Transforms:
r"""
get the transformation
"""
@transforms.setter
def transforms(self, value: transforms.Transforms) -> None:
r"""
set the transformation
"""
@property
def path(self) -> pathlib.Path: def path(self) -> pathlib.Path:
r""" r"""
the file path the file path
@@ -220,6 +214,38 @@ class Imread:
r""" r"""
the name of the tube lens the name of the tube lens
""" """
@property
def channel_names(self) -> builtins.dict[builtins.int, builtins.str]:
r"""
names of the channels in order
"""
@property
def summary(self) -> builtins.str:
r"""
gives a helpful summary of the recorded experiment
"""
@property
def interpolation_order(self) -> typing.Optional[builtins.int]:
r"""
the bspline order used for interpolation when using transforms
None -> 0 (nearest neighbor) for int dtypes, 3 (cubic) for float dtypes
"""
@interpolation_order.setter
def interpolation_order(self, value: typing.Optional[builtins.int]) -> None:
r"""
the bspline order used for interpolation when using transforms
None -> 0 (nearest neighbor) for int dtypes, 3 (cubic) for float dtypes
"""
@property
def transforms(self) -> transforms.Transforms:
r"""
get the transformation
"""
@transforms.setter
def transforms(self, value: transforms.Transforms) -> None:
r"""
set the transformation
"""
def __new__( def __new__(
cls, cls,
path: str | pathlib.Path | Imread | bytes, path: str | pathlib.Path | Imread | bytes,
@@ -228,7 +254,33 @@ class Imread:
reader: typing.Optional[builtins.str] = None, reader: typing.Optional[builtins.str] = None,
) -> Imread: ) -> Imread:
r""" r"""
new view on a file at path, open series #, open as dtype: (u)int(8/16/32) or float(32/64) new view on an image file
path: path to image file to open, can include /Pos\d
dtype: when not None; will cast image to this dtype,
choices: (u)int(8/16/32) or float(32/64)
axes: the resulting object will represent the image with the axes in this order
reader: use a specific reader to open the file,
view available choices with Imread.available_readers
"""
def debug(self) -> None:
r"""
some debug info
"""
@staticmethod
def warm_jvm() -> None:
r"""
creates the jvm (if it does not exist yet) so that hotspot installs its signal handlers before
anything else does: see `crate::readers::bioformats_java::warm_jvm`
in a python process, call this before `ray.init()`, which installs CPython's faulthandler
"""
@staticmethod
def jvm_is_running() -> builtins.bool:
r"""
returns whether the jvm was already created in this process
this is false in a process that inherited a jvm across a fork, since such a jvm cannot be
used; reading a bio-formats file there raises instead
""" """
def set_flags( def set_flags(
self, self,
@@ -251,47 +303,19 @@ class Imread:
r""" r"""
only remains for backwards compatibility only remains for backwards compatibility
""" """
def reshape(self, order: builtins.str, copy: builtins.bool) -> typing.Any: def reshape(
r"""
reshape the view with a new axis order
"""
def with_transform(
self, self,
channels: builtins.bool = True, *shape: typing.Any,
drift: builtins.bool = False, order: builtins.str = "C",
file: typing.Optional[typing.Any] = None, copy: typing.Optional[builtins.bool] = None,
bead_files: typing.Optional[typing.Any] = None, ) -> Imread | numpy.NDArray:
main_channel: typing.Optional[builtins.int] = None,
default_transform: typing.Optional[typing.Sequence[builtins.float]] = None,
) -> Imread:
r""" r"""
return a new view with transformations applied (channel alignment, drift correction) reshape the view with a new axis order, arguments order and copy cannot be used and will
backward compatibility raise an exception if order != "C" or copy is False
""" """
def with_transforms( def squeeze(
self, self, axes: str | collections.abc.Sequence[int | str] = None
channels: builtins.bool = True, ) -> Imread | float | int: ...
drift: builtins.bool = False,
file: typing.Optional[typing.Any] = None,
bead_files: typing.Optional[typing.Any] = None,
main_channel: typing.Optional[builtins.int] = None,
default_transform: typing.Optional[typing.Sequence[builtins.float]] = None,
) -> Imread:
r"""
return a new view with transformations applied (channel alignment, drift correction)
"""
def load_transforms_from_yaml(
self, path: builtins.str | os.PathLike | pathlib.Path
) -> None: ...
def calculate_channel_transforms_2d(
self, main_channel: builtins.int
) -> builtins.dict[builtins.str, transforms.Transform]: ...
def calculate_channel_transforms_3d(
self, main_channel: builtins.int
) -> builtins.dict[builtins.str, transforms.Transform]: ...
def calculate_drift_transforms_2d(self) -> builtins.list[transforms.Transform]: ...
def calculate_drift_transforms_3d(self) -> builtins.list[transforms.Transform]: ...
def squeeze(self) -> numpy.ndarray | int | float: ...
def close(self) -> None: def close(self) -> None:
r""" r"""
close the file: does nothing as this is handled automatically close the file: does nothing as this is handled automatically
@@ -304,7 +328,19 @@ class Imread:
r""" r"""
change the data type of the view: (u)int(8/16/32) or float(32/64) change the data type of the view: (u)int(8/16/32) or float(32/64)
""" """
def __getitem__(self, n: typing.Any) -> typing.Any: def __call__(
self,
c: typing.Optional[int | slice | numpy.ArrayLike] = None,
z: typing.Optional[int | slice | numpy.ArrayLike] = None,
t: typing.Optional[int | slice | numpy.ArrayLike] = None,
y: typing.Optional[int | slice | numpy.ArrayLike] = None,
x: typing.Optional[int | slice | numpy.ArrayLike] = None,
) -> typing.Optional[int | Imread]:
r"""
same as im[] but allowing keyword axes, but slices need to made with slice()
also, no ellipsis or new axes
"""
def __getitem__(self, n: typing.Any) -> typing.Optional[int | Imread]:
r""" r"""
slice the view and return a new view or a single number slice the view and return a new view or a single number
""" """
@@ -312,7 +348,7 @@ class Imread:
self, self,
dtype: typing.Optional[numpy.typing.DTypeLike] = None, dtype: typing.Optional[numpy.typing.DTypeLike] = None,
copy: typing.Optional[builtins.bool] = None, copy: typing.Optional[builtins.bool] = None,
) -> typing.Any: ) -> numpy.typing.NDArray:
r""" r"""
convert to a numpy array, optionally with a different dtype convert to a numpy array, optionally with a different dtype
""" """
@@ -320,139 +356,199 @@ class Imread:
r""" r"""
check if an item is contained in the view (not implemented) check if an item is contained in the view (not implemented)
""" """
def __lt__(self, other: typing.Any) -> typing.Any: def __lt__(
self, other: float | numpy.ArrayLike | Imread
) -> numpy.typing.NDArray | bool:
r""" r"""
element-wise less than comparison element-wise less than comparison
""" """
def __le__(self, other: typing.Any) -> typing.Any: def __le__(
self, other: float | numpy.ArrayLike | Imread
) -> numpy.typing.NDArray | bool:
r""" r"""
element-wise less than or equal comparison element-wise less than or equal comparison
""" """
def __eq__(self, other: typing.Any) -> typing.Any: def __eq__(
self, other: float | numpy.ArrayLike | Imread
) -> numpy.typing.NDArray | bool:
r""" r"""
element-wise equality comparison element-wise equality comparison
""" """
def __ne__(self, other: typing.Any) -> typing.Any: def __ne__(
self, other: float | numpy.ArrayLike | Imread
) -> numpy.typing.NDArray | bool:
r""" r"""
element-wise not equal comparison element-wise not equal comparison
""" """
def __gt__(self, other: typing.Any) -> typing.Any: def __gt__(
self, other: float | numpy.ArrayLike | Imread
) -> numpy.typing.NDArray | bool:
r""" r"""
element-wise greater than comparison element-wise greater than comparison
""" """
def __ge__(self, other: typing.Any) -> typing.Any: def __ge__(
self, other: float | numpy.ArrayLike | Imread
) -> numpy.typing.NDArray | bool:
r""" r"""
element-wise greater than or equal comparison element-wise greater than or equal comparison
""" """
def __add__(self, other: typing.Any) -> typing.Any: def __add__(
self, other: float | numpy.ArrayLike | Imread
) -> numpy.typing.NDArray | float:
r""" r"""
element-wise addition element-wise addition
""" """
def __radd__(self, other: typing.Any) -> typing.Any: def __radd__(
self, other: float | numpy.ArrayLike | Imread
) -> numpy.typing.NDArray | float:
r""" r"""
element-wise addition (reflected) element-wise addition (reflected)
""" """
def __sub__(self, other: typing.Any) -> typing.Any: def __sub__(
self, other: float | numpy.ArrayLike | Imread
) -> numpy.typing.NDArray | float:
r""" r"""
element-wise subtraction element-wise subtraction
""" """
def __rsub__(self, other: typing.Any) -> typing.Any: def __rsub__(
self, other: float | numpy.ArrayLike | Imread
) -> numpy.typing.NDArray | float:
r""" r"""
element-wise subtraction (reflected) element-wise subtraction (reflected)
""" """
def __mul__(self, other: typing.Any) -> typing.Any: def __mul__(
self, other: float | numpy.ArrayLike | Imread
) -> numpy.typing.NDArray | float:
r""" r"""
element-wise multiplication element-wise multiplication
""" """
def __rmul__(self, other: typing.Any) -> typing.Any: def __rmul__(
self, other: float | numpy.ArrayLike | Imread
) -> numpy.typing.NDArray | float:
r""" r"""
element-wise multiplication (reflected) element-wise multiplication (reflected)
""" """
def __truediv__(self, other: typing.Any) -> typing.Any: def __truediv__(
self, other: float | numpy.ArrayLike | Imread
) -> numpy.typing.NDArray | float:
r""" r"""
element-wise true division element-wise true division
""" """
def __rtruediv__(self, other: typing.Any) -> typing.Any: def __rtruediv__(
self, other: float | numpy.ArrayLike | Imread
) -> numpy.typing.NDArray | float:
r""" r"""
element-wise true division (reflected) element-wise true division (reflected)
""" """
def __floordiv__(self, other: typing.Any) -> typing.Any: def __floordiv__(
self, other: float | numpy.ArrayLike | Imread
) -> numpy.typing.NDArray | float:
r""" r"""
element-wise floor division element-wise floor division
""" """
def __rfloordiv__(self, other: typing.Any) -> typing.Any: def __rfloordiv__(
self, other: float | numpy.ArrayLike | Imread
) -> numpy.typing.NDArray | float:
r""" r"""
element-wise floor division (reflected) element-wise floor division (reflected)
""" """
def __mod__(self, other: typing.Any) -> typing.Any: def __mod__(
self, other: float | numpy.ArrayLike | Imread
) -> numpy.typing.NDArray | float:
r""" r"""
element-wise modulo element-wise modulo
""" """
def __rmod__(self, other: typing.Any) -> typing.Any: def __rmod__(
self, other: float | numpy.ArrayLike | Imread
) -> numpy.typing.NDArray | float:
r""" r"""
element-wise modulo (reflected) element-wise modulo (reflected)
""" """
def __matmul__(self, other: typing.Any) -> typing.Any: def __matmul__(
self, other: float | numpy.ArrayLike | Imread
) -> numpy.typing.NDArray | float:
r""" r"""
element-wise matrix multiplication element-wise matrix multiplication
""" """
def __rmatmul__(self, other: typing.Any) -> typing.Any: def __rmatmul__(
self, other: float | numpy.ArrayLike | Imread
) -> numpy.typing.NDArray | float:
r""" r"""
element-wise matrix multiplication (reflected) element-wise matrix multiplication (reflected)
""" """
def __and__(self, other: typing.Any) -> typing.Any: def __and__(
self, other: float | numpy.ArrayLike | Imread
) -> numpy.typing.NDArray | bool:
r""" r"""
element-wise bitwise AND element-wise bitwise AND
""" """
def __rand__(self, other: typing.Any) -> typing.Any: def __rand__(
self, other: float | numpy.ArrayLike | Imread
) -> numpy.typing.NDArray | bool:
r""" r"""
element-wise bitwise AND (reflected) element-wise bitwise AND (reflected)
""" """
def __or__(self, other: typing.Any) -> typing.Any: def __or__(
self, other: float | numpy.ArrayLike | Imread
) -> numpy.typing.NDArray | bool:
r""" r"""
element-wise bitwise OR element-wise bitwise OR
""" """
def __ror__(self, other: typing.Any) -> typing.Any: def __ror__(
self, other: float | numpy.ArrayLike | Imread
) -> numpy.typing.NDArray | bool:
r""" r"""
element-wise bitwise OR (reflected) element-wise bitwise OR (reflected)
""" """
def __xor__(self, other: typing.Any) -> typing.Any: def __xor__(
self, other: float | numpy.ArrayLike | Imread
) -> numpy.typing.NDArray | bool:
r""" r"""
element-wise bitwise XOR element-wise bitwise XOR
""" """
def __rxor__(self, other: typing.Any) -> typing.Any: def __rxor__(
self, other: float | numpy.ArrayLike | Imread
) -> numpy.typing.NDArray | bool:
r""" r"""
element-wise bitwise XOR (reflected) element-wise bitwise XOR (reflected)
""" """
def __lshift__(self, other: typing.Any) -> typing.Any: def __lshift__(
self, other: float | numpy.ArrayLike | Imread
) -> numpy.typing.NDArray | float:
r""" r"""
element-wise left shift element-wise left shift
""" """
def __rlshift__(self, other: typing.Any) -> typing.Any: def __rlshift__(
self, other: float | numpy.ArrayLike | Imread
) -> numpy.typing.NDArray | float:
r""" r"""
element-wise left shift (reflected) element-wise left shift (reflected)
""" """
def __rshift__(self, other: typing.Any) -> typing.Any: def __rshift__(
self, other: float | numpy.ArrayLike | Imread
) -> numpy.typing.NDArray | float:
r""" r"""
element-wise right shift element-wise right shift
""" """
def __rrshift__(self, other: typing.Any) -> typing.Any: def __rrshift__(
self, other: float | numpy.ArrayLike | Imread
) -> numpy.typing.NDArray | float:
r""" r"""
element-wise right shift (reflected) element-wise right shift (reflected)
""" """
def __neg__(self) -> typing.Any: def __neg__(self) -> numpy.typing.NDArray | float:
r""" r"""
element-wise negation element-wise negation
""" """
def __pos__(self) -> typing.Any: def __pos__(self) -> numpy.typing.NDArray | float:
r""" r"""
element-wise positive element-wise positive
""" """
def __abs__(self) -> typing.Any: def __abs__(self) -> numpy.typing.NDArray | float:
r""" r"""
element-wise absolute value element-wise absolute value
""" """
def __invert__(self) -> typing.Any: def __invert__(self) -> numpy.typing.NDArray | float:
r""" r"""
element-wise bitwise inversion element-wise bitwise inversion
""" """
@@ -505,13 +601,19 @@ class Imread:
r""" r"""
the file path as a string the file path as a string
""" """
def frame(
self, c: builtins.int, z: builtins.int, t: builtins.int
) -> numpy.typing.NDArray | float:
r"""
retrieve a single frame at czt, sliced accordingly, alias of get_frame
"""
def get_frame( def get_frame(
self, c: builtins.int, z: builtins.int, t: builtins.int self, c: builtins.int, z: builtins.int, t: builtins.int
) -> typing.Any: ) -> numpy.typing.NDArray | float:
r""" r"""
retrieve a single frame at czt, sliced accordingly retrieve a single frame at czt, sliced accordingly
""" """
def flatten(self) -> typing.Any: def flatten(self) -> numpy.typing.NDArray | float:
r""" r"""
flatten the view into a 1D numpy array flatten the view into a 1D numpy array
""" """
@@ -527,17 +629,21 @@ class Imread:
r""" r"""
find the position of an axis find the position of an axis
""" """
def swapaxes(self, ax0: int | str, ax1: int | str) -> Imread:
r"""
swap two axes
"""
def swap_axes(self, ax0: int | str, ax1: int | str) -> Imread: def swap_axes(self, ax0: int | str, ax1: int | str) -> Imread:
r""" r"""
swap two axes swap two axes
""" """
def transpose( def transpose(
self, axes: typing.Optional[typing.Sequence[int | str]] = None self, axes: typing.Optional[collections.abc.Sequence[int | str] | str] = None
) -> Imread: ) -> Imread:
r""" r"""
permute the order of the axes permute the order of the axes
""" """
def as_array(self) -> typing.Any: def as_array(self) -> numpy.typing.NDArray | float:
r""" r"""
collect data into a numpy array collect data into a numpy array
""" """
@@ -567,9 +673,9 @@ class Imread:
r""" r"""
the detector gain for a given channel the detector gain for a given channel
""" """
def summary(self) -> builtins.str: def channel_name(self, channel: builtins.int) -> builtins.str:
r""" r"""
gives a helpful summary of the recorded experiment the name of a particular channel
""" """
@staticmethod @staticmethod
def get_available_series( def get_available_series(
@@ -673,6 +779,73 @@ class Imread:
r""" r"""
Return the sum along a given axis. Arguments beyond axis are not implemented Return the sum along a given axis. Arguments beyond axis are not implemented
""" """
def with_interpolation_order(self, order: typing.Optional[builtins.int]) -> Imread:
r"""
the bspline order used for interpolation when using transforms
None -> 0 (nearest neighbor) for int dtypes, 3 (cubic) for float dtypes
"""
def get_default_interpolation_value(
self, channel: builtins.int
) -> transforms.DefaultValue:
r"""
the value of the interpolated image outside the data
"""
def set_default_interpolation_value(
self,
channel: builtins.int,
value: float | transforms.DefaultValue | collections.abc.Sequence[float],
) -> None:
r"""
the value of the interpolated image outside the data
"""
def with_default_interpolation_value(
self,
channel: builtins.int,
value: float | transforms.DefaultValue | collections.abc.Sequence[float],
) -> Imread:
r"""
the value of the interpolated image outside the data
"""
def with_transform(
self,
channels: builtins.bool = True,
drift: builtins.bool = False,
file: typing.Optional[builtins.str | os.PathLike | pathlib.Path] = None,
bead_files: typing.Optional[
typing.Sequence[builtins.str | os.PathLike | pathlib.Path]
] = None,
main_channel: typing.Optional[builtins.int] = None,
default_transform: typing.Optional[typing.Sequence[builtins.float]] = None,
) -> Imread:
r"""
return a new view with transformations applied (channel alignment, drift correction)
backward compatibility
"""
def with_transforms(
self,
channels: builtins.bool = True,
drift: builtins.bool = False,
file: typing.Optional[builtins.str | os.PathLike | pathlib.Path] = None,
bead_files: typing.Optional[
typing.Sequence[builtins.str | os.PathLike | pathlib.Path]
] = None,
main_channel: typing.Optional[builtins.int] = None,
default_transform: typing.Optional[typing.Sequence[builtins.float]] = None,
) -> Imread:
r"""
returns a view where channels and/or frames are registered with an affine transformation
"""
def load_transforms_from_yaml(
self, path: builtins.str | os.PathLike | pathlib.Path
) -> None: ...
def calculate_channel_transforms_2d(
self, main_channel: builtins.int
) -> builtins.dict[builtins.str, transforms.Transform]: ...
def calculate_channel_transforms_3d(
self, main_channel: builtins.int
) -> builtins.dict[builtins.str, transforms.Transform]: ...
def calculate_drift_transforms_2d(self) -> builtins.list[transforms.Transform]: ...
def calculate_drift_transforms_3d(self) -> builtins.list[transforms.Transform]: ...
class Shape: class Shape:
r""" r"""
@@ -742,7 +915,7 @@ class Shape:
arguments for pickling arguments for pickling
""" """
def __getitem__( def __getitem__(
self, idx: str | int | None | Ellipsis | slice | list[int] | tuple[int] self, idx: str | int | None | Ellipsis | slice | typing.Sequence[int]
) -> typing.Optional[int | tuple[int]]: ) -> typing.Optional[int | tuple[int]]:
r""" r"""
get dimension size by index or axis name get dimension size by index or axis name
@@ -773,7 +946,7 @@ def batch_to_tiff(
def generate_stub(dest_path: builtins.str) -> None: def generate_stub(dest_path: builtins.str) -> None:
r""" r"""
generates ndbioimage/__init__.pyi generates .pyi files
""" """
def main() -> None: def main() -> None:
@@ -2,6 +2,7 @@
# ruff: noqa: E501, F401, F403, F405 # ruff: noqa: E501, F401, F403, F405
import builtins import builtins
import collections.abc
import os import os
import pathlib import pathlib
import typing import typing
@@ -10,6 +11,7 @@ import numpy
import numpy.typing import numpy.typing
__all__ = [ __all__ = [
"DefaultValue",
"Optimizer", "Optimizer",
"RegistrationStep", "RegistrationStep",
"SamplingArg", "SamplingArg",
@@ -18,6 +20,21 @@ __all__ = [
"Transforms", "Transforms",
] ]
class DefaultValue:
@property
def values(self) -> builtins.list[builtins.float]: ...
@property
def weights(self) -> builtins.list[builtins.float]: ...
def __eq__(self, other: builtins.object, /) -> builtins.bool: ...
def __new__(
cls,
values: typing.Sequence[builtins.float],
weights: typing.Optional[typing.Sequence[builtins.float]] = None,
) -> DefaultValue: ...
def __getnewargs__(
self,
) -> tuple[builtins.list[builtins.float], builtins.list[builtins.float]]: ...
class Optimizer: class Optimizer:
def __eq__(self, other: builtins.object, /) -> builtins.bool: ... def __eq__(self, other: builtins.object, /) -> builtins.bool: ...
def __new__(cls, optimizer: builtins.str) -> Optimizer: ... def __new__(cls, optimizer: builtins.str) -> Optimizer: ...
@@ -116,14 +133,6 @@ class Transform:
@dparameters.setter @dparameters.setter
def dparameters(self, value: typing.Sequence[builtins.float]) -> None: ... def dparameters(self, value: typing.Sequence[builtins.float]) -> None: ...
@property @property
def center(self) -> builtins.list[builtins.float]: ...
@center.setter
def center(self, value: typing.Sequence[builtins.float]) -> None: ...
@property
def shape(self) -> builtins.list[builtins.int]: ...
@shape.setter
def shape(self, value: typing.Sequence[builtins.int]) -> None: ...
@property
def ndim(self) -> builtins.int: ... def ndim(self) -> builtins.int: ...
@property @property
def matrix(self) -> numpy.typing.NDArray[numpy.float64]: ... def matrix(self) -> numpy.typing.NDArray[numpy.float64]: ...
@@ -139,18 +148,12 @@ class Transform:
def __new__( def __new__(
cls, cls,
parameters: typing.Optional[typing.Sequence[builtins.float]] = None, parameters: typing.Optional[typing.Sequence[builtins.float]] = None,
shape: typing.Optional[typing.Sequence[builtins.int]] = None, dparameters: typing.Optional[typing.Sequence[builtins.float]] = None,
center: typing.Optional[typing.Sequence[builtins.float]] = None, center: typing.Optional[typing.Sequence[builtins.float]] = None,
) -> Transform: ... ) -> Transform: ...
def __getnewargs__( def __getnewargs__(
self, self,
) -> tuple[ ) -> tuple[builtins.list[builtins.float], builtins.list[builtins.float]]: ...
builtins.list[builtins.float],
builtins.list[builtins.int],
typing.Optional[builtins.list[builtins.float]],
]: ...
def __getstate__(self) -> builtins.list[builtins.float]: ...
def __setstate__(self, state: typing.Sequence[builtins.float]) -> None: ...
def __add__(self, other: Transform) -> Transform: ... def __add__(self, other: Transform) -> Transform: ...
def __radd__(self, other: Transform) -> Transform: ... def __radd__(self, other: Transform) -> Transform: ...
def __sub__(self, other: Transform) -> Transform: ... def __sub__(self, other: Transform) -> Transform: ...
@@ -158,11 +161,6 @@ class Transform:
def __mul__(self, other: Transform | float) -> Transform: ... def __mul__(self, other: Transform | float) -> Transform: ...
def __rmul__(self, other: Transform | float) -> Transform: ... def __rmul__(self, other: Transform | float) -> Transform: ...
def __truediv__(self, other: builtins.float) -> Transform: ... def __truediv__(self, other: builtins.float) -> Transform: ...
def adapt(
self,
center: typing.Sequence[builtins.float],
shape: typing.Sequence[builtins.int],
) -> None: ...
@staticmethod @staticmethod
def from_scaling(scaling: typing.Sequence[builtins.float]) -> Transform: ... def from_scaling(scaling: typing.Sequence[builtins.float]) -> Transform: ...
@staticmethod @staticmethod
@@ -179,17 +177,32 @@ class Transform:
self, theta: builtins.float, center: typing.Sequence[builtins.float] self, theta: builtins.float, center: typing.Sequence[builtins.float]
) -> Transform: ... ) -> Transform: ...
def interpolate( def interpolate(
self, order: builtins.int, image: numpy.typing.ArrayLike self,
order: builtins.int,
image: numpy.typing.ArrayLike,
default_value: typing.Optional[DefaultValue] = None,
) -> numpy.typing.NDArray[numpy.float64]: ... ) -> numpy.typing.NDArray[numpy.float64]: ...
def interpolate_par( def interpolate_par(
self, order: builtins.int, image: numpy.typing.ArrayLike self,
order: builtins.int,
image: numpy.typing.ArrayLike,
default_value: typing.Optional[DefaultValue] = None,
) -> numpy.typing.NDArray[numpy.float64]: ... ) -> numpy.typing.NDArray[numpy.float64]: ...
def frame(
self,
image: numpy.typing.ArrayLike,
default: typing.Optional[typing.Any] = None,
default_value: typing.Optional[DefaultValue] = None,
) -> numpy.ndarray:
r"""
backwards compatibility, default parameter is not implemented
"""
def is_unity(self) -> builtins.bool: ... def is_unity(self) -> builtins.bool: ...
def transform_point( def transform_point(
self, point: numpy.typing.ArrayLike self, point: numpy.typing.ArrayLike, center: numpy.typing.ArrayLike
) -> numpy.typing.NDArray[numpy.float64]: ... ) -> numpy.typing.NDArray[numpy.float64]: ...
def transform_points( def transform_points(
self, points: numpy.typing.ArrayLike self, points: numpy.typing.ArrayLike, center: numpy.typing.ArrayLike
) -> numpy.typing.NDArray[numpy.float64]: ... ) -> numpy.typing.NDArray[numpy.float64]: ...
@staticmethod @staticmethod
def register( def register(
@@ -206,9 +219,25 @@ class Transform:
def register_translation( def register_translation(
fixed: numpy.typing.ArrayLike, moving: numpy.typing.ArrayLike fixed: numpy.typing.ArrayLike, moving: numpy.typing.ArrayLike
) -> Transform: ... ) -> Transform: ...
@staticmethod
def register_translation_fft(
fixed: numpy.typing.ArrayLike, moving: numpy.typing.ArrayLike
) -> Transform: ...
class Transforms: class Transforms:
@property @property
def interpolation_order(self) -> typing.Optional[builtins.int]:
r"""
the bspline order used for interpolation when using transforms,
None -> 0 (nearest neighbor) for int dtypes, 3 (cubic) for float dtypes
"""
@interpolation_order.setter
def interpolation_order(self, value: typing.Optional[builtins.int]) -> None:
r"""
the bspline order used for interpolation when using transforms
None -> 0 (nearest neighbor) for int dtypes, 3 (cubic) for float dtypes
"""
@property
def channel_transforms(self) -> builtins.dict[builtins.str, Transform]: ... def channel_transforms(self) -> builtins.dict[builtins.str, Transform]: ...
@channel_transforms.setter @channel_transforms.setter
def channel_transforms( def channel_transforms(
@@ -223,10 +252,48 @@ class Transforms:
cls, cls,
channel: typing.Mapping[builtins.str, Transform] = {}, channel: typing.Mapping[builtins.str, Transform] = {},
drift: typing.Sequence[Transform] = [], drift: typing.Sequence[Transform] = [],
interpolation_order: typing.Optional[builtins.int] = None,
default_value: typing.Optional[
typing.Mapping[builtins.str, DefaultValue]
] = None,
) -> Transforms: ... ) -> Transforms: ...
def __getnewargs__( def __getnewargs__(
self, self,
) -> tuple[builtins.dict[builtins.str, Transform], builtins.list[Transform]]: ... ) -> tuple[
builtins.dict[builtins.str, Transform],
builtins.list[Transform],
typing.Optional[builtins.int],
builtins.dict[builtins.str, DefaultValue],
]: ...
def with_interpolation_order(
self, order: typing.Optional[builtins.int]
) -> Transforms:
r"""
the bspline order used for interpolation when using transforms
None -> 0 (nearest neighbor) for int dtypes, 3 (cubic) for float dtypes
"""
def get_default_interpolation_value(
self, channel_name: builtins.str
) -> DefaultValue:
r"""
the value of the interpolated image outside the data
"""
def set_default_interpolation_value(
self,
channel_name: builtins.str,
value: float | DefaultValue | collections.abc.Sequence[float],
) -> None:
r"""
the value of the interpolated image outside the data
"""
def with_default_interpolation_value(
self,
channel_name: builtins.str,
value: float | DefaultValue | collections.abc.Sequence[float],
) -> Transforms:
r"""
the value of the interpolated image outside the data
"""
@staticmethod @staticmethod
def load(path: builtins.str | os.PathLike | pathlib.Path) -> Transforms: ... def load(path: builtins.str | os.PathLike | pathlib.Path) -> Transforms: ...
def save(self, path: builtins.str | os.PathLike | pathlib.Path) -> None: ... def save(self, path: builtins.str | os.PathLike | pathlib.Path) -> None: ...
File renamed without changes.
@@ -1,4 +1,5 @@
from .ndbioimage_rs.transforms import ( from .ndbioimage_rs.transforms import (
DefaultValue,
Optimizer, Optimizer,
RegistrationStep, RegistrationStep,
SamplingArg, SamplingArg,
@@ -8,6 +9,7 @@ from .ndbioimage_rs.transforms import (
) )
__all__ = [ __all__ = [
"DefaultValue",
"Optimizer", "Optimizer",
"RegistrationStep", "RegistrationStep",
"SamplingArg", "SamplingArg",
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "maturin"
[project] [project]
name = "ndbioimage" name = "ndbioimage"
version = "2027.0.5" version = "2027.0.8"
requires-python = ">=3.10" requires-python = ">=3.10"
classifiers = [ classifiers = [
"License :: OSI Approved :: MIT License", "License :: OSI Approved :: MIT License",
@@ -35,7 +35,7 @@ ndbioimage_generate_stub = "ndbioimage:ndbioimage_generate_stub"
[tool.maturin] [tool.maturin]
python-source = "py" python-source = "py"
features = ["python", "bioformats_java", "gpl-formats", "czi", "tiff", "tiffseq", "tiffwrite", "movie", "transforms"] features = ["python", "bioformats_java", "gpl-formats", "bioformats_rust", "czi", "tiff", "tiffseq", "tiffwrite", "movie", "transforms"]
no-default-features = true no-default-features = true
module-name = "ndbioimage.ndbioimage_rs" module-name = "ndbioimage.ndbioimage_rs"
include = ["py/ndbioimage/jassets/j4rs*", "py/ndbioimage/deps/libj4rs*"] include = ["py/ndbioimage/jassets/j4rs*", "py/ndbioimage/deps/libj4rs*"]
+18 -9
View File
@@ -196,11 +196,11 @@ impl<'de> DeserializeAs<'de, SliceInfoElem> for SliceInfoElemDef {
} }
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug, Copy)]
pub(crate) struct Slice { pub(crate) struct Slice {
start: isize, pub(crate) start: isize,
end: isize, pub(crate) end: isize,
step: isize, pub(crate) step: isize,
} }
impl Slice { impl Slice {
@@ -236,7 +236,7 @@ impl IntoIterator for &Slice {
type IntoIter = Slice; type IntoIter = Slice;
fn into_iter(self) -> Self::IntoIter { fn into_iter(self) -> Self::IntoIter {
self.clone() *self
} }
} }
@@ -314,16 +314,16 @@ impl Index<&Axis> for Shape {
impl Index<usize> for Shape { impl Index<usize> for Shape {
type Output = usize; type Output = usize;
fn index(&self, dim: usize) -> &Self::Output { fn index(&self, index: usize) -> &Self::Output {
&self[self.order[dim % self.order.len()]] &self[self.order[index]]
} }
} }
impl Index<&usize> for Shape { impl Index<&usize> for Shape {
type Output = usize; type Output = usize;
fn index(&self, dim: &usize) -> &Self::Output { fn index(&self, index: &usize) -> &Self::Output {
&self[self.order[dim % self.order.len()]] &self[self.order[*index]]
} }
} }
@@ -406,6 +406,10 @@ impl Shape {
} }
} }
pub fn get(&self, index: &usize) -> Option<&usize> {
self.order.get(*index).map(|axis| &self[axis])
}
/// the number of axes in the shape /// the number of axes in the shape
pub fn len(&self) -> usize { pub fn len(&self) -> usize {
self.order.len() self.order.len()
@@ -421,6 +425,11 @@ impl Shape {
self.order.iter().map(|axis| self[axis]).collect() self.order.iter().map(|axis| self[axis]).collect()
} }
/// the sizes in cztyx order
pub fn cztyx(&self) -> [usize; 5] {
[self.c, self.z, self.t, self.y, self.x]
}
/// the sizes along the axes as a map from axis to size /// the sizes along the axes as a map from axis to size
pub fn to_hashmap(&self) -> HashMap<Axis, usize> { pub fn to_hashmap(&self) -> HashMap<Axis, usize> {
let mut map = HashMap::new(); let mut map = HashMap::new();
+215 -6
View File
@@ -1,13 +1,22 @@
use crate::axes::{Axis, Operation}; use crate::axes::{Axis, Operation};
use crate::readers::Frame;
#[cfg(feature = "transforms")]
use crate::transforms::{DefaultValues, Transforms};
#[cfg(feature = "transforms")]
use image_registration::transform::Transform;
use indexmap::{Equivalent, IndexMap}; use indexmap::{Equivalent, IndexMap};
#[cfg(feature = "transforms")]
use ndarray::IxDyn;
use ndarray::{ArrayD, SliceInfoElem}; use ndarray::{ArrayD, SliceInfoElem};
use std::any::Any; use std::any::Any;
#[cfg(feature = "transforms")]
use std::borrow::Cow;
use std::cmp::PartialEq;
use std::fmt::{Debug, Formatter}; use std::fmt::{Debug, Formatter};
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock}; use std::sync::{Arc, Mutex, OnceLock};
use std::time::SystemTime;
use crate::readers::Frame;
/// maximum number of frames held in the cache /// maximum number of frames held in the cache
const DEFAULT_FRAME_CACHE_SIZE: usize = 128; const DEFAULT_FRAME_CACHE_SIZE: usize = 128;
@@ -15,6 +24,68 @@ const DEFAULT_FRAME_CACHE_SIZE: usize = 128;
/// maximum number of materialized arrays held in the cache /// maximum number of materialized arrays held in the cache
const DEFAULT_ARRAY_CACHE_SIZE: usize = 32; const DEFAULT_ARRAY_CACHE_SIZE: usize = 32;
/// fingerprint of the file(s) a reader reads from.
///
/// The cache is keyed partly on this stamp, so a file that is rewritten (for
/// example an output file that is written and then read back) gets a new cache
/// key instead of returning stale pixels. The stamp is read from the
/// filesystem on every lookup, so a long-lived view also notices a change.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub(crate) struct FileStamp {
/// size of the file in bytes
len: u64,
/// last modification time
modified: Option<SystemTime>,
/// last status change time; on unix this catches same-size rewrites that
/// happen within the timestamp resolution of `modified`
#[cfg(unix)]
changed: Option<(i64, u32)>,
/// inode number; on unix this catches an atomic replace that preserves the
/// modification time and size
#[cfg(unix)]
inode: u64,
/// creation time; windows has no stable `ctime` or file index, but this
/// changes when a file is deleted/recreated or atomically replaced, which is
/// the case the unix inode covers. in-place rewrites are caught by
/// `modified`, which has 100 ns resolution on windows.
#[cfg(windows)]
created: Option<SystemTime>,
}
impl FileStamp {
/// read the current stamp for `path`, returning the default (all-zero)
/// stamp when the metadata cannot be read
pub(crate) fn from_path(path: &Path) -> Self {
let Ok(meta) = std::fs::metadata(path) else {
return Self::default();
};
let len = meta.len();
let modified = meta.modified().ok();
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
Self {
len,
modified,
changed: Some((meta.ctime(), meta.ctime_nsec() as u32)),
inode: meta.ino(),
}
}
#[cfg(windows)]
{
Self {
len,
modified,
created: meta.created().ok(),
}
}
#[cfg(not(any(unix, windows)))]
{
Self { len, modified }
}
}
}
/// identity of the reader a frame was read from /// identity of the reader a frame was read from
#[derive(Debug, Clone, PartialEq, Eq, Hash)] #[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct ReaderKey { pub(crate) struct ReaderKey {
@@ -22,6 +93,7 @@ pub(crate) struct ReaderKey {
pub(crate) path: PathBuf, pub(crate) path: PathBuf,
pub(crate) series: usize, pub(crate) series: usize,
pub(crate) position: usize, pub(crate) position: usize,
pub(crate) stamp: FileStamp,
} }
/// borrowed view of [`ReaderKey`] for cache lookups without allocation. /// borrowed view of [`ReaderKey`] for cache lookups without allocation.
@@ -33,6 +105,7 @@ pub(crate) struct ReaderKeyRef<'a> {
pub(crate) path: &'a Path, pub(crate) path: &'a Path,
pub(crate) series: usize, pub(crate) series: usize,
pub(crate) position: usize, pub(crate) position: usize,
pub(crate) stamp: FileStamp,
} }
impl Hash for ReaderKeyRef<'_> { impl Hash for ReaderKeyRef<'_> {
@@ -41,6 +114,7 @@ impl Hash for ReaderKeyRef<'_> {
self.path.hash(state); self.path.hash(state);
self.series.hash(state); self.series.hash(state);
self.position.hash(state); self.position.hash(state);
self.stamp.hash(state);
} }
} }
@@ -50,6 +124,7 @@ impl Equivalent<ReaderKey> for ReaderKeyRef<'_> {
&& self.path == key.path.as_path() && self.path == key.path.as_path()
&& self.series == key.series && self.series == key.series
&& self.position == key.position && self.position == key.position
&& self.stamp == key.stamp
} }
} }
@@ -60,11 +135,38 @@ impl ReaderKeyRef<'_> {
path: self.path.to_path_buf(), path: self.path.to_path_buf(),
series: self.series, series: self.series,
position: self.position, position: self.position,
stamp: self.stamp,
} }
} }
} }
pub(crate) type FrameKey = (ReaderKey, usize, usize, usize); #[derive(Debug, PartialEq, Eq)]
pub(crate) struct FrameKey {
pub(crate) reader: ReaderKey,
pub(crate) c: usize,
pub(crate) z: usize,
pub(crate) t: usize,
#[cfg(feature = "transforms")]
pub(crate) transform: Option<(Transform<IxDyn>, usize, DefaultValues)>,
}
impl Hash for FrameKey {
fn hash<H: Hasher>(&self, state: &mut H) {
self.reader.hash(state);
self.c.hash(state);
self.z.hash(state);
self.t.hash(state);
#[cfg(feature = "transforms")]
if let Some(transform) = self.transform.as_ref() {
transform.0.hash(state);
transform.1.hash(state);
transform.2.iter().for_each(|(k, v)| {
k.hash(state);
v.hash(state);
})
}
}
}
/// borrowed view of [`FrameKey`] for cache lookups without allocation. /// borrowed view of [`FrameKey`] for cache lookups without allocation.
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
@@ -73,6 +175,8 @@ pub(crate) struct FrameKeyRef<'a> {
pub(crate) c: usize, pub(crate) c: usize,
pub(crate) z: usize, pub(crate) z: usize,
pub(crate) t: usize, pub(crate) t: usize,
#[cfg(feature = "transforms")]
pub(crate) transform: Option<(Cow<'a, Transform<IxDyn>>, usize, Cow<'a, DefaultValues>)>,
} }
impl Hash for FrameKeyRef<'_> { impl Hash for FrameKeyRef<'_> {
@@ -81,19 +185,60 @@ impl Hash for FrameKeyRef<'_> {
self.c.hash(state); self.c.hash(state);
self.z.hash(state); self.z.hash(state);
self.t.hash(state); self.t.hash(state);
#[cfg(feature = "transforms")]
if let Some(transform) = self.transform.as_ref() {
transform.0.hash(state);
transform.1.hash(state);
transform.2.iter().for_each(|(k, v)| {
k.hash(state);
v.hash(state);
})
}
} }
} }
impl Equivalent<FrameKey> for FrameKeyRef<'_> { impl Equivalent<FrameKey> for FrameKeyRef<'_> {
fn equivalent(&self, key: &FrameKey) -> bool { fn equivalent(&self, key: &FrameKey) -> bool {
let (rk, c, z, t) = key; let FrameKey {
self.reader.equivalent(rk) && self.c == *c && self.z == *z && self.t == *t reader: reader_key,
c,
z,
t,
#[cfg(feature = "transforms")]
transform,
} = key;
#[cfg(feature = "transforms")]
{
self.reader.equivalent(reader_key)
&& self.c == *c
&& self.z == *z
&& self.t == *t
&& self
.transform
.as_ref()
.map(|(t, i, d)| (t.as_ref(), i, d.as_ref()))
== transform.as_ref().map(|(t, i, d)| (t, i, d))
}
#[cfg(not(feature = "transforms"))]
{
self.reader.equivalent(reader_key) && self.c == *c && self.z == *z && self.t == *t
}
} }
} }
impl FrameKeyRef<'_> { impl FrameKeyRef<'_> {
pub(crate) fn to_owned(&self) -> FrameKey { pub(crate) fn to_owned(&self) -> FrameKey {
(self.reader.to_owned(), self.c, self.z, self.t) FrameKey {
reader: self.reader.to_owned(),
c: self.c,
z: self.z,
t: self.t,
#[cfg(feature = "transforms")]
transform: self
.transform
.as_ref()
.map(|(t, i, d)| (t.clone().into_owned(), *i, d.clone().into_owned())),
}
} }
} }
@@ -105,6 +250,8 @@ pub(crate) struct ArrayKey {
pub(crate) slice: Vec<SliceInfoElem>, pub(crate) slice: Vec<SliceInfoElem>,
pub(crate) axes: Vec<Axis>, pub(crate) axes: Vec<Axis>,
pub(crate) operations: Vec<(Axis, Operation)>, pub(crate) operations: Vec<(Axis, Operation)>,
#[cfg(feature = "transforms")]
pub(crate) transforms: Transforms,
} }
/// borrowed view of [`ArrayKey`] for cache lookups without allocation. /// borrowed view of [`ArrayKey`] for cache lookups without allocation.
@@ -115,6 +262,8 @@ pub(crate) struct ArrayKeyRef<'a> {
pub(crate) slice: &'a [SliceInfoElem], pub(crate) slice: &'a [SliceInfoElem],
pub(crate) axes: &'a [Axis], pub(crate) axes: &'a [Axis],
pub(crate) operations: &'a IndexMap<Axis, Operation>, pub(crate) operations: &'a IndexMap<Axis, Operation>,
#[cfg(feature = "transforms")]
pub(crate) transforms: &'a Transforms,
} }
impl Hash for ArrayKeyRef<'_> { impl Hash for ArrayKeyRef<'_> {
@@ -128,11 +277,15 @@ impl Hash for ArrayKeyRef<'_> {
ax.hash(state); ax.hash(state);
op.hash(state); op.hash(state);
} }
#[cfg(feature = "transforms")]
self.transforms.hash(state);
} }
} }
impl Equivalent<ArrayKey> for ArrayKeyRef<'_> { impl Equivalent<ArrayKey> for ArrayKeyRef<'_> {
fn equivalent(&self, key: &ArrayKey) -> bool { fn equivalent(&self, key: &ArrayKey) -> bool {
#[cfg(feature = "transforms")]
{
self.reader.equivalent(&key.reader) self.reader.equivalent(&key.reader)
&& self.dtype == key.dtype && self.dtype == key.dtype
&& self.slice == key.slice && self.slice == key.slice
@@ -143,6 +296,21 @@ impl Equivalent<ArrayKey> for ArrayKeyRef<'_> {
.iter() .iter()
.zip(&key.operations) .zip(&key.operations)
.all(|((ax, op), (key_ax, key_op))| ax == key_ax && op == key_op) .all(|((ax, op), (key_ax, key_op))| ax == key_ax && op == key_op)
&& self.transforms == key.transforms
}
#[cfg(not(feature = "transforms"))]
{
self.reader.equivalent(&key.reader)
&& self.dtype == key.dtype
&& self.slice == key.slice
&& self.axes == key.axes
&& self.operations.len() == key.operations.len()
&& self
.operations
.iter()
.zip(&key.operations)
.all(|((ax, op), (key_ax, key_op))| ax == key_ax && op == key_op)
}
} }
} }
@@ -158,6 +326,8 @@ impl ArrayKeyRef<'_> {
.iter() .iter()
.map(|(ax, op)| (*ax, op.clone())) .map(|(ax, op)| (*ax, op.clone()))
.collect(), .collect(),
#[cfg(feature = "transforms")]
transforms: self.transforms.to_owned(),
} }
} }
} }
@@ -320,3 +490,42 @@ impl Debug for ArrayCache {
f.debug_struct("ArrayCache").finish_non_exhaustive() f.debug_struct("ArrayCache").finish_non_exhaustive()
} }
} }
#[cfg(test)]
mod tests {
use super::*;
fn temp_path(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("ndbioimage-cache-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir.join(name)
}
#[test]
fn file_stamp_detects_rewrite() {
let path = temp_path("stamp.bin");
std::fs::write(&path, b"aaaa").unwrap();
let before = FileStamp::from_path(&path);
std::fs::write(&path, b"bbbbbb").unwrap();
let after = FileStamp::from_path(&path);
assert_ne!(before, after);
let _ = std::fs::remove_file(&path);
}
#[test]
fn reader_key_includes_file_stamp() {
let key = |stamp| ReaderKey {
name: "test".to_string(),
path: PathBuf::from("test.tif"),
series: 0,
position: 0,
stamp,
};
let a = key(FileStamp::from_path(Path::new("does-not-exist.tif")));
let b = key(FileStamp {
len: 1,
..FileStamp::default()
});
assert_ne!(a, b);
}
}
File renamed without changes.
+22 -5
View File
@@ -47,7 +47,7 @@ pub enum Error {
#[error(transparent)] #[error(transparent)]
Tiff(#[from] tiff::TiffError), Tiff(#[from] tiff::TiffError),
/// a postcard (de)serialization error /// a postcard (de)serialization error
#[cfg(feature = "python")] #[cfg(any(test, feature = "python"))]
#[error(transparent)] #[error(transparent)]
PostCard(#[from] postcard::Error), PostCard(#[from] postcard::Error),
/// an error from the libczi binding /// an error from the libczi binding
@@ -65,10 +65,6 @@ pub enum Error {
#[cfg(feature = "czi")] #[cfg(feature = "czi")]
#[error(transparent)] #[error(transparent)]
XmlTreeParse(#[from] xmltree::ParseError), XmlTreeParse(#[from] xmltree::ParseError),
/// a czi-specific error
#[cfg(feature = "czi")]
#[error(transparent)]
Czi(#[from] crate::readers::czi::CziError),
/// an error joining a tokio task /// an error joining a tokio task
#[cfg(feature = "movie")] #[cfg(feature = "movie")]
#[error(transparent)] #[error(transparent)]
@@ -121,6 +117,9 @@ pub enum Error {
/// the view is empty /// the view is empty
#[error("empty view")] #[error("empty view")]
EmptyView, EmptyView,
/// the view cannot be parsed as a scalar
#[error("not a scalar")]
NotScalar,
/// the color string could not be parsed /// the color string could not be parsed
#[error("invalid color: {0}")] #[error("invalid color: {0}")]
InvalidColor(String), InvalidColor(String),
@@ -172,6 +171,24 @@ pub enum Error {
/// file mismatch /// file mismatch
#[error("{0} do not match in {1} and {2}")] #[error("{0} do not match in {1} and {2}")]
FileMismatch(String, String, String), FileMismatch(String, String, String),
/// a jvm created in another process was inherited across a fork
#[error(
"the jvm was created in process {0} but this is process {1}: a forked child inherits a jvm \
it cannot use. read the file before forking, or start the child with spawn/exec instead."
)]
/// jvm was forked, so it cannot run in this process
ForkedJvm(u32, u32),
/// only 2d and 3d transformations are implemented
#[error("{0}D transformation is not implemented")]
TransformationNotImplemented(usize),
/// cannot find the name for some channel because it does not exist
#[error("no channel {0} present")]
NoChannel(usize),
/// no valid blocks in czi file
#[error("czi file has no valid blocks")]
CziNoValidBlocks,
#[error("f128 is not supported")]
F128NotSupported,
} }
impl Error { impl Error {
+5 -5
View File
@@ -6,8 +6,8 @@
//! structure. Additionally, it can automatically calculate an affine transform that corrects for //! structure. Additionally, it can automatically calculate an affine transform that corrects for
//! chromatic aberrations etc. and apply it on the fly to the image. //! chromatic aberrations etc. and apply it on the fly to the image.
//! //!
//! Currently, it supports imagej tif files, czi files, micromanager tif sequences and anything //! Currently, it supports imagej and ome tif files, czi files, micromanager tif sequences and
//! [bioformats](https://www.openmicroscopy.org/bio-formats/) can handle. //! anything [bioformats](https://www.openmicroscopy.org/bio-formats/) can handle.
//! //!
//! ```rust,no_run //! ```rust,no_run
//! use ndarray::Array2; //! use ndarray::Array2;
@@ -16,7 +16,7 @@
//! # fn main() -> Result<(), ndbioimage::error::Error> { //! # fn main() -> Result<(), ndbioimage::error::Error> {
//! let path = "/path/to/file"; //! let path = "/path/to/file";
//! let reader = DynReader::new(&path, 0, 0)?; //! let reader = DynReader::new(&path, 0, 0)?;
//! println!("shape: {}", reader.shape()); //! println!("shape: {}", reader.raw_shape());
//! let frame = reader.get_frame(0, 0, 0)?; //! let frame = reader.get_frame(0, 0, 0)?;
//! if let Ok(arr) = <Frame as TryInto<Array2<i8>>>::try_into(frame) { //! if let Ok(arr) = <Frame as TryInto<Array2<i8>>>::try_into(frame) {
//! println!("{:?}", arr); //! println!("{:?}", arr);
@@ -70,7 +70,7 @@ pub mod readers;
pub mod tiffwrite; pub mod tiffwrite;
#[cfg(feature = "transforms")] #[cfg(feature = "transforms")]
/// image registration and warping images using affine transforms /// image registration and warping images using affine transforms
mod transforms; pub mod transforms;
mod utils; mod utils;
/// main entry point for the application /// main entry point for the application
@@ -338,7 +338,7 @@ mod tests {
fn read_ser() -> Result<(), Error> { fn read_ser() -> Result<(), Error> {
let file = "czi/Experiment-2029.czi"; let file = "czi/Experiment-2029.czi";
let reader = open(file)?; let reader = open(file)?;
println!("shape: {}", reader.shape()); println!("shape: {}", reader.raw_shape());
let frame = reader.get_frame(0, 0, 0)?; let frame = reader.get_frame(0, 0, 0)?;
if let Ok(arr) = <Frame as TryInto<Array2<i8>>>::try_into(frame) { if let Ok(arr) = <Frame as TryInto<Array2<i8>>>::try_into(frame) {
println!("{:?}", arr); println!("{:?}", arr);
File renamed without changes.
File renamed without changes.
File renamed without changes.
+5 -1
View File
@@ -40,7 +40,7 @@ pub(crate) fn ndbioimage_file() -> PathBuf {
PathBuf::from(file) PathBuf::from(file)
} }
/// generates ndbioimage/__init__.pyi /// generates .pyi files
#[gen_stub_pyfunction] #[gen_stub_pyfunction]
#[pyfunction] #[pyfunction]
pub fn generate_stub(dest_path: String) -> PyResult<()> { pub fn generate_stub(dest_path: String) -> PyResult<()> {
@@ -67,6 +67,7 @@ fn main() -> PyResult<()> {
#[pymodule] #[pymodule]
#[pyo3(name = "ndbioimage_rs")] #[pyo3(name = "ndbioimage_rs")]
mod ndbioimage_rs { mod ndbioimage_rs {
#[cfg(feature = "transforms")]
use pyo3::prelude::*; use pyo3::prelude::*;
#[pymodule_export] #[pymodule_export]
@@ -100,6 +101,9 @@ mod ndbioimage_rs {
#[pymodule_export] #[pymodule_export]
use super::super::transforms::PyTransforms; use super::super::transforms::PyTransforms;
#[pymodule_export]
use super::super::transforms::PyDefaultValue;
#[pymodule_export] #[pymodule_export]
use super::super::transforms::PyRegistrationStep; use super::super::transforms::PyRegistrationStep;
File diff suppressed because it is too large. Load diff
@@ -1,13 +1,17 @@
use crate::error::Error; use crate::error::Error;
use crate::py::imread::PyView;
use crate::transforms::Transforms; use crate::transforms::Transforms;
use image_registration::bspline::DefaultValue;
use image_registration::metric::{SamplingArg, Sigma}; use image_registration::metric::{SamplingArg, Sigma};
use image_registration::register::{Optimizer, RegistrationStep}; use image_registration::register::{Optimizer, RegistrationStep};
use image_registration::transform::Transform; use image_registration::transform::Transform;
use ndarray::{Ix0, Ix1, Ix2, Ix3, Ix4, Ix5, IxDyn}; use ndarray::{Ix0, Ix1, Ix2, Ix3, Ix4, Ix5, IxDyn};
use numpy::{ use numpy::{
AllowTypeChange, IntoPyArray, PyArray, PyArray1, PyArray2, PyArrayDyn, PyArrayLike1, AllowTypeChange, IntoPyArray, PyArray, PyArray1, PyArray2, PyArrayDescrMethods, PyArrayDyn,
PyArrayLike2, PyArrayLikeDyn, PyArrayLike1, PyArrayLike2, PyArrayLikeDyn, PyArrayMethods, PyUntypedArray,
PyUntypedArrayMethods, dtype,
}; };
use pyo3::IntoPyObjectExt;
use pyo3::exceptions::{PyNotImplementedError, PyValueError}; use pyo3::exceptions::{PyNotImplementedError, PyValueError};
use pyo3::prelude::*; use pyo3::prelude::*;
use pyo3_stub_gen::derive::*; use pyo3_stub_gen::derive::*;
@@ -15,6 +19,45 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
#[gen_stub_pyclass]
#[pyclass(
subclass,
from_py_object,
eq,
name = "DefaultValue",
module = "ndbioimage.transforms"
)]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub(crate) struct PyDefaultValue {
inner: DefaultValue,
}
#[gen_stub_pymethods]
#[pymethods]
impl PyDefaultValue {
#[new]
#[pyo3(signature = (values, weights = None))]
fn new(values: Vec<f64>, weights: Option<Vec<f64>>) -> Self {
Self {
inner: DefaultValue::new(values, weights.unwrap_or_default()),
}
}
#[getter]
fn values(&self) -> Vec<f64> {
self.inner.values().to_vec()
}
#[getter]
fn weights(&self) -> Vec<f64> {
self.inner.weights().to_vec()
}
fn __getnewargs__(&self) -> (Vec<f64>, Vec<f64>) {
(self.inner.values().to_vec(), self.inner.weights().to_vec())
}
}
#[gen_stub_pyclass] #[gen_stub_pyclass]
#[pyclass( #[pyclass(
subclass, subclass,
@@ -32,41 +75,31 @@ pub(crate) struct PyTransform {
#[pymethods] #[pymethods]
impl PyTransform { impl PyTransform {
#[new] #[new]
#[pyo3(signature = (parameters = None, shape = None, center = None))] #[pyo3(signature = (parameters = None, dparameters = None, center = None))]
fn new( fn new(
parameters: Option<Vec<f64>>, parameters: Option<Vec<f64>>,
shape: Option<Vec<usize>>, dparameters: Option<Vec<f64>>,
center: Option<Vec<f64>>, center: Option<Vec<f64>>,
) -> Self { ) -> Self {
let parameters = parameters.unwrap_or_else(|| vec![1.0, 0.0, 0.0, 1.0, 0.0, 0.0]); let parameters = parameters.unwrap_or_else(|| vec![1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
let shape = shape.unwrap_or_else(|| vec![512, 512]); let mut transform = if let Some(center) = center {
if let Some(center) = center { Transform::new_around_center(parameters, &center)
Self {
inner: Transform::new_with_center(parameters, center, shape),
}
} else { } else {
Self { Transform::new(parameters)
inner: Transform::new(parameters, shape), };
} if let Some(dparameters) = dparameters {
transform.dparameters = dparameters;
} }
Self { inner: transform }
} }
fn __getnewargs__(&self) -> (Vec<f64>, Vec<usize>, Option<Vec<f64>>) { fn __getnewargs__(&self) -> (Vec<f64>, Vec<f64>) {
( (
self.inner.parameters.clone(), self.inner.parameters.clone(),
self.inner.shape.clone(), self.inner.dparameters.clone(),
Some(self.inner.center.clone()),
) )
} }
fn __getstate__(&self) -> Vec<f64> {
self.inner.dparameters.clone()
}
fn __setstate__(&mut self, state: Vec<f64>) {
self.inner.dparameters = state;
}
fn __add__(&self, other: &PyTransform) -> PyTransform { fn __add__(&self, other: &PyTransform) -> PyTransform {
PyTransform { PyTransform {
inner: &self.inner + &other.inner, inner: &self.inner + &other.inner,
@@ -96,9 +129,9 @@ impl PyTransform {
py: Python, py: Python,
#[gen_stub(override_type(type_repr = "Transform | float"))] other: &Bound<PyAny>, #[gen_stub(override_type(type_repr = "Transform | float"))] other: &Bound<PyAny>,
) -> PyResult<PyTransform> { ) -> PyResult<PyTransform> {
if other.is_instance_of::<PyTransform>() { if let Ok(other) = other.extract::<PyTransform>() {
Ok(PyTransform { Ok(PyTransform {
inner: &self.inner * &other.extract::<PyTransform>()?.inner, inner: &self.inner * other.inner,
}) })
} else { } else {
let builtins = PyModule::import(py, "builtins")?; let builtins = PyModule::import(py, "builtins")?;
@@ -114,9 +147,9 @@ impl PyTransform {
py: Python, py: Python,
#[gen_stub(override_type(type_repr = "Transform | float"))] other: &Bound<PyAny>, #[gen_stub(override_type(type_repr = "Transform | float"))] other: &Bound<PyAny>,
) -> PyResult<PyTransform> { ) -> PyResult<PyTransform> {
if other.is_instance_of::<PyTransform>() { if let Ok(other) = other.extract::<PyTransform>() {
Ok(PyTransform { Ok(PyTransform {
inner: &other.extract::<PyTransform>()?.inner * &self.inner, inner: &other.inner * &self.inner,
}) })
} else { } else {
let builtins = PyModule::import(py, "builtins")?; let builtins = PyModule::import(py, "builtins")?;
@@ -155,26 +188,6 @@ impl PyTransform {
self.inner.dparameters = dparameters; self.inner.dparameters = dparameters;
} }
#[getter]
fn get_center(&self) -> Vec<f64> {
self.inner.center.clone()
}
#[setter]
fn set_center(&mut self, center: Vec<f64>) {
self.inner.center = center;
}
#[getter]
fn get_shape(&self) -> Vec<usize> {
self.inner.shape.clone()
}
#[setter]
fn set_shape(&mut self, shape: Vec<usize>) {
self.inner.shape = shape;
}
#[getter] #[getter]
fn get_ndim(&self) -> usize { fn get_ndim(&self) -> usize {
self.inner.ndim() self.inner.ndim()
@@ -217,10 +230,6 @@ impl PyTransform {
}) })
} }
fn adapt(&mut self, center: Vec<f64>, shape: Vec<usize>) {
self.inner.adapt(center.as_slice(), shape.as_slice());
}
#[staticmethod] #[staticmethod]
fn from_scaling(scaling: Vec<f64>) -> PyTransform { fn from_scaling(scaling: Vec<f64>) -> PyTransform {
PyTransform { PyTransform {
@@ -261,74 +270,178 @@ impl PyTransform {
} }
} }
#[pyo3(signature = (order, image, default_value = None))]
fn interpolate<'py>( fn interpolate<'py>(
&self, &self,
py: Python<'py>, py: Python<'py>,
order: usize, order: usize,
#[gen_stub(override_type(type_repr="numpy.typing.ArrayLike", imports=("numpy.typing")))] #[gen_stub(override_type(type_repr="numpy.typing.ArrayLike", imports=("numpy.typing")))]
image: PyArrayLikeDyn<f64, AllowTypeChange>, image: PyArrayLikeDyn<f64, AllowTypeChange>,
default_value: Option<PyDefaultValue>,
) -> PyResult<Bound<'py, PyArrayDyn<f64>>> { ) -> PyResult<Bound<'py, PyArrayDyn<f64>>> {
let image = image.as_array(); let image = image.as_array();
let default_value = default_value.map(|i| i.inner).unwrap_or_default();
Ok(py Ok(py
.detach(|| match order { .detach(|| match order {
0 => Ok(self 0 => Ok(self.inner.interpolate::<0, _, _>(
.inner image.into_dimensionality().map_err(Error::from)?,
.interpolate::<0, _, _>(image.into_dimensionality().map_err(Error::from)?)), &default_value,
1 => Ok(self )),
.inner 1 => Ok(self.inner.interpolate::<1, _, _>(
.interpolate::<1, _, _>(image.into_dimensionality().map_err(Error::from)?)), image.into_dimensionality().map_err(Error::from)?,
2 => Ok(self &default_value,
.inner )),
.interpolate::<2, _, _>(image.into_dimensionality().map_err(Error::from)?)), 2 => Ok(self.inner.interpolate::<2, _, _>(
3 => Ok(self image.into_dimensionality().map_err(Error::from)?,
.inner &default_value,
.interpolate::<3, _, _>(image.into_dimensionality().map_err(Error::from)?)), )),
4 => Ok(self 3 => Ok(self.inner.interpolate::<3, _, _>(
.inner image.into_dimensionality().map_err(Error::from)?,
.interpolate::<4, _, _>(image.into_dimensionality().map_err(Error::from)?)), &default_value,
5 => Ok(self )),
.inner 4 => Ok(self.inner.interpolate::<4, _, _>(
.interpolate::<5, _, _>(image.into_dimensionality().map_err(Error::from)?)), image.into_dimensionality().map_err(Error::from)?,
&default_value,
)),
5 => Ok(self.inner.interpolate::<5, _, _>(
image.into_dimensionality().map_err(Error::from)?,
&default_value,
)),
_ => Err(PyValueError::new_err("order must be 0 <= order < 6")), _ => Err(PyValueError::new_err("order must be 0 <= order < 6")),
})? })?
.map_err(Error::from)? .map_err(Error::from)?
.into_pyarray(py)) .into_pyarray(py))
} }
#[pyo3(signature = (order, image, default_value = None))]
fn interpolate_par<'py>( fn interpolate_par<'py>(
&self, &self,
py: Python<'py>, py: Python<'py>,
order: usize, order: usize,
#[gen_stub(override_type(type_repr="numpy.typing.ArrayLike", imports=("numpy.typing")))] #[gen_stub(override_type(type_repr="numpy.typing.ArrayLike", imports=("numpy.typing")))]
image: PyArrayLikeDyn<f64, AllowTypeChange>, image: PyArrayLikeDyn<f64, AllowTypeChange>,
default_value: Option<PyDefaultValue>,
) -> PyResult<Bound<'py, PyArrayDyn<f64>>> { ) -> PyResult<Bound<'py, PyArrayDyn<f64>>> {
let image = image.as_array(); let image = image.as_array();
let default_value = default_value.map(|i| i.inner).unwrap_or_default();
Ok(py Ok(py
.detach(|| match order { .detach(|| match order {
0 => Ok(self 0 => Ok(self.inner.interpolate_par::<0, _, _>(
.inner image.into_dimensionality().map_err(Error::from)?,
.interpolate_par::<0, _, _>(image.into_dimensionality().map_err(Error::from)?)), &default_value,
1 => Ok(self )),
.inner 1 => Ok(self.inner.interpolate_par::<1, _, _>(
.interpolate_par::<1, _, _>(image.into_dimensionality().map_err(Error::from)?)), image.into_dimensionality().map_err(Error::from)?,
2 => Ok(self &default_value,
.inner )),
.interpolate_par::<2, _, _>(image.into_dimensionality().map_err(Error::from)?)), 2 => Ok(self.inner.interpolate_par::<2, _, _>(
3 => Ok(self image.into_dimensionality().map_err(Error::from)?,
.inner &default_value,
.interpolate_par::<3, _, _>(image.into_dimensionality().map_err(Error::from)?)), )),
4 => Ok(self 3 => Ok(self.inner.interpolate_par::<3, _, _>(
.inner image.into_dimensionality().map_err(Error::from)?,
.interpolate_par::<4, _, _>(image.into_dimensionality().map_err(Error::from)?)), &default_value,
5 => Ok(self )),
.inner 4 => Ok(self.inner.interpolate_par::<4, _, _>(
.interpolate_par::<5, _, _>(image.into_dimensionality().map_err(Error::from)?)), image.into_dimensionality().map_err(Error::from)?,
&default_value,
)),
5 => Ok(self.inner.interpolate_par::<5, _, _>(
image.into_dimensionality().map_err(Error::from)?,
&default_value,
)),
_ => Err(PyValueError::new_err("order must be 0 <= order < 6")), _ => Err(PyValueError::new_err("order must be 0 <= order < 6")),
})? })?
.map_err(Error::from)? .map_err(Error::from)?
.into_pyarray(py)) .into_pyarray(py))
} }
/// backwards compatibility, default parameter is not implemented
#[gen_stub(override_return_type(type_repr="numpy.ndarray", imports=("numpy")))]
#[pyo3(signature = (image, default = None, default_value = None))]
fn frame<'py>(
&self,
py: Python<'py>,
#[gen_stub(override_type(type_repr="numpy.typing.ArrayLike", imports=("numpy.typing")))]
image: Bound<'py, PyUntypedArray>,
default: Option<Bound<'py, PyAny>>,
default_value: Option<PyDefaultValue>,
) -> PyResult<Bound<'py, PyAny>> {
if default.is_some() {
return Err(PyNotImplementedError::new_err(
"default parameter is not implemented",
));
}
if image.ndim() != self.inner.ndim() {
return Err(PyValueError::new_err(format!(
"dimensionality of image ({}) and transform ({}) do not correspond",
image.ndim(),
self.inner.ndim()
)));
}
let default_value = default_value.map(|i| i.inner).unwrap_or_default();
macro_rules! interp {
($order:expr, $t:tt) => {
self.inner
.interpolate_par::<$order, _, _>(
unsafe { image.cast::<PyArrayDyn<$t>>()?.as_array() }
.mapv(|i| i as f64)
.into_dimensionality()
.map_err(Error::from)?
.view(),
&default_value,
)
.map_err(Error::from)?
.mapv(|i| i as $t)
.into_pyarray(py)
.into_bound_py_any(py)
};
}
let dt = image.dtype();
if dt.is_equiv_to(&dtype::<bool>(py)) {
self.inner
.interpolate_par::<0, _, _>(
unsafe { image.cast::<PyArrayDyn<bool>>()?.as_array() }
.mapv(|i| if i { 1.0 } else { 0.0 })
.into_dimensionality()
.map_err(Error::from)?
.view(),
&default_value,
)
.map_err(Error::from)?
.mapv(|i| i > 0.5)
.into_pyarray(py)
.into_bound_py_any(py)
} else if dt.is_equiv_to(&dtype::<u8>(py)) {
interp!(0, u8)
} else if dt.is_equiv_to(&dtype::<i8>(py)) {
interp!(0, i8)
} else if dt.is_equiv_to(&dtype::<u16>(py)) {
interp!(0, u16)
} else if dt.is_equiv_to(&dtype::<i16>(py)) {
interp!(0, i16)
} else if dt.is_equiv_to(&dtype::<u32>(py)) {
interp!(0, u32)
} else if dt.is_equiv_to(&dtype::<i32>(py)) {
interp!(0, i32)
} else if dt.is_equiv_to(&dtype::<u64>(py)) {
interp!(0, u64)
} else if dt.is_equiv_to(&dtype::<i64>(py)) {
interp!(0, i64)
} else if dt.is_equiv_to(&dtype::<f32>(py)) {
interp!(3, f32)
} else if dt.is_equiv_to(&dtype::<f64>(py)) {
interp!(3, f64)
} else {
Err(PyValueError::new_err(format!(
"invalid dtype: {}; must be bool, (u)int(8/16/32/64) or float(32/64)",
dt
)))
}
}
fn is_unity(&self) -> bool { fn is_unity(&self) -> bool {
self.inner.is_unity() self.inner.is_unity()
} }
@@ -338,13 +451,19 @@ impl PyTransform {
py: Python<'py>, py: Python<'py>,
#[gen_stub(override_type(type_repr="numpy.typing.ArrayLike", imports=("numpy.typing")))] #[gen_stub(override_type(type_repr="numpy.typing.ArrayLike", imports=("numpy.typing")))]
point: PyArrayLike1<f64, AllowTypeChange>, point: PyArrayLike1<f64, AllowTypeChange>,
#[gen_stub(override_type(type_repr="numpy.typing.ArrayLike", imports=("numpy.typing")))]
center: PyArrayLike1<f64, AllowTypeChange>,
) -> Bound<'py, PyArray1<f64>> { ) -> Bound<'py, PyArray1<f64>> {
let point = point.as_array(); let point = point.as_array();
if let Some(slice) = point.as_slice() { let center = center.as_array();
self.inner.transform_point(slice)
} else { match (point.as_slice(), center.as_slice()) {
let point = point.to_vec(); (Some(point), Some(center)) => self.inner.transform_point(point, center),
self.inner.transform_point(&point) (Some(point), None) => self.inner.transform_point(point, &center.to_vec()),
(None, Some(center)) => self.inner.transform_point(&point.to_vec(), center),
(None, None) => self
.inner
.transform_point(&point.to_vec(), &center.to_vec()),
} }
.into_pyarray(py) .into_pyarray(py)
} }
@@ -354,10 +473,19 @@ impl PyTransform {
py: Python<'py>, py: Python<'py>,
#[gen_stub(override_type(type_repr="numpy.typing.ArrayLike", imports=("numpy.typing")))] #[gen_stub(override_type(type_repr="numpy.typing.ArrayLike", imports=("numpy.typing")))]
points: PyArrayLike2<f64, AllowTypeChange>, points: PyArrayLike2<f64, AllowTypeChange>,
#[gen_stub(override_type(type_repr="numpy.typing.ArrayLike", imports=("numpy.typing")))]
center: PyArrayLike1<f64, AllowTypeChange>,
) -> PyResult<Bound<'py, PyArray2<f64>>> { ) -> PyResult<Bound<'py, PyArray2<f64>>> {
let points = points.as_array(); let points = points.as_array();
let center = center.as_array();
Ok(py Ok(py
.detach(|| self.inner.transform_points(points)) .detach(|| {
if let Some(center) = center.as_slice() {
self.inner.transform_points(points, center)
} else {
self.inner.transform_points(points, &center.to_vec())
}
})
.map_err(Error::from)? .map_err(Error::from)?
.into_pyarray(py)) .into_pyarray(py))
} }
@@ -598,6 +726,29 @@ impl PyTransform {
))), ))),
}) })
} }
#[staticmethod]
fn register_translation_fft<'py>(
py: Python<'py>,
#[gen_stub(override_type(type_repr="numpy.typing.ArrayLike", imports=("numpy.typing")))]
fixed: PyArrayLikeDyn<f64, AllowTypeChange>,
#[gen_stub(override_type(type_repr="numpy.typing.ArrayLike", imports=("numpy.typing")))]
moving: PyArrayLikeDyn<f64, AllowTypeChange>,
) -> PyResult<PyTransform> {
let fixed = fixed.as_array();
let moving = moving.as_array();
if fixed.shape() != moving.shape() {
return Err(PyErr::from(Error::ShapeMismatch(
fixed.shape().to_vec(),
moving.shape().to_vec(),
)));
}
py.detach(|| {
Ok(PyTransform {
inner: Transform::register_translation_fft(fixed, moving).map_err(Error::from)?,
})
})
}
} }
#[gen_stub_pyclass] #[gen_stub_pyclass]
@@ -613,22 +764,375 @@ pub(crate) struct PyTransforms {
pub(crate) inner: Transforms, pub(crate) inner: Transforms,
} }
#[gen_stub_pymethods]
#[pymethods]
impl PyView {
/// the bspline order used for interpolation when using transforms
/// None -> 0 (nearest neighbor) for int dtypes, 3 (cubic) for float dtypes
#[getter]
fn get_interpolation_order(&self) -> Option<usize> {
self.view.transforms.interpolation_order
}
/// the bspline order used for interpolation when using transforms
/// None -> 0 (nearest neighbor) for int dtypes, 3 (cubic) for float dtypes
#[setter]
fn set_interpolation_order(&mut self, order: Option<usize>) {
self.view.transforms.interpolation_order = order;
}
/// the bspline order used for interpolation when using transforms
/// None -> 0 (nearest neighbor) for int dtypes, 3 (cubic) for float dtypes
fn with_interpolation_order(&self, order: Option<usize>) -> Self {
let mut new = self.clone();
new.view.transforms.interpolation_order = order;
new
}
/// the value of the interpolated image outside the data
fn get_default_interpolation_value(&self, channel: usize) -> PyResult<PyDefaultValue> {
let channel_name = self.view.channel_name(channel)?.to_string();
Ok(PyDefaultValue {
inner: self
.view
.transforms
.default_value
.get(&channel_name)
.cloned()
.unwrap_or_default(),
})
}
/// the value of the interpolated image outside the data
fn set_default_interpolation_value(
&mut self,
channel: usize,
#[gen_stub(override_type(type_repr="float | transforms.DefaultValue | collections.abc.Sequence[float]", imports=("collections.abc")))]
value: Bound<PyAny>,
) -> PyResult<()> {
let channel_name = self.view.channel_name(channel)?.to_string();
if let Ok(value) = value.extract::<PyDefaultValue>() {
self.view
.transforms
.default_value
.insert(channel_name, value.inner);
} else if let Ok(value) = value.extract::<f64>() {
self.view
.transforms
.default_value
.insert(channel_name, value.into());
} else if let Ok(value) = value.try_iter() {
self.view.transforms.default_value.insert(
channel_name,
value
.map(|i| i?.extract::<f64>())
.collect::<PyResult<Vec<_>>>()?
.into(),
);
} else {
return Err(PyValueError::new_err(format!(
"cannot parse value: {:?} into DefaultValue",
value
)));
}
Ok(())
}
/// the value of the interpolated image outside the data
fn with_default_interpolation_value(
&self,
channel: usize,
#[gen_stub(override_type(type_repr="float | transforms.DefaultValue | collections.abc.Sequence[float]", imports=("collections.abc")))]
value: Bound<PyAny>,
) -> PyResult<Self> {
let mut new = self.clone();
new.set_default_interpolation_value(channel, value)?;
Ok(new)
}
/// return a new view with transformations applied (channel alignment, drift correction)
/// backward compatibility
#[cfg(feature = "transforms")]
#[pyo3(signature = (channels = true, drift = false, file = None, bead_files = None, main_channel = None, default_transform = None))]
fn with_transform(
&self,
channels: bool,
drift: bool,
file: Option<PathBuf>,
bead_files: Option<Vec<PathBuf>>,
main_channel: Option<usize>,
default_transform: Option<Vec<f64>>,
) -> PyResult<PyView> {
self.with_transforms(
channels,
drift,
file,
bead_files,
main_channel,
default_transform,
)
}
/// returns a view where channels and/or frames are registered with an affine transformation
// channels: True/False register channels using bead_files
// drift: True/False register frames to correct drift
// file: load registration from file with name file, default: transform.yml in self.path.parent
// bead_files: files used to register channels, default: files in self.path.parent,
// with names starting with 'beads'
// main channel: which channel to transform with default_transform
// default_transform: parameters of the default transform: [a, b, c, d, e, f] where the affine transform matrix is
// | a b e |
// | c d f |
// | 0 0 1 |
#[cfg(feature = "transforms")]
#[pyo3(signature = (channels = true, drift = false, file = None, bead_files = None, main_channel = None, default_transform = None))]
fn with_transforms(
&self,
channels: bool,
drift: bool,
file: Option<PathBuf>,
bead_files: Option<Vec<PathBuf>>,
main_channel: Option<usize>,
default_transform: Option<Vec<f64>>,
) -> PyResult<PyView> {
let mut new = self.clone();
let bead_files = bead_files
.as_ref()
.map(|i| i.iter().map(|j| j.as_path()).collect::<Vec<_>>());
new.view.transforms = Transforms::calculate_transforms_from(
file.as_deref(),
if channels {
bead_files.as_deref()
} else {
None
},
if drift { Some(self.view.path()) } else { None },
main_channel,
default_transform,
)?;
Ok(new)
}
/// get the transformation
#[getter]
fn get_transforms(&self) -> PyResult<PyTransforms> {
Ok(PyTransforms {
inner: self.view.get_transforms().clone(),
})
}
/// set the transformation
#[setter]
fn set_transforms(&mut self, transform: PyTransforms) {
self.view.set_transforms(transform.inner);
}
fn load_transforms_from_yaml(&mut self, path: PathBuf) -> PyResult<()> {
self.view.load_transforms_from_yaml(path.as_path())?;
Ok(())
}
fn calculate_channel_transforms_2d(
&self,
py: Python,
main_channel: usize,
) -> PyResult<HashMap<String, PyTransform>> {
py.detach(|| {
Ok(self
.view
.calculate_channel_transforms_2d(main_channel)?
.into_iter()
.map(|(n, t)| {
(
n,
PyTransform {
inner: t.into_dyn(),
},
)
})
.collect())
})
}
fn calculate_channel_transforms_3d(
&self,
py: Python,
main_channel: usize,
) -> PyResult<HashMap<String, PyTransform>> {
py.detach(|| {
Ok(self
.view
.calculate_channel_transforms_3d(main_channel)?
.into_iter()
.map(|(n, t)| {
(
n,
PyTransform {
inner: t.into_dyn(),
},
)
})
.collect())
})
}
fn calculate_drift_transforms_2d(&self, py: Python) -> PyResult<Vec<PyTransform>> {
py.detach(|| {
Ok(self
.view
.calculate_drift_transforms_2d()?
.into_iter()
.map(|t| PyTransform {
inner: t.into_dyn(),
})
.collect())
})
}
fn calculate_drift_transforms_3d(&self, py: Python) -> PyResult<Vec<PyTransform>> {
py.detach(|| {
Ok(self
.view
.calculate_drift_transforms_3d()?
.into_iter()
.map(|t| PyTransform {
inner: t.into_dyn(),
})
.collect())
})
}
}
#[gen_stub_pymethods] #[gen_stub_pymethods]
#[pymethods] #[pymethods]
impl PyTransforms { impl PyTransforms {
#[new] #[new]
#[pyo3(signature = (channel = HashMap::new(), drift = Vec::new()))] #[pyo3(signature = (channel = HashMap::new(), drift = Vec::new(), interpolation_order = None, default_value = None))]
fn new( fn new(
channel: HashMap<String, PyTransform>, channel: HashMap<String, PyTransform>,
drift: Vec<PyTransform>, drift: Vec<PyTransform>,
interpolation_order: Option<usize>,
default_value: Option<HashMap<String, PyDefaultValue>>,
) -> PyResult<PyTransforms> { ) -> PyResult<PyTransforms> {
let channel = channel.into_iter().map(|(n, t)| (n, t.inner)).collect(); let channel = channel.into_iter().map(|(n, t)| (n, t.inner)).collect();
let drift = drift.into_iter().map(|t| t.inner).collect(); let drift = drift.into_iter().map(|t| t.inner).collect();
Ok(PyTransforms { Ok(PyTransforms {
inner: Transforms { channel, drift }, inner: Transforms {
default_value: default_value
.into_iter()
.flatten()
.map(|(k, v)| (k, v.inner))
.collect::<HashMap<_, _>>(),
interpolation_order,
channel,
drift,
},
}) })
} }
#[allow(clippy::type_complexity)]
fn __getnewargs__(
&self,
) -> (
HashMap<String, PyTransform>,
Vec<PyTransform>,
Option<usize>,
HashMap<String, PyDefaultValue>,
) {
(
self.inner
.channel
.iter()
.map(|(k, v)| (k.to_string(), PyTransform { inner: v.clone() }))
.collect(),
self.inner
.drift
.iter()
.map(|i| PyTransform { inner: i.clone() })
.collect(),
self.inner.interpolation_order,
self.inner
.default_value
.iter()
.map(|(k, v)| (k.to_string(), PyDefaultValue { inner: v.clone() }))
.collect(),
)
}
/// the bspline order used for interpolation when using transforms,
/// None -> 0 (nearest neighbor) for int dtypes, 3 (cubic) for float dtypes
#[getter]
fn get_interpolation_order(&self) -> Option<usize> {
self.inner.interpolation_order
}
/// the bspline order used for interpolation when using transforms
/// None -> 0 (nearest neighbor) for int dtypes, 3 (cubic) for float dtypes
#[setter]
fn set_interpolation_order(&mut self, order: Option<usize>) {
self.inner.interpolation_order = order;
}
/// the bspline order used for interpolation when using transforms
/// None -> 0 (nearest neighbor) for int dtypes, 3 (cubic) for float dtypes
fn with_interpolation_order(&self, order: Option<usize>) -> Self {
let mut new = self.clone();
new.inner.interpolation_order = order;
new
}
/// the value of the interpolated image outside the data
fn get_default_interpolation_value(&self, channel_name: &str) -> PyResult<PyDefaultValue> {
Ok(PyDefaultValue {
inner: self
.inner
.default_value
.get(channel_name)
.cloned()
.unwrap_or_default(),
})
}
/// the value of the interpolated image outside the data
fn set_default_interpolation_value(
&mut self,
channel_name: String,
#[gen_stub(override_type(type_repr="float | DefaultValue | collections.abc.Sequence[float]", imports=("collections.abc")))]
value: Bound<PyAny>,
) -> PyResult<()> {
if let Ok(value) = value.extract::<PyDefaultValue>() {
self.inner.default_value.insert(channel_name, value.inner);
} else if let Ok(value) = value.extract::<f64>() {
self.inner.default_value.insert(channel_name, value.into());
} else if let Ok(value) = value.try_iter() {
self.inner.default_value.insert(
channel_name,
value
.map(|i| i?.extract::<f64>())
.collect::<PyResult<Vec<_>>>()?
.into(),
);
} else {
return Err(PyValueError::new_err(format!(
"cannot parse value: {:?} into DefaultValue",
value
)));
}
Ok(())
}
/// the value of the interpolated image outside the data
fn with_default_interpolation_value(
&self,
channel_name: String,
#[gen_stub(override_type(type_repr="float | DefaultValue | collections.abc.Sequence[float]", imports=("collections.abc")))]
value: Bound<PyAny>,
) -> PyResult<Self> {
let mut new = self.clone();
new.set_default_interpolation_value(channel_name, value)?;
Ok(new)
}
#[getter] #[getter]
fn get_channel_transforms(&self) -> HashMap<String, PyTransform> { fn get_channel_transforms(&self) -> HashMap<String, PyTransform> {
self.inner self.inner
@@ -659,10 +1163,6 @@ impl PyTransforms {
Ok(()) Ok(())
} }
fn __getnewargs__(&self) -> (HashMap<String, PyTransform>, Vec<PyTransform>) {
(self.get_channel_transforms(), self.get_drift_transforms())
}
#[staticmethod] #[staticmethod]
pub(crate) fn load(path: PathBuf) -> PyResult<PyTransforms> { pub(crate) fn load(path: PathBuf) -> PyResult<PyTransforms> {
Ok(PyTransforms { Ok(PyTransforms {
+54 -19
View File
@@ -2,7 +2,7 @@ use crate::axes::{Axis, Shape};
use crate::error::Error; use crate::error::Error;
use crate::view::View; use crate::view::View;
use ndarray::{Array, Dimension, Ix2, Ix5, s}; use ndarray::{Array, Dimension, Ix2, Ix5, s};
use num::{FromPrimitive, Zero}; use num::{FromPrimitive, One, Zero};
use ome_metadata::Ome; use ome_metadata::Ome;
use regex::Regex; use regex::Regex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -128,7 +128,7 @@ pub enum PixelType {
I128, I128,
/// unsigned 128-bit integer /// unsigned 128-bit integer
U128, U128,
/// 128-bit float (emulated) /// 128-bit float (not supported)
F128, F128,
} }
@@ -148,6 +148,8 @@ impl PixelType {
/// array data with a specific pixel type /// array data with a specific pixel type
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum ArrayT<D: Dimension> { pub enum ArrayT<D: Dimension> {
/// boolean array
Bool(Array<bool, D>),
/// signed 8-bit integer array /// signed 8-bit integer array
I8(Array<i8, D>), I8(Array<i8, D>),
/// unsigned 8-bit integer array /// unsigned 8-bit integer array
@@ -168,24 +170,28 @@ pub enum ArrayT<D: Dimension> {
I64(Array<i64, D>), I64(Array<i64, D>),
/// unsigned 64-bit integer array /// unsigned 64-bit integer array
U64(Array<u64, D>), U64(Array<u64, D>),
/// signed 128-bit integer array /// signed 128-bit integer array (not supported by numpy)
I128(Array<i128, D>), I128(Array<i128, D>),
/// unsigned 128-bit integer array /// unsigned 128-bit integer array (not supported by numpy)
U128(Array<u128, D>), U128(Array<u128, D>),
/// 128-bit float array (emulated as f64)
F128(Array<f64, D>),
} }
/// type alias for a single frame (2D image) /// type alias for a single frame (2D image)
pub type Frame = ArrayT<Ix2>; pub type Frame = ArrayT<Ix2>;
/// trait for reading image files /// trait for reading image files
pub trait Reader: Clone + Sized + Debug + Send + Hash + Into<DynReader> { pub trait Reader: Clone + Sized + Debug + Sync + Send + Hash + Into<DynReader> {
/// create a new reader for the given path, series, and position /// create a new reader for the given path, series, and position
fn new<P>(path: P, series: usize, position: usize) -> Result<Self, Error> fn new<P>(path: P, series: usize, position: usize) -> Result<Self, Error>
where where
P: AsRef<Path>; P: AsRef<Path>;
/// a simple fast test to see if the reader can open a path without parsing the whole file
/// not a guarantee of success, but rather an early failure
fn can_open<P>(path: P) -> Result<bool, Error>
where
P: AsRef<Path>;
/// the name of the reader type /// the name of the reader type
fn reader_name(&self) -> &'static str { fn reader_name(&self) -> &'static str {
type_name::<Self>() type_name::<Self>()
@@ -197,9 +203,9 @@ pub trait Reader: Clone + Sized + Debug + Send + Hash + Into<DynReader> {
/// get a sliceable view on the image file /// get a sliceable view on the image file
fn view(&self) -> View<Ix5, Self> { fn view(&self) -> View<Ix5, Self> {
let shape = self.shape(); let shape = self.raw_shape();
let slice = s![0..shape.c, 0..shape.z, 0..shape.t, 0..shape.y, 0..shape.x,]; let slice = s![0..shape.c, 0..shape.z, 0..shape.t, 0..shape.y, 0..shape.x,];
View::new( View::new_with_slice_and_axes(
self.clone(), self.clone(),
slice.as_ref().to_vec(), slice.as_ref().to_vec(),
vec![Axis::C, Axis::Z, Axis::T, Axis::Y, Axis::X], vec![Axis::C, Axis::Z, Axis::T, Axis::Y, Axis::X],
@@ -216,7 +222,7 @@ pub trait Reader: Clone + Sized + Debug + Send + Hash + Into<DynReader> {
/// the position index /// the position index
fn position(&self) -> usize; fn position(&self) -> usize;
/// the shape of the image data /// the shape of the image data
fn shape(&self) -> &Shape; fn raw_shape(&self) -> &Shape;
/// the pixel type of the image data /// the pixel type of the image data
fn pixel_type(&self) -> &PixelType; fn pixel_type(&self) -> &PixelType;
/// get all available positions for a given series /// get all available positions for a given series
@@ -314,6 +320,7 @@ macro_rules! try_into_array_body {
($self:expr, $map:ident) => {{ ($self:expr, $map:ident) => {{
let mut err = Ok(()); let mut err = Ok(());
let arr = match $self { let arr = match $self {
ArrayT::Bool(v) => v.$map(|x| if x { T::one() } else { T::zero() }),
ArrayT::I8(v) => v.$map(|x| { ArrayT::I8(v) => v.$map(|x| {
T::from_i8(x).unwrap_or_else(|| { T::from_i8(x).unwrap_or_else(|| {
err = Err(Error::Cast(x.to_string(), type_name::<T>().to_string())); err = Err(Error::Cast(x.to_string(), type_name::<T>().to_string()));
@@ -356,7 +363,7 @@ macro_rules! try_into_array_body {
T::zero() T::zero()
}) })
}), }),
ArrayT::F64(v) | ArrayT::F128(v) => v.$map(|x| { ArrayT::F64(v) => v.$map(|x| {
T::from_f64(x).unwrap_or_else(|| { T::from_f64(x).unwrap_or_else(|| {
err = Err(Error::Cast(x.to_string(), type_name::<T>().to_string())); err = Err(Error::Cast(x.to_string(), type_name::<T>().to_string()));
T::zero() T::zero()
@@ -397,7 +404,7 @@ macro_rules! try_into_array_body {
impl<D, T> TryInto<Array<T, D>> for ArrayT<D> impl<D, T> TryInto<Array<T, D>> for ArrayT<D>
where where
D: Dimension, D: Dimension,
T: FromPrimitive + Zero + 'static, T: FromPrimitive + Zero + One + 'static,
{ {
type Error = Error; type Error = Error;
@@ -409,7 +416,7 @@ where
impl<D, T> TryInto<Array<T, D>> for &ArrayT<D> impl<D, T> TryInto<Array<T, D>> for &ArrayT<D>
where where
D: Dimension, D: Dimension,
T: Clone + FromPrimitive + Zero + 'static, T: Clone + FromPrimitive + Zero + One + 'static,
{ {
type Error = Error; type Error = Error;
@@ -475,6 +482,34 @@ impl Reader for DynReader {
)) ))
} }
fn can_open<P>(path: P) -> Result<bool, Error>
where
P: AsRef<Path>,
{
let mut can_open = false;
#[cfg(feature = "tiff")]
{
can_open |= tiff::TiffReader::can_open(&path)?;
}
#[cfg(feature = "tiffseq")]
{
can_open |= tiffseq::TiffSeqReader::can_open(&path)?;
}
#[cfg(feature = "czi")]
{
can_open |= czi::CziReader::can_open(&path)?;
}
#[cfg(feature = "bioformats_rust")]
{
can_open |= bioformats_rust::BioFormatsRustReader::can_open(&path)?;
}
#[cfg(feature = "bioformats_java")]
{
can_open |= bioformats_java::BioFormatsJavaReader::can_open(&path)?;
}
Ok(can_open)
}
fn reader_name(&self) -> &'static str { fn reader_name(&self) -> &'static str {
match self { match self {
#[cfg(feature = "tiff")] #[cfg(feature = "tiff")]
@@ -577,18 +612,18 @@ impl Reader for DynReader {
} }
} }
fn shape(&self) -> &Shape { fn raw_shape(&self) -> &Shape {
match self { match self {
#[cfg(feature = "tiff")] #[cfg(feature = "tiff")]
DynReader::Tiff(r) => r.shape(), DynReader::Tiff(r) => r.raw_shape(),
#[cfg(feature = "tiffseq")] #[cfg(feature = "tiffseq")]
DynReader::TiffSeq(r) => r.shape(), DynReader::TiffSeq(r) => r.raw_shape(),
#[cfg(feature = "czi")] #[cfg(feature = "czi")]
DynReader::Czi(r) => r.shape(), DynReader::Czi(r) => r.raw_shape(),
#[cfg(feature = "bioformats_rust")] #[cfg(feature = "bioformats_rust")]
DynReader::BioFormatsRust(r) => r.shape(), DynReader::BioFormatsRust(r) => r.raw_shape(),
#[cfg(feature = "bioformats_java")] #[cfg(feature = "bioformats_java")]
DynReader::BioFormatsJava(r) => r.shape(), DynReader::BioFormatsJava(r) => r.raw_shape(),
#[expect(unreachable_patterns)] #[expect(unreachable_patterns)]
_ => unreachable!(), _ => unreachable!(),
} }
@@ -14,6 +14,7 @@ use std::hash::{Hash, Hasher};
use std::ops::Deref; use std::ops::Deref;
use std::rc::Rc; use std::rc::Rc;
use std::sync::Mutex; use std::sync::Mutex;
use std::sync::atomic::{AtomicU32, Ordering};
use thread_local::ThreadLocal; use thread_local::ThreadLocal;
include!(concat!(env!("OUT_DIR"), "/constants.rs")); include!(concat!(env!("OUT_DIR"), "/constants.rs"));
@@ -24,10 +25,33 @@ thread_local! {
static DOWNLOAD_LOCK: Mutex<()> = Mutex::new(()); static DOWNLOAD_LOCK: Mutex<()> = Mutex::new(());
static JVM_BUILT: Mutex<bool> = Mutex::new(false); static JVM_BUILT: Mutex<bool> = Mutex::new(false);
/// the process that called [JvmBuilder::build], 0 if no jvm has been built yet
static JVM_PID: AtomicU32 = AtomicU32::new(0);
/// Returns `Ok(())` if the jvm in this process can still be used, and an error if it was inherited
/// across a fork.
///
/// A fork copies the address space, so the child gets the parent's jvm wholesale: its threads do
/// not exist and any lock another thread held at the moment of the fork stays held forever. There
/// is no way to repair that, and no way to start a replacement either, because HotSpot's
/// "a jvm already exists" flag lives in libjvm's private data and is copied along with everything
/// else -- verified by `JNI_GetCreatedJavaVMs`, which still reports 1 in the child, so a second
/// `JNI_CreateJavaVM` (which is what j4rs' `Jvm::attach_thread` calls) takes the already-exists
/// path. An `exec` replaces the address space, and that does reset the count to 0.
pub fn check_not_forked() -> Result<(), Error> {
let built_in = JVM_PID.load(Ordering::Relaxed);
if built_in != 0 && built_in != std::process::id() {
return Err(Error::ForkedJvm(built_in, std::process::id()));
}
Ok(())
}
/// Ensure 1 jvm per thread /// Ensure 1 jvm per thread
fn jvm() -> Rc<Jvm> { fn jvm() -> Result<Rc<Jvm>, Error> {
JVM.with(|cell| { // before touching JVM: in a forked child that thread's cell may already hold an Rc<Jvm> whose
// vm is gone, and the guard in check_not_forked is the only thing that can catch that.
check_not_forked()?;
Ok(JVM.with(|cell| {
cell.get_or_init(move || { cell.get_or_init(move || {
#[cfg(feature = "python")] #[cfg(feature = "python")]
let path = crate::py::ndbioimage_file(); let path = crate::py::ndbioimage_file();
@@ -113,6 +137,7 @@ fn jvm() -> Rc<Jvm> {
.java_opt(JavaOpt::new("--sun-misc-unsafe-memory-access=allow")) .java_opt(JavaOpt::new("--sun-misc-unsafe-memory-access=allow"))
.build() .build()
.expect("Failed to build JVM"); .expect("Failed to build JVM");
JVM_PID.store(std::process::id(), Ordering::Relaxed);
if let Ok(e) = InvocationArg::try_from("ERROR") { if let Ok(e) = InvocationArg::try_from("ERROR") {
let _ = j.invoke_static( let _ = j.invoke_static(
"loci.common.DebugTools", "loci.common.DebugTools",
@@ -125,9 +150,103 @@ fn jvm() -> Rc<Jvm> {
} }
}) })
.clone() .clone()
}) }))
} }
/// Returns `true` if the jvm was already created on the current thread.
///
/// The jvm is created lazily, on the first bio-formats read, and HotSpot installs its own signal
/// handlers while starting up. So this tells whether those handlers are in place yet.
///
/// This is process-wide, not thread-local: a forked child inherited the parent's jvm but cannot
/// use it, so reporting `true` there would only invite the deadlock that [check_not_forked] turns
/// into an error.
pub fn jvm_is_running() -> bool {
JVM_PID.load(Ordering::Relaxed) == std::process::id() && JVM.with(|cell| cell.get().is_some())
}
/// Creates the jvm on the current thread if it does not exist yet (no-op if it already does),
/// and initializes bio-formats on it.
///
/// This exists because of how HotSpot probes the cpu features during VM init: on an avx-512
/// capable cpu it deliberately reads through a NULL pointer (`VM_Version::_cpuinfo_segv_addr`, set
/// to the address of the generated cpu-feature stub) and resumes from its own SIGSEGV handler after
/// recognizing that address in `PosixSignals::pd_hotspot_signal_handler`.
///
/// Any SIGSEGV handler that was installed *before* the VM started is not that handler, and
/// HotSpot chains to it instead. Then the deliberate fault is not recognized, the chained handler
/// re-raises SIGSEGV, and the re-entrant pass no longer has a recognizable pc and ends in
/// `VMError::report_and_die` -> `os::die()` -> `abort()`.
///
/// In a python process this bites when `ray.init()` has already installed CPython's faulthandler
/// (ray/_private/worker.py), so call this before that.
///
/// Note that creating the VM is *not* enough: it has to be initialized far enough that the
/// deliberate fault has been taken before the foreign handler shows up. Measured on an AMD EPYC
/// 9124 (avx-512, XCR0 = 0x2e7) with ray: merely creating the VM leaves the crash rate unchanged,
/// and so does loading the reader classes without opening a file. What does work is initializing a
/// reader on an actual (tiny) image, so that is what this does.
pub fn warm_jvm() -> Result<(), Error> {
let jvm = jvm()?;
let no_args: &[InvocationArg] = &[];
// jvm() only *creates* the VM; run one static method so that this only returns Ok once the VM
// can actually run java bytecode.
let version = jvm.invoke_static(
"java.lang.System",
"getProperty",
&[InvocationArg::try_from("java.version")?],
)?;
let _: String = jvm.to_rust(version)?;
// loads (and runs the static initializers of) every reader class in the registry
let _ = jvm.invoke_static(
"loci.formats.ImageReader",
"getDefaultReaderClasses",
no_args,
)?;
// then open a 1x1 image so that the reader and its OMEXML metadata store get initialized.
// ImageReader.setId needs a real file, so write the tiff out and clean it up again.
let path = std::env::temp_dir().join(format!("ndbioimage-warm-jvm-{}.tif", std::process::id()));
std::fs::write(&path, WARM_TIFF)?;
let result = (|| -> Result<(), Error> {
let reader = jvm.create_instance("loci.formats.ImageReader", no_args)?;
jvm.invoke(
&reader,
"setId",
&[InvocationArg::try_from(path.to_string_lossy().as_ref())?],
)?;
let _: i32 = jvm.to_rust(jvm.invoke(&reader, "getSizeY", no_args)?)?;
let _ = jvm.invoke(&reader, "getMetadataStore", no_args)?;
Ok(())
})();
let _ = std::fs::remove_file(&path);
result
}
/// A 1x1 8-bit grayscale tiff (written by tifffile), used by [warm_jvm] to give bio-formats
/// something it can open.
const WARM_TIFF: &[u8] = &[
0x49, 0x49, 0x2a, 0x00, 0x08, 0x00, 0x00, 0x00, 0x0e, 0x00, 0x00, 0x01, 0x04, 0x00, 0x01, 0x00,
0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x01, 0x04, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00,
0x00, 0x00, 0x02, 0x01, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x03, 0x01,
0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x06, 0x01, 0x03, 0x00, 0x01, 0x00,
0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x0e, 0x01, 0x02, 0x00, 0x12, 0x00, 0x00, 0x00, 0xb6, 0x00,
0x00, 0x00, 0x11, 0x01, 0x04, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x15, 0x01,
0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x16, 0x01, 0x04, 0x00, 0x01, 0x00,
0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x17, 0x01, 0x04, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00,
0x00, 0x00, 0x1a, 0x01, 0x05, 0x00, 0x01, 0x00, 0x00, 0x00, 0xd8, 0x00, 0x00, 0x00, 0x1b, 0x01,
0x05, 0x00, 0x01, 0x00, 0x00, 0x00, 0xe0, 0x00, 0x00, 0x00, 0x28, 0x01, 0x03, 0x00, 0x01, 0x00,
0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x31, 0x01, 0x02, 0x00, 0x0c, 0x00, 0x00, 0x00, 0xe8, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7b, 0x22, 0x73, 0x68, 0x61, 0x70, 0x65, 0x22, 0x3a,
0x20, 0x5b, 0x31, 0x2c, 0x20, 0x31, 0x5d, 0x7d, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00,
0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00,
0x00, 0x00, 0x00, 0x74, 0x69, 0x66, 0x66, 0x66, 0x69, 0x6c, 0x65, 0x2e, 0x70, 0x79, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00,
];
macro_rules! method_return { macro_rules! method_return {
($R:ty$(|c)?) => { Result<$R, Error> }; ($R:ty$(|c)?) => { Result<$R, Error> };
() => { Result<(), Error> }; () => { Result<(), Error> };
@@ -147,14 +266,14 @@ macro_rules! method {
#[allow(dead_code)] #[allow(dead_code)]
pub(crate) fn $name(&self, $($($n: $t),*)?) -> method_return!($($tt)?) { pub(crate) fn $name(&self, $($($n: $t),*)?) -> method_return!($($tt)?) {
let args: Vec<InvocationArg> = vec![$($( method_arg!($n:$t$(|$p)?) ),*)?]; let args: Vec<InvocationArg> = vec![$($( method_arg!($n:$t$(|$p)?) ),*)?];
let _result = jvm().invoke(&self.0, $method, &args)?; let _result = jvm()?.invoke(&self.0, $method, &args)?;
macro_rules! method_result { macro_rules! method_result {
($R:ty|c) => { ($R:ty|c) => {
Ok(jvm().to_rust(_result)?) Ok(jvm()?.to_rust(_result)?)
}; };
($R:ty|d) => { ($R:ty|d) => {
Ok(jvm().to_rust_deserialized(_result)?) Ok(jvm()?.to_rust_deserialized(_result)?)
}; };
($R:ty) => { ($R:ty) => {
Ok(_result) Ok(_result)
@@ -187,7 +306,7 @@ pub struct DebugTools;
impl DebugTools { impl DebugTools {
/// set debug root level: ERROR, DEBUG, TRACE, INFO, OFF /// set debug root level: ERROR, DEBUG, TRACE, INFO, OFF
pub fn set_root_level(level: &str) -> Result<(), Error> { pub fn set_root_level(level: &str) -> Result<(), Error> {
jvm().invoke_static( jvm()?.invoke_static(
"loci.common.DebugTools", "loci.common.DebugTools",
"setRootLevel", "setRootLevel",
&[InvocationArg::try_from(level)?], &[InvocationArg::try_from(level)?],
@@ -201,7 +320,7 @@ pub(crate) struct ChannelSeparator(Instance);
impl ChannelSeparator { impl ChannelSeparator {
pub(crate) fn new(image_reader: &ImageReader) -> Result<Self, Error> { pub(crate) fn new(image_reader: &ImageReader) -> Result<Self, Error> {
let jvm = jvm(); let jvm = jvm()?;
let channel_separator = jvm.create_instance( let channel_separator = jvm.create_instance(
"loci.formats.ChannelSeparator", "loci.formats.ChannelSeparator",
&[InvocationArg::from(jvm.clone_instance(&image_reader.0)?)], &[InvocationArg::from(jvm.clone_instance(&image_reader.0)?)],
@@ -228,7 +347,7 @@ impl Drop for ImageReader {
impl ImageReader { impl ImageReader {
pub(crate) fn new() -> Result<Self, Error> { pub(crate) fn new() -> Result<Self, Error> {
let reader = jvm().create_instance("loci.formats.ImageReader", InvocationArg::empty())?; let reader = jvm()?.create_instance("loci.formats.ImageReader", InvocationArg::empty())?;
Ok(ImageReader(reader)) Ok(ImageReader(reader))
} }
@@ -238,7 +357,7 @@ impl ImageReader {
pub(crate) fn ome_xml(&self) -> Result<String, Error> { pub(crate) fn ome_xml(&self) -> Result<String, Error> {
let mds = self.get_metadata_store()?; let mds = self.get_metadata_store()?;
Ok(jvm() Ok(jvm()?
.chain(&mds)? .chain(&mds)?
.cast("loci.formats.ome.OMEPyramidStore")? .cast("loci.formats.ome.OMEPyramidStore")?
.invoke("dumpXML", InvocationArg::empty())? .invoke("dumpXML", InvocationArg::empty())?
@@ -276,7 +395,7 @@ pub(crate) struct MetadataTools(Instance);
impl MetadataTools { impl MetadataTools {
pub(crate) fn new() -> Result<Self, Error> { pub(crate) fn new() -> Result<Self, Error> {
let meta_data_tools = let meta_data_tools =
jvm().create_instance("loci.formats.MetadataTools", InvocationArg::empty())?; jvm()?.create_instance("loci.formats.MetadataTools", InvocationArg::empty())?;
Ok(MetadataTools(meta_data_tools)) Ok(MetadataTools(meta_data_tools))
} }
@@ -402,7 +521,7 @@ impl BioFormatsJavaReader {
PixelType::U64 => 8, PixelType::U64 => 8,
PixelType::I128 => 16, PixelType::I128 => 16,
PixelType::U128 => 16, PixelType::U128 => 16,
PixelType::F128 => 8, PixelType::F128 => 16,
}; };
Ok(bytes Ok(bytes
.chunks(chunk_size) .chunks(chunk_size)
@@ -468,7 +587,7 @@ impl BioFormatsJavaReader {
(PixelType::U64, true) => get_frame!(u64, <8), (PixelType::U64, true) => get_frame!(u64, <8),
(PixelType::I128, true) => get_frame!(i128, <16), (PixelType::I128, true) => get_frame!(i128, <16),
(PixelType::U128, true) => get_frame!(u128, <16), (PixelType::U128, true) => get_frame!(u128, <16),
(PixelType::F128, true) => get_frame!(f64, <8), (PixelType::F128, true) => Err(Error::F128NotSupported),
(PixelType::Bool, false) => get_frame!(bool, >1), (PixelType::Bool, false) => get_frame!(bool, >1),
(PixelType::I8, false) => get_frame!(i8, >1), (PixelType::I8, false) => get_frame!(i8, >1),
(PixelType::U8, false) => get_frame!(u8, >1), (PixelType::U8, false) => get_frame!(u8, >1),
@@ -482,7 +601,7 @@ impl BioFormatsJavaReader {
(PixelType::U64, false) => get_frame!(u64, >8), (PixelType::U64, false) => get_frame!(u64, >8),
(PixelType::I128, false) => get_frame!(i128, >16), (PixelType::I128, false) => get_frame!(i128, >16),
(PixelType::U128, false) => get_frame!(u128, >16), (PixelType::U128, false) => get_frame!(u128, >16),
(PixelType::F128, false) => get_frame!(f64, >8), (PixelType::F128, false) => Err(Error::F128NotSupported),
} }
} }
} }
@@ -517,6 +636,10 @@ impl Reader for BioFormatsJavaReader {
where where
P: AsRef<Path>, P: AsRef<Path>,
{ {
// checked here as well as in jvm() so that this entry point fails with the error rather
// than reaching get_size_x() below, which goes through deref() -> get_reader().unwrap()
// and would turn it into a panic.
check_not_forked()?;
let mut path = path.as_ref().to_path_buf(); let mut path = path.as_ref().to_path_buf();
if path.is_dir() { if path.is_dir() {
let orig = path.clone(); let orig = path.clone();
@@ -543,6 +666,13 @@ impl Reader for BioFormatsJavaReader {
Ok(new) Ok(new)
} }
fn can_open<P>(_path: P) -> Result<bool, Error>
where
P: AsRef<Path>,
{
Ok(true)
}
fn metadata(&self) -> Result<Ome, Error> { fn metadata(&self) -> Result<Ome, Error> {
self.get_ome() self.get_ome()
} }
@@ -575,7 +705,7 @@ impl Reader for BioFormatsJavaReader {
0 0
} }
fn shape(&self) -> &Shape { fn raw_shape(&self) -> &Shape {
&self.shape &self.shape
} }
@@ -648,6 +778,7 @@ mod tests {
metadata_g: "tiffseq/4-Pos_001_002/img_000000000_Cy3-Cy3_filter_000.tif", metadata_g: "tiffseq/4-Pos_001_002/img_000000000_Cy3-Cy3_filter_000.tif",
metadata_h: "tiffseq/20-Pos_005_005/img_000000000_Cy3-Cy3_filter_000.tif", metadata_h: "tiffseq/20-Pos_005_005/img_000000000_Cy3-Cy3_filter_000.tif",
metadata_i: "tiffseq/YTL1841B2-2-1_1hr_DMSO_galinduction_1", metadata_i: "tiffseq/YTL1841B2-2-1_1hr_DMSO_galinduction_1",
metadata_j: "tiff/R1_NRF1_Gtf3a500nM_REAL_20260731_25pct_500ms_001_MMStack_PosR1_NRF1_Gtf3a500nM_REAL_20260731_25pct_500ms_001.ome.tif",
} }
#[test] #[test]
@@ -140,7 +140,7 @@ impl BioFormatsRustReader {
PixelType::U64 => 8, PixelType::U64 => 8,
PixelType::I128 => 16, PixelType::I128 => 16,
PixelType::U128 => 16, PixelType::U128 => 16,
PixelType::F128 => 8, PixelType::F128 => 16,
}; };
Ok(bytes Ok(bytes
.chunks(chunk_size) .chunks(chunk_size)
@@ -206,7 +206,7 @@ impl BioFormatsRustReader {
(PixelType::U64, true) => get_frame!(u64, <8), (PixelType::U64, true) => get_frame!(u64, <8),
(PixelType::I128, true) => get_frame!(i128, <16), (PixelType::I128, true) => get_frame!(i128, <16),
(PixelType::U128, true) => get_frame!(u128, <16), (PixelType::U128, true) => get_frame!(u128, <16),
(PixelType::F128, true) => get_frame!(f64, <8), (PixelType::F128, true) => Err(Error::F128NotSupported),
(PixelType::Bool, false) => get_frame!(bool, >1), (PixelType::Bool, false) => get_frame!(bool, >1),
(PixelType::I8, false) => get_frame!(i8, >1), (PixelType::I8, false) => get_frame!(i8, >1),
(PixelType::U8, false) => get_frame!(u8, >1), (PixelType::U8, false) => get_frame!(u8, >1),
@@ -220,7 +220,7 @@ impl BioFormatsRustReader {
(PixelType::U64, false) => get_frame!(u64, >8), (PixelType::U64, false) => get_frame!(u64, >8),
(PixelType::I128, false) => get_frame!(i128, >16), (PixelType::I128, false) => get_frame!(i128, >16),
(PixelType::U128, false) => get_frame!(u128, >16), (PixelType::U128, false) => get_frame!(u128, >16),
(PixelType::F128, false) => get_frame!(f64, >8), (PixelType::F128, false) => Err(Error::F128NotSupported),
} }
} }
} }
@@ -274,6 +274,13 @@ impl Reader for BioFormatsRustReader {
Ok(new) Ok(new)
} }
fn can_open<P>(path: P) -> Result<bool, Error>
where
P: AsRef<Path>,
{
Ok(path.as_ref().extension() != Some(std::ffi::OsStr::new("czi")))
}
fn metadata(&self) -> Result<Ome, Error> { fn metadata(&self) -> Result<Ome, Error> {
let reader = self.get_reader()?; let reader = self.get_reader()?;
let metadata = reader.metadata(); let metadata = reader.metadata();
@@ -309,7 +316,7 @@ impl Reader for BioFormatsRustReader {
0 0
} }
fn shape(&self) -> &Shape { fn raw_shape(&self) -> &Shape {
&self.shape &self.shape
} }
@@ -122,14 +122,6 @@ impl From<Option<String>> for Version {
} }
} }
/// errors specific to czi file reading
#[derive(Debug, thiserror::Error)]
pub enum CziError {
/// czi file has no valid blocks
#[error("czi file has no valid blocks")]
NoValidBlocks,
}
impl Reader for CziReader { impl Reader for CziReader {
fn new<P>(path: P, series: usize, position: usize) -> Result<Self, Error> fn new<P>(path: P, series: usize, position: usize) -> Result<Self, Error>
where where
@@ -195,7 +187,7 @@ impl Reader for CziReader {
let mut max_y = i32::MIN; let mut max_y = i32::MIN;
for &i in Ok(block_map.get(&(0, 0, 0))) for &i in Ok(block_map.get(&(0, 0, 0)))
.transpose() .transpose()
.unwrap_or_else(|| block_map.values().next().ok_or(CziError::NoValidBlocks))? .unwrap_or_else(|| block_map.values().next().ok_or(Error::CziNoValidBlocks))?
{ {
if let Ok(info) = reader.try_get_sub_block_info_for_index(i) { if let Ok(info) = reader.try_get_sub_block_info_for_index(i) {
let rect = info.get_logical_rect(); let rect = info.get_logical_rect();
@@ -219,10 +211,17 @@ impl Reader for CziReader {
new.shape.x = (max_y - min_y) as usize; new.shape.x = (max_y - min_y) as usize;
new.shape.y = (max_x - min_x) as usize; new.shape.y = (max_x - min_x) as usize;
new.block_map = block_map; new.block_map = block_map;
new.pixel_type = pixel_type.ok_or(CziError::NoValidBlocks)?; new.pixel_type = pixel_type.ok_or(Error::CziNoValidBlocks)?;
Ok(new) Ok(new)
} }
fn can_open<P>(path: P) -> Result<bool, Error>
where
P: AsRef<Path>,
{
Ok(path.as_ref().extension() == Some(std::ffi::OsStr::new("czi")))
}
fn metadata(&self) -> Result<Ome, Error> { fn metadata(&self) -> Result<Ome, Error> {
let mut ome = Ome::default(); let mut ome = Ome::default();
let xml = xmltree::Element::parse(self.metadata_xml()?.as_bytes())?; let xml = xmltree::Element::parse(self.metadata_xml()?.as_bytes())?;
@@ -1133,7 +1132,7 @@ impl Reader for CziReader {
PixelType::U64 => get_frame!(u64, 8), PixelType::U64 => get_frame!(u64, 8),
PixelType::I128 => get_frame!(i128, 16), PixelType::I128 => get_frame!(i128, 16),
PixelType::U128 => get_frame!(u128, 16), PixelType::U128 => get_frame!(u128, 16),
PixelType::F128 => get_frame!(f64, 8), PixelType::F128 => Err(Error::F128NotSupported),
} }
} }
@@ -1149,7 +1148,7 @@ impl Reader for CziReader {
self.position self.position
} }
fn shape(&self) -> &Shape { fn raw_shape(&self) -> &Shape {
&self.shape &self.shape
} }
+585
View File
@@ -0,0 +1,585 @@
use crate::error::Error;
use crate::readers::{ArrayT, DynReader, Frame, PixelType, Reader, Shape};
use ndarray::Array2;
use ome_metadata::{Ome, ome};
use serde::{Deserialize, Serialize};
use std::cell::{RefCell, RefMut};
use std::collections::{HashMap, HashSet};
use std::fs::File;
use std::hash::{Hash, Hasher};
use std::io::{Read, Seek};
use std::path::{Path, PathBuf};
use thread_local::ThreadLocal;
use tiff::decoder::ifd::Value;
use tiff::decoder::{Decoder, DecodingSampleBuffer};
use tiff::tags::Tag;
/// reader for single tiff image files
#[derive(Debug, Serialize, Deserialize)]
pub struct TiffReader {
#[serde(skip)]
reader: ThreadLocal<RefCell<Decoder<File>>>,
path: PathBuf,
series: usize,
position: usize,
shape: Shape,
pixel_type: PixelType,
n_samples: usize,
p_ndim: u8,
planar: bool,
}
impl From<TiffReader> for DynReader {
fn from(value: TiffReader) -> Self {
DynReader::Tiff(value)
}
}
impl Hash for TiffReader {
fn hash<H: Hasher>(&self, state: &mut H) {
self.path.hash(state);
self.series.hash(state);
self.position.hash(state);
}
}
impl Clone for TiffReader {
fn clone(&self) -> Self {
TiffReader {
reader: ThreadLocal::default(),
path: self.path.clone(),
series: self.series,
position: self.position,
shape: self.shape.clone(),
pixel_type: self.pixel_type,
n_samples: self.n_samples,
p_ndim: self.p_ndim,
planar: self.planar,
}
}
}
impl PartialEq for TiffReader {
fn eq(&self, other: &Self) -> bool {
self.path == other.path && self.series == other.series && self.position == other.position
}
}
impl Eq for TiffReader {}
impl TiffReader {
fn get_reader(&self) -> Result<RefMut<'_, Decoder<File>>, Error> {
self.reader
.get_or_try(|| {
let mut decoder = Decoder::open(File::open(&self.path)?)?;
decoder.next_image()?;
Ok(RefCell::new(decoder))
})
.map(|i| i.borrow_mut())
}
}
fn pixel_type_from_bits_sample(bits: u16, fmt: u16) -> Result<PixelType, Error> {
match (fmt, bits) {
(1, 8) => Ok(PixelType::U8),
(1, 16) => Ok(PixelType::U16),
(1, 32) => Ok(PixelType::U32),
(2, 8) => Ok(PixelType::I8),
(2, 16) => Ok(PixelType::I16),
(2, 32) => Ok(PixelType::I32),
(3, 32) => Ok(PixelType::F32),
(3, 64) => Ok(PixelType::F64),
_ => Err(Error::Parse(format!(
"unsupported pixel type: SampleFormat={}, BitsPerSample={}",
fmt, bits
))),
}
}
fn parse_imagej_metadata(s: &str) -> HashMap<String, String> {
let mut map = HashMap::new();
for line in s.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(pos) = line.find('=') {
let key = line[..pos].trim().to_string();
let val = line[pos + 1..].trim().to_string();
map.insert(key, val);
}
}
map
}
impl Reader for TiffReader {
fn new<P>(path: P, series: usize, position: usize) -> Result<Self, Error>
where
P: AsRef<Path>,
{
let mut new = Self {
reader: ThreadLocal::default(),
path: path.as_ref().to_path_buf(),
series,
position,
shape: Shape::default(),
pixel_type: PixelType::U16,
n_samples: 0,
p_ndim: 0,
planar: true,
};
let mut reader = new.get_reader()?;
let width = reader.current_ifd().get_tag(Tag::ImageWidth)?.into_u32()? as usize;
let height = reader.current_ifd().get_tag(Tag::ImageLength)?.into_u32()? as usize;
let n_samples = reader
.current_ifd()
.get_tag(Tag::SamplesPerPixel)
.ok()
.and_then(|t| t.into_u16().ok())
.unwrap_or(1) as usize;
let bits = tag_first_u16(&mut reader, Tag::BitsPerSample).unwrap_or(8);
let sfmt = tag_first_u16(&mut reader, Tag::SampleFormat).unwrap_or(1);
// indexed color pages (PhotometricInterpretation::RGBPalette) are read as plain
// intensities: the pixel values are indices into the ColorMap palette, which is display
// information that ndbioimage does not use.
let pixtype = pixel_type_from_bits_sample(bits, sfmt)?;
let planar = reader
.current_ifd()
.get_tag(Tag::PlanarConfiguration)
.ok()
.and_then(|t| t.into_u16().ok())
.unwrap_or(1)
== 2;
let image_description = reader.current_ifd().get_tag(Tag::ImageDescription);
let p_ndim = if n_samples > 1 { 3u8 } else { 2u8 };
let (size_c, size_t, size_z) = if let Ok(Value::Ascii(xml)) = image_description
&& xml.starts_with("<?xml")
{
let ome = Ome::from_xml(xml)?;
(
ome.image[0].pixels.size_c as usize,
ome.image[0].pixels.size_t as usize,
ome.image[0].pixels.size_z as usize,
)
} else {
let imagej_bytes = reader
.current_ifd()
.get_tag(Tag::Unknown(50839))
.or_else(|_| reader.current_ifd().get_tag(Tag::ImageDescription))?;
let imagej_bytes = match imagej_bytes {
Value::Ascii(s) => s.into_bytes(),
other => other.into_u8_vec()?,
};
let metadata_str = String::from_utf8_lossy(&imagej_bytes);
let metadata_map = parse_imagej_metadata(&metadata_str);
let size_c = if p_ndim == 3 {
n_samples
} else {
metadata_map
.get("channels")
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(1)
};
let size_z = metadata_map
.get("slices")
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(1);
let size_t = metadata_map
.get("frames")
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(1);
let mult = if p_ndim == 3 { n_samples } else { 1 };
let expected = size_c * size_z * size_t / mult;
let mut n_ifds = 1;
while reader.more_images() {
reader.next_directory()?;
n_ifds += 1;
}
let size_z = if n_ifds != expected && n_ifds > 0 {
n_ifds / (size_c * size_t / mult).max(1)
} else {
size_z
};
(size_c, size_t, size_z)
};
drop(reader);
new.shape = Shape {
c: size_c,
z: size_z,
t: size_t,
y: height,
x: width,
..Default::default()
};
new.pixel_type = pixtype;
new.n_samples = n_samples;
new.p_ndim = p_ndim;
new.planar = planar;
// test reading first frame, if error another reader (bioformats) can take over
new.get_frame(0, 0, 0)?;
Ok(new)
}
fn can_open<P>(path: P) -> Result<bool, Error>
where
P: AsRef<Path>,
{
let path = path.as_ref();
let mut file = File::open(path)?;
let mut sig = [0; 2];
file.read_exact(&mut sig)?;
if sig == [73, 73] {
file.read_exact(&mut sig)?;
Ok((sig[0] == 42) || (sig[0] == 43))
} else if sig == [77, 77] {
Ok((sig[1] == 42) || (sig[1] == 43))
} else {
Ok(false)
}
}
fn metadata(&self) -> Result<Ome, Error> {
let mut reader = self.get_reader()?;
let image_description = reader.current_ifd().get_tag(Tag::ImageDescription);
if let Ok(Value::Ascii(xml)) = image_description
&& xml.starts_with("<?xml")
{
Ok(Ome::from_xml(xml)?)
} else {
let imagej_bytes = reader
.current_ifd()
.get_tag(Tag::Unknown(50839))
.or_else(|_| reader.current_ifd().get_tag(Tag::ImageDescription))?;
let imagej_bytes = match imagej_bytes {
Value::Ascii(s) => s.into_bytes(),
other => other.into_u8_vec()?,
};
let metadata_str = String::from_utf8_lossy(&imagej_bytes);
let metadata_map = parse_imagej_metadata(&metadata_str);
let interval_t = metadata_map
.get("interval")
.and_then(|v| v.parse::<f64>().ok());
let ru = reader
.current_ifd()
.get_tag(Tag::ResolutionUnit)
.ok()
.and_then(|i| i.into_u16().ok())
.unwrap_or(2);
let pixel_size = reader
.current_ifd()
.get_tag(Tag::XResolution)
.ok()
.and_then(|i| i.into_u64_vec().ok())
.and_then(|i| {
i.first()
.and_then(|&n| i.get(1).map(|&d| (n as f64) / (d as f64)))
})
.and_then(|i| {
if i <= 0.0 {
None
} else if ru == 3 {
Some(1e4 / i)
} else if ru == 2 {
Some(25400.0 / i)
} else {
Some(1.0 / i)
}
});
let interval_z = metadata_map
.get("spacing")
.and_then(|v| v.parse::<f64>().ok());
let ome_pixel_type = match self.pixel_type {
PixelType::I8 => ome::PixelType::Int8,
PixelType::U8 => ome::PixelType::Uint8,
PixelType::I16 => ome::PixelType::Int16,
PixelType::U16 => ome::PixelType::Uint16,
PixelType::I32 => ome::PixelType::Int32,
PixelType::U32 => ome::PixelType::Uint32,
PixelType::F32 => ome::PixelType::Float,
PixelType::F64 => ome::PixelType::Double,
_ => ome::PixelType::Uint16,
};
let mut pixels = ome::Pixels {
id: "Pixels:0".to_string(),
size_x: self.shape.x as i32,
size_y: self.shape.y as i32,
size_z: self.shape.z as i32,
size_c: self.shape.c as i32,
size_t: self.shape.t as i32,
dimension_order: ome::PixelsDimensionOrderType::Xyczt,
r#type: ome_pixel_type,
physical_size_x: pixel_size.map(|v| v as f32),
physical_size_x_unit: ome::UnitsLength::um,
physical_size_y: pixel_size.map(|v| v as f32),
physical_size_y_unit: ome::UnitsLength::um,
physical_size_z: interval_z.map(|v| v as f32),
physical_size_z_unit: ome::UnitsLength::um,
time_increment: interval_t.map(|v| v as f32),
time_increment_unit: ome::UnitsTime::s,
significant_bits: None,
interleaved: None,
big_endian: None,
channel: Vec::new(),
bin_data: Vec::new(),
tiff_data: Vec::new(),
metadata_only: None,
plane: Vec::new(),
};
for c in 0..self.shape.c {
pixels.channel.push(ome::Channel {
id: format!("Channel:{}", c),
name: Some(format!("Channel {}", c)),
..Default::default()
});
}
for c in 0..self.shape.c {
for z in 0..self.shape.z {
for t in 0..self.shape.t {
pixels.plane.push(ome::Plane {
the_c: Some(c as i32),
the_z: Some(z as i32),
the_t: Some(t as i32),
delta_t: interval_t.and_then(|v| {
if v > 0.0 {
Some((t as f64 * v) as f32)
} else {
None
}
}),
..Default::default()
});
}
}
}
let mut ome = Ome::default();
ome.image.push(ome::Image {
id: "Image:0".to_string(),
name: self
.path
.file_name()
.map(|n| n.to_string_lossy().to_string()),
pixels,
instrument_ref: None,
objective_settings: None,
acquisition_date: None,
description: None,
experimenter_ref: None,
experiment_ref: None,
experimenter_group_ref: None,
imaging_environment: None,
stage_label: None,
roi_ref: Vec::new(),
microbeam_manipulation_ref: Vec::new(),
annotation_ref: Vec::new(),
});
Ok(ome)
}
}
fn get_frame(&self, c: usize, z: usize, t: usize) -> Result<Frame, Error> {
let (page_idx, offset, stride) = if self.p_ndim == 3 {
(t * self.shape.z + z, c, self.n_samples)
} else {
(c + z * self.shape.c + t * self.shape.c * self.shape.z, 0, 1)
};
let mut reader = self.get_reader()?;
reader.seek_to_image(page_idx)?;
let data: DecodingSampleBuffer = if self.planar {
let mut result = DecodingSampleBuffer::U16(Vec::new());
reader.read_image_to_buffer(&mut result)?;
result
} else {
reader.read_image()?
};
macro_rules! to_frame {
($var:ident, $ty:ty) => {{
let v: Vec<$ty> = match data {
DecodingSampleBuffer::$var(v) => v,
_ => {
return Err(Error::Parse(format!(
"type mismatch: expected {}",
stringify!($var)
)));
}
};
let yx = self.shape.y * self.shape.x;
if self.planar {
let chan: Vec<$ty> = v.into_iter().skip(c * yx).take(yx).collect();
Ok(ArrayT::$var(Array2::from_shape_vec(
(self.shape.y, self.shape.x),
chan,
)?))
} else if stride > 1 {
let chan: Vec<$ty> = v
.into_iter()
.skip(offset)
.step_by(stride)
.take(yx)
.collect();
Ok(ArrayT::$var(Array2::from_shape_vec(
(self.shape.y, self.shape.x),
chan,
)?))
} else {
Ok(ArrayT::$var(Array2::from_shape_vec(
(self.shape.y, self.shape.x),
v,
)?))
}
}};
}
match self.pixel_type {
PixelType::U8 => to_frame!(U8, u8),
PixelType::U16 => to_frame!(U16, u16),
PixelType::U32 => to_frame!(U32, u32),
PixelType::U64 => to_frame!(U64, u64),
PixelType::I8 => to_frame!(I8, i8),
PixelType::I16 => to_frame!(I16, i16),
PixelType::I32 => to_frame!(I32, i32),
PixelType::I64 => to_frame!(I64, i64),
PixelType::F32 => to_frame!(F32, f32),
PixelType::F64 => to_frame!(F64, f64),
_ => Err(Error::NotImplemented(format!(
"unsupported TIFF pixel type: {:?}",
self.pixel_type
))),
}
}
fn path(&self) -> &Path {
&self.path
}
fn series(&self) -> usize {
self.series
}
fn position(&self) -> usize {
self.position
}
fn raw_shape(&self) -> &Shape {
&self.shape
}
fn pixel_type(&self) -> &PixelType {
&self.pixel_type
}
fn get_available_positions<P>(path: P, _series: usize) -> Result<HashSet<usize>, Error>
where
P: AsRef<Path>,
{
let path = path.as_ref();
if Self::can_open(path)? {
Ok(HashSet::from([0]))
} else {
Err(Error::InvalidReader(
"TiffReader".to_string(),
path.to_string_lossy().to_string(),
"it is not a tiff".to_string(),
))
}
}
fn get_available_series<P>(path: P) -> Result<HashSet<usize>, Error>
where
P: AsRef<Path>,
{
let path = path.as_ref();
if Self::can_open(path)? {
Ok(HashSet::from([0]))
} else {
Err(Error::InvalidReader(
"TiffReader".to_string(),
path.to_string_lossy().to_string(),
"it is not a tiff".to_string(),
))
}
}
}
fn tag_first_u16<R: Read + Seek>(reader: &mut RefMut<Decoder<R>>, tag: Tag) -> Option<u16> {
let val = reader.current_ifd().get_tag(tag).ok()?;
match val {
Value::Short(v) => Some(v),
Value::List(list) => list.first().and_then(|v| match v {
Value::Short(v) => Some(*v),
_ => None,
}),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use ndarray::s;
fn open(file: &str) -> Result<TiffReader, Error> {
let path = std::env::current_dir()?
.join("tests")
.join("files")
.join(file);
TiffReader::new(&path, 0, 0)
}
macro_rules! test_metadata {
($($name:ident: $file:expr $(,)?)*) => {
$(
#[test]
fn $name() -> Result<(), Error> {
let ts = open($file)?;
println!("{}", ts.view().squeeze()?.summary()?);
let view = ts.view();
let first_frame = view.slice_cztyx(s![0, 0, 0, .., ..])?;
let array = first_frame.as_array::<u16>()?;
println!("{:?}", array);
assert_eq!(ts.shape.y, array.shape()[0]);
assert_eq!(ts.shape.x, array.shape()[1]);
if view.shape.t > 1 {
let second_frame = view.slice_cztyx(s![0, 0, 1, .., ..])?;
let array = second_frame.as_array::<u16>()?;
println!("{:?}", array);
assert_eq!(ts.shape.y, array.shape()[0]);
assert_eq!(ts.shape.x, array.shape()[1]);
}
Ok(())
}
)*
};
}
test_metadata! {
metadata_a: "tiff/20251014_20-Pos_000_000_mask.tif",
metadata_b: "tiff/20251014_20-Pos_000_000_max.tif",
metadata_c: "tiff/20251014_20-Pos_000_000_loc_results_Cy3.tif",
metadata_e: "tiff/1xp53-01-AP1.tiff",
metadata_f: "tiff/test.tif",
metadata_g: "tiff/YTL1849A111_2023_05_04__14_46_19_cellnr_1_track.tif",
metadata_h: "tiff/Wapl-AID_C46_13_tagRFPt_MCP_well1_video1_1_max.tif",
metadata_i: "tiff/R1_NRF1_Gtf3a500nM_REAL_20260731_25pct_500ms_001_MMStack_PosR1_NRF1_Gtf3a500nM_REAL_20260731_25pct_500ms_001.ome.tif"
metadata_j: "tiff/20260922_Pos0_max.tif"
}
}
@@ -8,7 +8,7 @@ use std::collections::{HashMap, HashSet};
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::str::FromStr; use std::str::FromStr;
use tiff::decoder::{Decoder, DecodingResult}; use tiff::decoder::{Decoder, DecodingSampleBuffer};
use tiff::tags::Tag; use tiff::tags::Tag;
/// reader for sequences of tiff files (one file per z/time plane) /// reader for sequences of tiff files (one file per z/time plane)
@@ -84,9 +84,13 @@ impl TiffSeqReader {
fn read_tiff_dimensions(path: &Path) -> Result<(usize, usize), Error> { fn read_tiff_dimensions(path: &Path) -> Result<(usize, usize), Error> {
let file = std::fs::File::open(path)?; let file = std::fs::File::open(path)?;
let mut decoder = Decoder::new(file)?; let mut decoder = Decoder::open(file)?;
let width = decoder.get_tag(Tag::ImageWidth)?.into_u32()? as usize; decoder.next_image()?;
let height = decoder.get_tag(Tag::ImageLength)?.into_u32()? as usize; let width = decoder.current_ifd().get_tag(Tag::ImageWidth)?.into_u32()? as usize;
let height = decoder
.current_ifd()
.get_tag(Tag::ImageLength)?
.into_u32()? as usize;
Ok((width, height)) Ok((width, height))
} }
} }
@@ -235,6 +239,13 @@ impl Reader for TiffSeqReader {
}) })
} }
fn can_open<P>(path: P) -> Result<bool, Error>
where
P: AsRef<Path>,
{
Ok(path.as_ref().is_dir())
}
fn metadata(&self) -> Result<Ome, Error> { fn metadata(&self) -> Result<Ome, Error> {
let mut ome = Ome::default(); let mut ome = Ome::default();
let info = self.metadata_map.get("Info").and_then(|v| v.as_mapping()); let info = self.metadata_map.get("Info").and_then(|v| v.as_mapping());
@@ -514,38 +525,39 @@ impl Reader for TiffSeqReader {
.ok_or_else(|| Error::OutOfBounds(c.max(z).max(t) as isize, 0))?; .ok_or_else(|| Error::OutOfBounds(c.max(z).max(t) as isize, 0))?;
let rdr = std::fs::File::open(file)?; let rdr = std::fs::File::open(file)?;
let mut decoder = Decoder::new(rdr)?; let mut decoder = Decoder::open(rdr)?;
decoder.next_image()?;
match decoder.read_image()? { match decoder.read_image()? {
DecodingResult::U8(d) => Ok(ArrayT::U8(Array2::from_shape_vec( DecodingSampleBuffer::U8(d) => Ok(ArrayT::U8(Array2::from_shape_vec(
(self.shape.y, self.shape.x), (self.shape.y, self.shape.x),
d, d,
)?)), )?)),
DecodingResult::U16(d) => Ok(ArrayT::U16(Array2::from_shape_vec( DecodingSampleBuffer::U16(d) => Ok(ArrayT::U16(Array2::from_shape_vec(
(self.shape.y, self.shape.x), (self.shape.y, self.shape.x),
d, d,
)?)), )?)),
DecodingResult::U32(d) => Ok(ArrayT::U32(Array2::from_shape_vec( DecodingSampleBuffer::U32(d) => Ok(ArrayT::U32(Array2::from_shape_vec(
(self.shape.y, self.shape.x), (self.shape.y, self.shape.x),
d, d,
)?)), )?)),
DecodingResult::I8(d) => Ok(ArrayT::I8(Array2::from_shape_vec( DecodingSampleBuffer::I8(d) => Ok(ArrayT::I8(Array2::from_shape_vec(
(self.shape.y, self.shape.x), (self.shape.y, self.shape.x),
d, d,
)?)), )?)),
DecodingResult::I16(d) => Ok(ArrayT::I16(Array2::from_shape_vec( DecodingSampleBuffer::I16(d) => Ok(ArrayT::I16(Array2::from_shape_vec(
(self.shape.y, self.shape.x), (self.shape.y, self.shape.x),
d, d,
)?)), )?)),
DecodingResult::I32(d) => Ok(ArrayT::I32(Array2::from_shape_vec( DecodingSampleBuffer::I32(d) => Ok(ArrayT::I32(Array2::from_shape_vec(
(self.shape.y, self.shape.x), (self.shape.y, self.shape.x),
d, d,
)?)), )?)),
DecodingResult::F32(d) => Ok(ArrayT::F32(Array2::from_shape_vec( DecodingSampleBuffer::F32(d) => Ok(ArrayT::F32(Array2::from_shape_vec(
(self.shape.y, self.shape.x), (self.shape.y, self.shape.x),
d, d,
)?)), )?)),
DecodingResult::F64(d) => Ok(ArrayT::F64(Array2::from_shape_vec( DecodingSampleBuffer::F64(d) => Ok(ArrayT::F64(Array2::from_shape_vec(
(self.shape.y, self.shape.x), (self.shape.y, self.shape.x),
d, d,
)?)), )?)),
@@ -567,7 +579,7 @@ impl Reader for TiffSeqReader {
self.position self.position
} }
fn shape(&self) -> &Shape { fn raw_shape(&self) -> &Shape {
&self.shape &self.shape
} }
@@ -575,11 +587,20 @@ impl Reader for TiffSeqReader {
&self.pixel_type &self.pixel_type
} }
fn get_available_positions<P>(_path: P, _series: usize) -> Result<HashSet<usize>, Error> fn get_available_positions<P>(path: P, _series: usize) -> Result<HashSet<usize>, Error>
where where
P: AsRef<Path>, P: AsRef<Path>,
{ {
let path = path.as_ref();
if Self::can_open(path)? {
Ok(HashSet::from([0])) Ok(HashSet::from([0]))
} else {
Err(Error::InvalidReader(
"TiffSeqReader".to_string(),
path.to_string_lossy().to_string(),
"not a tiff sequence".to_string(),
))
}
} }
fn get_available_series<P>(path: P) -> Result<HashSet<usize>, Error> fn get_available_series<P>(path: P) -> Result<HashSet<usize>, Error>
File renamed without changes.
@@ -178,7 +178,7 @@ where
PixelType::U64 => self.save_as_tiff_with_type::<u64, P>(path, options)?, PixelType::U64 => self.save_as_tiff_with_type::<u64, P>(path, options)?,
PixelType::I128 => self.save_as_tiff_with_type::<i64, P>(path, options)?, PixelType::I128 => self.save_as_tiff_with_type::<i64, P>(path, options)?,
PixelType::U128 => self.save_as_tiff_with_type::<u64, P>(path, options)?, PixelType::U128 => self.save_as_tiff_with_type::<u64, P>(path, options)?,
PixelType::F128 => self.save_as_tiff_with_type::<f64, P>(path, options)?, PixelType::F128 => Err(Error::F128NotSupported)?,
} }
Ok(()) Ok(())
@@ -321,4 +321,44 @@ mod tests {
batch_to_tiff(&files_in, &files_out, None, None, true, true, None)?; batch_to_tiff(&files_in, &files_out, None, None, true, true, None)?;
Ok(()) Ok(())
} }
/// a rewritten file must not be served from the process-wide cache
#[cfg(feature = "tiff")]
#[test]
fn cache_invalidated_after_rewrite() -> Result<(), Error> {
use crate::view::View;
use ndarray::Array2;
use std::time::{Duration, SystemTime};
use tiffwrite::IJTiffFile;
let dir = std::env::temp_dir().join(format!("ndbioimage-rewrite-{}", std::process::id()));
create_dir_all(&dir)?;
let path = dir.join("cache_rewrite.tif");
let write = |value: u16| -> Result<(), Error> {
let mut tiff = IJTiffFile::new(&path)?;
tiff.save(Array2::<u16>::from_elem((4, 4), value), 0, 0, 0)?;
Ok(())
};
write(1)?;
let view: View<_, DynReader> = View::from_path(&path)?;
let frame: Array2<u16> = view.get_frame(0, 0, 0)?;
assert_eq!(frame[(0, 0)], 1);
write(2)?;
// make the modification time unambiguous even on filesystems with a
// coarse timestamp resolution
let file = std::fs::OpenOptions::new().write(true).open(&path)?;
file.set_modified(SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000_000))?;
drop(file);
let view: View<_, DynReader> = View::from_path(&path)?;
let frame: Array2<u16> = view.get_frame(0, 0, 0)?;
assert_eq!(frame[(0, 0)], 2);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir(&dir);
Ok(())
}
} }
+877
View File
@@ -0,0 +1,877 @@
use crate::axes::Axis;
use crate::error::Error;
use crate::readers::{DynReader, Frame, PixelType, Reader};
use crate::view::View;
use image_registration::{bspline::DefaultValue, filter, transform::Transform};
use ndarray::{Array2, Dimension, Ix2, Ix3, IxDyn, array, concatenate, s};
use rayon::iter::{IntoParallelIterator, ParallelIterator};
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use std::fmt::Debug;
use std::hash::{Hash, Hasher};
use std::io::Seek;
use std::path::Path;
use std::sync::Arc;
use tiffwrite::IJTiffFile;
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
struct TransformOld {
#[serde(alias = "CenterOfRotationPoint")]
center_of_rotation_point: Vec<f64>,
#[serde(alias = "Size")]
size: Vec<f64>,
#[serde(alias = "TransformParameters")]
transform_parameters: Vec<f64>,
#[serde(alias = "dTransformParameters")]
dtransform_parameters: Vec<f64>,
}
impl TryFrom<HashMap<String, TransformOld>> for Transforms {
type Error = Error;
fn try_from(t: HashMap<String, TransformOld>) -> Result<Self, Error> {
Ok(Self {
default_value: Default::default(),
interpolation_order: None,
channel: t
.into_iter()
.map(|(n, i)| {
let center = i
.size
.iter()
.zip(i.center_of_rotation_point.iter())
.map(|(s, c)| c - s / 2.0 + 0.5)
.collect::<Vec<_>>();
Ok((
n,
Transform::new_around_center(i.transform_parameters, &center)
.with_dparameters(i.dtransform_parameters)
.inverse()?,
))
})
.collect::<Result<_, Error>>()?,
drift: Vec::new(),
})
}
}
pub type DefaultValues = HashMap<String, DefaultValue>;
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize)]
pub struct Transforms {
#[serde(default)]
pub(crate) interpolation_order: Option<usize>,
#[serde(default)]
pub(crate) default_value: DefaultValues,
/// a transform for each channel by channel name
#[serde(default)]
pub channel: HashMap<String, Transform<IxDyn>>,
/// a transform for each time point
#[serde(default)]
pub drift: Vec<Transform<IxDyn>>,
}
impl Hash for Transforms {
fn hash<H: Hasher>(&self, state: &mut H) {
let mut keys: Vec<_> = self.channel.keys().collect();
keys.sort();
for key in keys {
key.hash(state);
self.channel[key].hash(state);
}
self.drift.hash(state);
}
}
impl PartialEq<Transforms> for &Transforms {
fn eq(&self, other: &Transforms) -> bool {
**self == *other
}
}
impl Serialize for Transforms {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
#[derive(Serialize)]
struct HumanReadable<'a> {
#[serde(skip_serializing_if = "HashMap::is_empty")]
channel: &'a HashMap<String, Transform<IxDyn>>,
#[serde(skip_serializing_if = "Vec::is_empty")]
drift: &'a Vec<Transform<IxDyn>>,
}
#[derive(Serialize)]
struct Binary<'a> {
interpolation_order: Option<usize>,
default_value: &'a DefaultValues,
channel: &'a HashMap<String, Transform<IxDyn>>,
drift: &'a Vec<Transform<IxDyn>>,
}
if serializer.is_human_readable() {
HumanReadable {
channel: &self.channel,
drift: &self.drift,
}
.serialize(serializer)
} else {
Binary {
interpolation_order: self.interpolation_order,
default_value: &self.default_value,
channel: &self.channel,
drift: &self.drift,
}
.serialize(serializer)
}
}
}
/// the kind of corrections the transforms are meant to correct for
#[derive(Clone, Debug, PartialEq, Copy)]
pub enum TransformKind {
/// no tranforms
None,
/// correct warp between channels, ex: camera/filter misalignment, chromatic aberrations
Channel,
/// drift in the miscrocope between time points
Drift,
/// both warp between channels and drift
Both,
}
impl Transforms {
/// the kind of corrections the transforms are meant to correct for
pub fn kind(&self) -> TransformKind {
match (self.channel.is_empty(), self.drift.is_empty()) {
(true, true) => TransformKind::None,
(false, true) => TransformKind::Channel,
(true, false) => TransformKind::Drift,
(false, false) => TransformKind::Both,
}
}
/// load the transforms from a yml file
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
let mut file = std::fs::File::open(&path)?;
Ok(
if let Ok(t) = yaml_serde::from_reader::<_, Self>(&file)
&& !(t.channel.is_empty() && t.drift.is_empty())
{
t
} else {
file.seek(std::io::SeekFrom::Start(0))?;
yaml_serde::from_reader::<_, HashMap<String, TransformOld>>(&file)?.try_into()?
},
)
}
/// save the transforms to a yml file
pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<(), Error> {
let file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(path)?;
Ok(yaml_serde::to_writer(file, self)?)
}
/// helper function to eiter load transforms from file or calculate and save them
pub fn calculate_transforms_from<P, Q, R>(
transform_file: Option<P>,
bead_files: Option<&[Q]>,
drift_file: Option<R>,
main_channel: Option<usize>,
default_transform: Option<Vec<f64>>,
) -> Result<Self, Error>
where
P: AsRef<Path>,
Q: AsRef<Path>,
R: AsRef<Path>,
{
if let Some(transform_file) = transform_file.as_ref()
&& let Ok(transforms) = Self::load(transform_file.as_ref().with_extension("yml"))
{
Ok(transforms)
} else {
let default_transform = default_transform.map(Transform::new);
let mut transforms = Self::default();
if let Some(bead_files) = bead_files {
let main_channel = main_channel.unwrap_or(0);
transforms.channel = Self::calculate_channel_transforms_2d(
bead_files,
main_channel,
default_transform,
)?
.into_iter()
.map(|(k, t)| (k, t.into_dyn()))
.collect();
};
if let Some(drift_file) = drift_file {
transforms.drift = Self::calculate_drift_transforms_2d(drift_file)?
.into_iter()
.map(|t| t.into_dyn())
.collect();
};
if let Some(transform_file) = transform_file {
let transform_file = transform_file.as_ref();
transforms.save(transform_file.with_extension("yml"))?;
if let Some(bead_files) = bead_files {
transforms.save_channel_transform_tiff(
transform_file.with_extension("tif"),
bead_files,
)?;
}
}
Ok(transforms)
}
}
/// save a tiff file with the uncorrected and corrected frames of the bead files side by side
pub fn save_channel_transform_tiff<P, Q>(&self, file: P, bead_files: &[Q]) -> Result<(), Error>
where
P: AsRef<Path>,
Q: AsRef<Path>,
{
let mut transforms = self.clone();
transforms.drift = Vec::new();
let mut tif = IJTiffFile::new(file)?;
for (t, bead_file) in bead_files.iter().enumerate() {
let mut view = View::<_, DynReader>::from_path(bead_file)?.into_dyn();
let axes = view.axes();
if axes.contains(&Axis::Z) {
view = view.max_proj(Axis::Z)?;
}
if axes.contains(&Axis::T) {
view = view.max_proj(Axis::T)?;
}
let view_u = view.into_dimensionality::<Ix3>()?;
let view_t = view_u.with_transforms(transforms.clone());
for c in 0..view_u.shape().c {
let a = view_u.slice_cztyx(s![c, 0, 0, .., ..])?.as_array::<u16>()?;
let b = view_t.slice_cztyx(s![c, 0, 0, .., ..])?.as_array::<u16>()?;
let frame = concatenate(ndarray::Axis(1), &[a.view(), b.view()])?;
tif.save(frame, c, 0, t)?;
}
}
Ok(())
}
/// calculate drift transforms in yx using image file
pub fn calculate_drift_transforms_2d<P: AsRef<Path>>(
file: P,
) -> Result<Vec<Transform<Ix2>>, Error> {
View::<_, DynReader>::from_path(file)?.calculate_drift_transforms_2d()
}
/// calculate drift transforms in zyx using image file
pub fn calculate_drift_transforms_3d<P: AsRef<Path>>(
file: P,
) -> Result<Vec<Transform<Ix3>>, Error> {
View::<_, DynReader>::from_path(file)?.calculate_drift_transforms_3d()
}
/// calculate channel transforms in yx using image file
pub fn calculate_channel_transforms_2d<P: AsRef<Path>>(
bead_files: &[P],
main_channel: usize,
default_transform: Option<Transform<Ix2>>,
) -> Result<HashMap<String, Transform<Ix2>>, Error> {
// TODO: autodetect main channel
let mut transforms = Vec::new();
let default_transform = default_transform.unwrap_or_default();
for file in bead_files {
let view = View::<_, DynReader>::from_path(file)?;
transforms.push(view.calculate_channel_transforms_2d(main_channel)?)
}
let transforms = transforms.into_iter().flat_map(|map| map.into_iter()).fold(
HashMap::new(),
|mut acc, (channel, transform)| {
acc.entry(channel)
.or_insert_with(Vec::new)
.push(transform * &default_transform);
acc
},
);
Ok(transforms
.iter()
.map(|(channel, ts)| {
let n = ts.len();
let parameters = ts
.iter()
.fold(vec![0.0; 6], |mut acc, t| {
for (a, p) in acc.iter_mut().zip(&t.parameters) {
*a += p;
}
acc
})
.iter()
.map(|i| i / n as f64)
.collect::<Vec<_>>();
let dparameters = if n == 1 {
ts[0].dparameters.clone()
} else {
ts.iter()
.fold(vec![0.0; 6], |mut acc, t| {
for ((a, p), dp) in acc.iter_mut().zip(&parameters).zip(&t.dparameters)
{
*a += (dp - p).powi(2);
}
acc
})
.iter()
.map(|i| (i / n as f64).sqrt())
.collect::<Vec<_>>()
};
(
channel.to_string(),
Transform::new(parameters).with_dparameters(dparameters),
)
})
.collect())
}
/// calculate channel transforms in zyx using image file
pub fn calculate_channel_transforms_3d<P: AsRef<Path>>(
bead_files: &[P],
main_channel: usize,
default_transform: Option<Transform<Ix3>>,
) -> Result<HashMap<String, Transform<Ix3>>, Error> {
let mut transforms = Vec::new();
let default_transform = default_transform.unwrap_or_default();
for file in bead_files {
let view = View::<_, DynReader>::from_path(file)?;
transforms.push(view.calculate_channel_transforms_3d(main_channel)?)
}
let channels = transforms
.iter()
.flat_map(|t| t.keys())
.collect::<HashSet<_>>();
let mut average_transforms = HashMap::new();
for channel in channels {
let (s, n) = transforms
.iter()
.filter_map(|t| {
if t.contains_key(channel) {
Some((&t[channel] * &default_transform).matrix())
} else {
None
}
})
.fold((Array2::zeros((4, 4)), 0), |(s, n), t| (s + t, n + 1));
let matrix = s / (n as f64);
let (s, n) = transforms
.iter()
.filter_map(|t| {
if t.contains_key(channel) {
Some(((&t[channel] * &default_transform).matrix() - &matrix).powi(2))
} else {
None
}
})
.fold((Array2::zeros((4, 4)), 0), |(s, n), t| (s + t, n + 1));
let dmatrix = (s / n as f64).sqrt();
average_transforms.insert(
channel.clone(),
Transform::default()
.with_matrix(matrix.view())
.with_dmatrix(dmatrix.view()),
);
}
Ok(average_transforms)
}
/// get the combination of channel and drift transforms for a certain channel and time point
pub fn get_transform(
&self,
channel: &str,
t: usize,
) -> Result<Option<Cow<'_, Transform<IxDyn>>>, Error> {
match (self.channel.get(channel), self.drift.get(t)) {
(Some(channel), Some(drift)) => match (channel.ndim(), drift.ndim()) {
(2, 2) | (3, 3) => Ok(Some(Cow::Owned(drift * channel))),
(2, 3) => Ok(Some(Cow::Owned(drift.add_dimension(0) * channel))),
(3, 2) => Ok(Some(Cow::Owned(drift * channel.add_dimension(0)))),
(2 | 3, n) | (n, _) => Err(Error::TransformationNotImplemented(n)),
},
(Some(channel), None) => Ok(Some(Cow::Borrowed(channel))),
(None, Some(drift)) => Ok(Some(Cow::Borrowed(drift))),
(None, None) => Ok(None),
}
}
}
impl<D: Dimension, R: Reader> View<D, R> {
pub fn get_transforms(&self) -> &Transforms {
&self.transforms
}
pub fn set_transforms(&mut self, transforms: Transforms) {
self.transforms = transforms;
}
/// apply transforms to a new view
pub fn with_transforms(&self, transforms: Transforms) -> Self {
let mut new = self.clone();
new.transforms = transforms;
new
}
/// load the transforms from a yml file and apply to a new view
pub fn with_transforms_from_yaml(mut self, path: &Path) -> Result<Self, Error> {
self.set_transforms(Transforms::load(path)?);
Ok(self)
}
/// load the transforms from a yml file and apply to the view
pub fn load_transforms_from_yaml(&mut self, path: &Path) -> Result<(), Error> {
self.set_transforms(Transforms::load(path)?);
Ok(())
}
/// calculate channel transforms in yx using this image view
pub fn calculate_channel_transforms_2d(
&self,
main_channel: usize,
) -> Result<HashMap<String, Transform<Ix2>>, Error> {
let main_max = self
.slice_cztyx(s![main_channel, .., 0, .., ..])?
.max_proj(Axis::Z)?
.as_array::<f64>()?;
let mut transforms = HashMap::new();
for (channel, metadata) in self.metadata()?.image[0].pixels.channel.iter().enumerate() {
let name = metadata
.name
.clone()
.unwrap_or_else(|| format!("channel{}", channel));
if channel == main_channel {
transforms.insert(name, Transform::default());
} else {
let max = self
.slice_cztyx(s![channel, .., 0, .., ..])?
.max_proj(Axis::Z)?
.as_array::<f64>()?;
transforms.insert(
name,
Transform::register_affine(main_max.view(), max.view())?,
);
}
}
Ok(transforms)
}
/// calculate channel transforms in zyx using this image view
pub fn calculate_channel_transforms_3d(
&self,
main_channel: usize,
) -> Result<HashMap<String, Transform<Ix3>>, Error> {
let main_max = self
.slice_cztyx(s![main_channel, .., 0, .., ..])?
.as_array::<f64>()?;
let mut transforms = HashMap::new();
for (channel, metadata) in self.metadata()?.image[0].pixels.channel.iter().enumerate() {
let name = metadata
.name
.clone()
.unwrap_or_else(|| format!("channel{}", channel));
if channel == main_channel {
transforms.insert(name, Transform::default());
} else {
let max = self
.slice_cztyx(s![channel, .., 0, .., ..])?
.as_array::<f64>()?;
transforms.insert(
name,
Transform::register_affine(main_max.view(), max.view())?,
);
}
}
Ok(transforms)
}
/// calculate drift transforms in yx using this image view
pub fn calculate_drift_transforms_2d(&self) -> Result<Vec<Transform<Ix2>>, Error> {
let mut view = self.clone().into_dyn();
let axes = view.axes();
if axes.contains(&Axis::Z) {
view = view.max_proj(Axis::Z)?;
}
if axes.contains(&Axis::C) {
view = view.max_proj(Axis::C)?;
}
view = view.permute_axes(&[Axis::T, Axis::Y, Axis::X])?;
let n = view.shape().t;
let m = (n as f64).sqrt().round() as usize;
let t_groups = (0..=m)
.map(|i| (((i * n) as f64) / (m as f64)).round() as usize)
.collect::<Vec<_>>();
let t_keys = t_groups
.array_windows()
.map(|[start, end]| (start + end) / 2)
.collect::<Vec<_>>();
let t_pairs = t_keys
.iter()
.zip(t_groups.array_windows())
.flat_map(|(t_key, [start, end])| (*start..*end).map(|i| (*t_key, i)))
.chain(t_keys.array_windows().map(|[start, end]| (*start, *end)))
.collect::<Vec<_>>();
let fmaxz_keys = t_keys
.iter()
.map(|t| {
Ok((
t,
filter::gaussian_smooth(
&view.slice(s![*t, .., ..])?.as_array::<f64>()?,
&[5.0; 2],
)?,
))
})
.collect::<Result<HashMap<_, _>, Error>>()?;
let shifts = t_pairs
.into_par_iter()
.map(|(t_key, t)| {
if t_key == t {
Ok(Transform::<Ix2>::from_translation(&vec![0.0; view.ndim()]))
} else {
if let Some(fmaxz) = fmaxz_keys.get(&t_key) {
Ok(Transform::<Ix2>::register_translation(
fmaxz_keys[&t_key].view(),
fmaxz,
)?)
} else {
let fmaxz = filter::gaussian_smooth(
&view.slice(s![t, .., ..])?.as_array::<f64>()?,
&[5.0; 2],
)?;
Ok(Transform::<Ix2>::register_translation(
fmaxz_keys[&t_key].view(),
fmaxz.view(),
)?)
}
}
})
.collect::<Result<Vec<_>, Error>>()?;
let mut t = Transform::<Ix2>::from_translation(&[0.0; 2]);
let mut key_shifts = vec![t.clone()];
key_shifts.extend(shifts.iter().skip(n).map(|i| {
t = i * &t;
t.clone()
}));
Ok(key_shifts
.iter()
.zip(t_groups.array_windows())
.flat_map(|(s, [start, end])| {
let mut t = s.clone();
(*start..*end)
.map(|i| {
t = &shifts[i] * &t;
t.clone()
})
.collect::<Vec<_>>()
})
.collect())
}
/// calculate drift transforms in zyx using this image view
pub fn calculate_drift_transforms_3d(&self) -> Result<Vec<Transform<Ix3>>, Error> {
todo!()
}
/// retrieve a frame and warp it according to the transform
pub(crate) fn get_transformed_frame(
&self,
c: usize,
z: usize,
t: usize,
transform: &Transform<IxDyn>,
order: usize,
default_value: &DefaultValue,
) -> Result<Arc<Frame>, Error> {
match transform.ndim() {
2 => {
let tr = transform.clone().into_dimensionality::<Ix2>()?;
macro_rules! interpolate {
($array:expr ,$var:ident, bool) => {
Ok(Arc::from(Frame::$var(
tr.interpolate_par_order(
&$array.mapv(|i| if i { 1.0 } else { 0.0 }),
order,
default_value,
)?
.mapv(|i| i > 0.5),
)))
};
($array:expr ,$var:ident, f64) => {
Ok(Arc::from(Frame::$var(tr.interpolate_par_order(
$array,
order,
default_value,
)?)))
};
($array:expr ,$var:ident, $t:ty) => {
Ok(Arc::from(Frame::$var(
tr.interpolate_par_order($array, order, default_value)?
.mapv(|i| i as $t),
)))
};
}
match self.get_cached_frame(c, z, t, None)?.as_ref() {
Frame::Bool(frame) => interpolate!(frame, Bool, bool),
Frame::I8(frame) => interpolate!(frame, I8, i8),
Frame::U8(frame) => interpolate!(frame, U8, u8),
Frame::I16(frame) => interpolate!(frame, I16, i16),
Frame::U16(frame) => interpolate!(frame, U16, u16),
Frame::I32(frame) => interpolate!(frame, I32, i32),
Frame::U32(frame) => interpolate!(frame, U32, u32),
Frame::U64(frame) => interpolate!(frame, U64, u64),
Frame::I64(frame) => interpolate!(frame, I64, i64),
Frame::I128(frame) => interpolate!(frame, I128, i128),
Frame::U128(frame) => interpolate!(frame, U128, u128),
Frame::F32(frame) => interpolate!(frame, F32, f32),
Frame::F64(frame) => interpolate!(frame, F64, f64),
}
}
3 => {
let tr = transform.clone().into_dimensionality::<Ix3>()?;
let shape = self.shape();
let center = shape.iter().map(|&i| (i as f64) / 2.0).collect::<Vec<_>>();
let zf = z as f64;
let yf = shape.y as f64 - 1.0;
let xf = shape.x as f64 - 1.0;
let corners = array![[zf, 0.0, 0.0], [zf, 0.0, xf], [zf, yf, 0.0], [zf, yf, xf]];
let extent = tr.transform_points(&corners, &center)?;
let z_extent = extent.slice(s![.., 2]);
let z_min = (z_extent
.iter()
.min_by(|a, b| a.total_cmp(b))
.unwrap()
.floor() as isize
- 2)
.max(0) as usize;
let z_max = (z_extent
.iter()
.max_by(|a, b| a.total_cmp(b))
.unwrap()
.ceil() as isize
+ 2)
.min(shape.z as isize - 1)
.max(0) as usize;
let frames = (z_min..=z_max)
.map(|zi| self.get_cached_frame(c, zi, t, None))
.collect::<Result<Vec<_>, Error>>()?;
// TODO; check these translations
let u0 = tr.as_translation(&[z_min as f64, 0.0, 0.0]);
let u1 = tr.as_translation(&[-(z_min as f64), 0.0, 0.0]);
let tr = u0 * tr * u1;
match frames.first().map(|f| f.as_ref()) {
Some(first) => {
macro_rules! interpolate {
($var:ident, bool) => {{
let views = frames
.iter()
.map(|f| match f.as_ref() {
Frame::$var(a) => Ok(a.view()),
_ => unreachable!(),
})
.collect::<Result<Vec<_>, Error>>()?;
let stack = ndarray::stack(ndarray::Axis(0), &views)?;
let transformed = tr.interpolate_par_order(
&stack.mapv(|i| if i { 1.0 } else { 0.0 }),
order,
default_value,
)?;
Ok(Arc::from(Frame::$var(
transformed.slice(s![0, .., ..]).mapv(|i| i > 0.5),
)))
}};
($var:ident, f64) => {{
let views = frames
.iter()
.map(|f| match f.as_ref() {
Frame::$var(a) => Ok(a.view()),
_ => unreachable!(),
})
.collect::<Result<Vec<_>, Error>>()?;
let stack = ndarray::stack(ndarray::Axis(0), &views)?;
let transformed =
tr.interpolate_par_order(&stack, order, default_value)?;
Ok(Arc::from(Frame::$var(
transformed.slice(s![0, .., ..]).to_owned(),
)))
}};
($var:ident, $t:ty) => {{
let views = frames
.iter()
.map(|f| match f.as_ref() {
Frame::$var(a) => Ok(a.view()),
_ => unreachable!(),
})
.collect::<Result<Vec<_>, Error>>()?;
let stack = ndarray::stack(ndarray::Axis(0), &views)?;
let transformed =
tr.interpolate_par_order(&stack, order, default_value)?;
Ok(Arc::from(Frame::$var(
transformed.slice(s![0, .., ..]).mapv(|i| i as $t),
)))
}};
}
match first {
Frame::Bool(_) => interpolate!(Bool, bool),
Frame::I8(_) => interpolate!(I8, i8),
Frame::U8(_) => interpolate!(U8, u8),
Frame::I16(_) => interpolate!(I16, i16),
Frame::U16(_) => interpolate!(U16, u16),
Frame::I32(_) => interpolate!(I32, i32),
Frame::U32(_) => interpolate!(U32, u32),
Frame::F32(_) => interpolate!(F32, f32),
Frame::F64(_) => interpolate!(F64, f64),
Frame::I64(_) => interpolate!(I64, i64),
Frame::U64(_) => interpolate!(U64, u64),
Frame::I128(_) => interpolate!(I128, i128),
Frame::U128(_) => interpolate!(U128, u128),
}
}
None => {
let shape = self.shape();
Ok(Arc::new(match self.pixel_type() {
PixelType::Bool => Frame::Bool(Array2::default((shape.y, shape.x))),
PixelType::I8 => Frame::I8(Array2::zeros((shape.y, shape.x))),
PixelType::U8 => Frame::U8(Array2::zeros((shape.y, shape.x))),
PixelType::I16 => Frame::I16(Array2::zeros((shape.y, shape.x))),
PixelType::U16 => Frame::U16(Array2::zeros((shape.y, shape.x))),
PixelType::I32 => Frame::I32(Array2::zeros((shape.y, shape.x))),
PixelType::U32 => Frame::U32(Array2::zeros((shape.y, shape.x))),
PixelType::F32 => Frame::F32(Array2::zeros((shape.y, shape.x))),
PixelType::F64 => Frame::F64(Array2::zeros((shape.y, shape.x))),
PixelType::I64 => Frame::I64(Array2::zeros((shape.y, shape.x))),
PixelType::U64 => Frame::U64(Array2::zeros((shape.y, shape.x))),
PixelType::I128 => Frame::I128(Array2::zeros((shape.y, shape.x))),
PixelType::U128 => Frame::U128(Array2::zeros((shape.y, shape.x))),
PixelType::F128 => Err(Error::F128NotSupported)?,
}))
}
}
}
d => Err(Error::TransformationNotImplemented(d)),
}
}
}
#[cfg(test)]
mod tests {
use crate::axes::Axis;
use crate::error::Error;
use crate::readers::{DynReader, Reader};
use crate::transforms::Transforms;
use image_registration::transform::Transform;
use ndarray::s;
use std::collections::HashMap;
fn open(file: &str) -> Result<DynReader, Error> {
let path = std::env::current_dir()?
.join("tests")
.join("files")
.join(file);
DynReader::new(&path, 0, 0)
}
#[test]
fn test_transforms() -> Result<(), Box<dyn std::error::Error>> {
let _t = Transforms::default();
Ok(())
}
#[test]
fn load_transforms() -> Result<(), Box<dyn std::error::Error>> {
let t = Transforms::load("tests/files/transforms.yml")?;
println!("t: {:?}", t);
assert_eq!(t.channel["TV1"].ndim(), 2);
assert_eq!(t.channel["TV2"].ndim(), 2);
Ok(())
}
#[test]
fn load_old_transforms() -> Result<(), Box<dyn std::error::Error>> {
let t = Transforms::load("tests/files/transform_red.yml")?;
println!("t: {:?}", t);
assert_eq!(t.channel["TV1"].ndim(), 2);
assert_eq!(t.channel["TV2"].ndim(), 2);
Ok(())
}
#[test]
fn transforms_as_array() -> Result<(), Box<dyn std::error::Error>> {
let file = "czi/beads_2020_08_05__11_43_07.czi";
let reader = open(file)?;
let view = reader.view();
let mut channel = HashMap::new();
channel.insert(
"TV1".to_string(),
Transform::new(vec![1.0, 0.0, 0.0, 1.0, 0.0, 0.0]),
);
channel.insert(
"TV2".to_string(),
Transform::new(vec![
0.9995868322142533,
0.012140955373934502,
0.012592501668218145,
0.8571690967587099,
-5.270859902118885,
-5.81118547158953,
]),
);
let view_t = view.with_transforms(Transforms {
default_value: Default::default(),
interpolation_order: None,
channel,
drift: Vec::new(),
});
let s_a = view.max_proj(Axis::Z)?.slice_cztyx(s![1, 0, .., .., ..])?;
println!(
"s_a: {:?}, {:?}, {:?}",
s_a.get_slice(),
s_a.get_axes(),
s_a.get_operations()
);
let s_b = view_t
.max_proj(Axis::Z)?
.slice_cztyx(s![1, 0, .., .., ..])?;
println!(
"s_b: {:?}, {:?}, {:?}",
s_b.get_slice(),
s_b.get_axes(),
s_b.get_operations()
);
let a = s_a.as_array::<u16>()?;
let b = s_b.as_array::<u16>()?;
assert_ne!(a, b);
// let a = view.slice_cztyx(s![1, 0, 0, .., ..])?.as_array::<u16>()?;
// let b = view_t.slice_cztyx(s![1, 0, 0, .., ..])?.as_array::<u16>()?;
// assert_ne!(a, b);
//
// let a = view
// .max_proj(Axis::Z)?
// .slice(s![1, 0, .., ..])?
// .as_array::<u16>()?;
// let b = view_t
// .max_proj(Axis::Z)?
// .slice(s![1, 0, .., ..])?
// .as_array::<u16>()?;
// assert_ne!(a, b);
Ok(())
}
}
File renamed without changes.
File diff suppressed because it is too large. Load diff
Binary file not shown.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,12 @@
{
"Channels": [{
"Name": "mScarlet_GFP-mSc-filter",
"DisplayMode": 1,
"HistogramMax": -1,
"Max": 523,
"Gamma": 1,
"Min": 352,
"Color": -1
}],
"Comments": {"Summary": "5-GFP-mScarlet/GFP/OD2.0/100x/1x/2x2binning/15s_interval/{-2,2,0/5zstack}/autofocus/mScarlet_GFP-mSc-filter/500ms/100% OR: GFP 20%/exp150ms"}
}
File renamed without changes.
@@ -26,8 +26,10 @@ def image(array):
@pytest.mark.parametrize( @pytest.mark.parametrize(
"s", "s",
list(
combinations_with_replacement( combinations_with_replacement(
(0, -1, 1, slice(None), slice(0, 1), slice(-1, 0), slice(1, 1)), 5 (0, -1, 1, slice(None), slice(0, 1), slice(-1, 0), slice(1, 1), slice(1, 2)), 5
)
), ),
) )
def test_slicing(s, image, array): def test_slicing(s, image, array):
@@ -26,6 +26,7 @@ def image(array):
@pytest.mark.parametrize( @pytest.mark.parametrize(
"fun_and_axis", "fun_and_axis",
list(
product( product(
( (
np.sum, np.sum,
@@ -44,6 +45,7 @@ def image(array):
np.nanstd, np.nanstd,
), ),
(None, 0, 1, 2, 3, 4), (None, 0, 1, 2, 3, 4),
)
), ),
) )
def test_ufuncs(fun_and_axis, image, array): def test_ufuncs(fun_and_axis, image, array):
-3
View File
@@ -1,3 +0,0 @@
from .ndbioimage_rs import Imread, Shape
__all__ = ["Imread", "Shape"]
-2376
View File
File diff suppressed because it is too large. Load diff
-497
View File
@@ -1,497 +0,0 @@
use crate::error::Error;
use crate::readers::{ArrayT, DynReader, Frame, PixelType, Reader, Shape};
use ndarray::Array2;
use ome_metadata::{Ome, ome};
use serde::{Deserialize, Serialize};
use std::cell::{RefCell, RefMut};
use std::collections::{HashMap, HashSet};
use std::fs::File;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use thread_local::ThreadLocal;
use tiff::decoder::ifd::Value;
use tiff::decoder::{Decoder, DecodingResult};
use tiff::tags::Tag;
/// reader for single tiff image files
#[derive(Debug, Serialize, Deserialize)]
pub struct TiffReader {
#[serde(skip)]
reader: ThreadLocal<RefCell<Decoder<File>>>,
path: PathBuf,
series: usize,
position: usize,
shape: Shape,
pixel_type: PixelType,
n_samples: usize,
p_ndim: u8,
planar: bool,
}
impl From<TiffReader> for DynReader {
fn from(value: TiffReader) -> Self {
DynReader::Tiff(value)
}
}
impl Hash for TiffReader {
fn hash<H: Hasher>(&self, state: &mut H) {
self.path.hash(state);
self.series.hash(state);
self.position.hash(state);
}
}
impl Clone for TiffReader {
fn clone(&self) -> Self {
TiffReader {
reader: ThreadLocal::default(),
path: self.path.clone(),
series: self.series,
position: self.position,
shape: self.shape.clone(),
pixel_type: self.pixel_type,
n_samples: self.n_samples,
p_ndim: self.p_ndim,
planar: self.planar,
}
}
}
impl PartialEq for TiffReader {
fn eq(&self, other: &Self) -> bool {
self.path == other.path && self.series == other.series && self.position == other.position
}
}
impl Eq for TiffReader {}
impl TiffReader {
fn get_reader(&self) -> Result<RefMut<'_, Decoder<File>>, Error> {
self.reader
.get_or_try(|| {
let file = File::open(&self.path)?;
Ok(RefCell::new(Decoder::new(file)?))
})
.map(|i| i.borrow_mut())
}
}
fn pixel_type_from_bits_sample(bits: u16, fmt: u16) -> Result<PixelType, Error> {
match (fmt, bits) {
(1, 8) => Ok(PixelType::U8),
(1, 16) => Ok(PixelType::U16),
(1, 32) => Ok(PixelType::U32),
(2, 8) => Ok(PixelType::I8),
(2, 16) => Ok(PixelType::I16),
(2, 32) => Ok(PixelType::I32),
(3, 32) => Ok(PixelType::F32),
(3, 64) => Ok(PixelType::F64),
_ => Err(Error::Parse(format!(
"unsupported pixel type: SampleFormat={}, BitsPerSample={}",
fmt, bits
))),
}
}
fn parse_imagej_metadata(s: &str) -> HashMap<String, String> {
let mut map = HashMap::new();
for line in s.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(pos) = line.find('=') {
let key = line[..pos].trim().to_string();
let val = line[pos + 1..].trim().to_string();
map.insert(key, val);
}
}
map
}
impl Reader for TiffReader {
fn new<P>(path: P, series: usize, position: usize) -> Result<Self, Error>
where
P: AsRef<Path>,
{
let mut new = Self {
reader: ThreadLocal::default(),
path: path.as_ref().to_path_buf(),
series,
position,
shape: Shape::default(),
pixel_type: PixelType::U16,
n_samples: 0,
p_ndim: 0,
planar: true,
};
let mut reader = new.get_reader()?;
let width = reader.get_tag(Tag::ImageWidth)?.into_u32()? as usize;
let height = reader.get_tag(Tag::ImageLength)?.into_u32()? as usize;
let n_samples = reader
.get_tag(Tag::SamplesPerPixel)
.ok()
.and_then(|t| t.into_u16().ok())
.unwrap_or(1) as usize;
let bits = tag_first_u16(&mut reader, Tag::BitsPerSample).unwrap_or(8);
let sfmt = tag_first_u16(&mut reader, Tag::SampleFormat).unwrap_or(1);
let pixtype = pixel_type_from_bits_sample(bits, sfmt)?;
let planar = reader
.get_tag(Tag::PlanarConfiguration)
.ok()
.and_then(|t| t.into_u16().ok())
.unwrap_or(1)
== 2;
let imagej_bytes = reader
.get_tag(Tag::Unknown(50839))
.or_else(|_| reader.get_tag(Tag::ImageDescription))?;
let imagej_bytes = match imagej_bytes {
Value::Ascii(s) => s.into_bytes(),
other => other.into_u8_vec()?,
};
let metadata_str = String::from_utf8_lossy(&imagej_bytes);
let metadata_map = parse_imagej_metadata(&metadata_str);
let p_ndim = if n_samples > 1 { 3u8 } else { 2u8 };
let size_c = if p_ndim == 3 {
n_samples
} else {
metadata_map
.get("channels")
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(1)
};
let size_z = metadata_map
.get("slices")
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(1);
let size_t = metadata_map
.get("frames")
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(1);
let mult = if p_ndim == 3 { n_samples } else { 1 };
let expected = size_c * size_z * size_t / mult;
let mut n_ifds = 1;
while reader.more_images() {
reader.next_image()?;
n_ifds += 1;
}
let size_z = if n_ifds != expected && n_ifds > 0 {
n_ifds / (size_c * size_t / mult).max(1)
} else {
size_z
};
drop(reader);
new.shape = Shape {
c: size_c,
z: size_z,
t: size_t,
y: height,
x: width,
..Default::default()
};
new.pixel_type = pixtype;
new.n_samples = n_samples;
new.p_ndim = p_ndim;
new.planar = planar;
// test reading first frame, if error another reader (bioformats) can take over
new.get_frame(0, 0, 0)?;
Ok(new)
}
fn metadata(&self) -> Result<Ome, Error> {
let mut reader = self.get_reader()?;
let imagej_bytes = reader
.get_tag(Tag::Unknown(50839))
.or_else(|_| reader.get_tag(Tag::ImageDescription))?;
let imagej_bytes = match imagej_bytes {
Value::Ascii(s) => s.into_bytes(),
other => other.into_u8_vec()?,
};
let metadata_str = String::from_utf8_lossy(&imagej_bytes);
let metadata_map = parse_imagej_metadata(&metadata_str);
let interval_t = metadata_map
.get("interval")
.and_then(|v| v.parse::<f64>().ok());
let ru = reader
.get_tag(Tag::ResolutionUnit)
.ok()
.and_then(|i| i.into_u16().ok())
.unwrap_or(2);
let pixel_size = reader
.get_tag(Tag::XResolution)
.ok()
.and_then(|i| i.into_u64_vec().ok())
.and_then(|i| {
i.first()
.and_then(|&n| i.get(1).map(|&d| (n as f64) / (d as f64)))
})
.and_then(|i| {
if i <= 0.0 {
None
} else if ru == 3 {
Some(1e4 / i)
} else if ru == 2 {
Some(25400.0 / i)
} else {
Some(1.0 / i)
}
});
let interval_z = metadata_map
.get("spacing")
.and_then(|v| v.parse::<f64>().ok());
let ome_pixel_type = match self.pixel_type {
PixelType::I8 => ome::PixelType::Int8,
PixelType::U8 => ome::PixelType::Uint8,
PixelType::I16 => ome::PixelType::Int16,
PixelType::U16 => ome::PixelType::Uint16,
PixelType::I32 => ome::PixelType::Int32,
PixelType::U32 => ome::PixelType::Uint32,
PixelType::F32 => ome::PixelType::Float,
PixelType::F64 => ome::PixelType::Double,
_ => ome::PixelType::Uint16,
};
let mut pixels = ome::Pixels {
id: "Pixels:0".to_string(),
size_x: self.shape.x as i32,
size_y: self.shape.y as i32,
size_z: self.shape.z as i32,
size_c: self.shape.c as i32,
size_t: self.shape.t as i32,
dimension_order: ome::PixelsDimensionOrderType::Xyczt,
r#type: ome_pixel_type,
physical_size_x: pixel_size.map(|v| v as f32),
physical_size_x_unit: ome::UnitsLength::um,
physical_size_y: pixel_size.map(|v| v as f32),
physical_size_y_unit: ome::UnitsLength::um,
physical_size_z: interval_z.map(|v| v as f32),
physical_size_z_unit: ome::UnitsLength::um,
time_increment: interval_t.map(|v| v as f32),
time_increment_unit: ome::UnitsTime::s,
significant_bits: None,
interleaved: None,
big_endian: None,
channel: Vec::new(),
bin_data: Vec::new(),
tiff_data: Vec::new(),
metadata_only: None,
plane: Vec::new(),
};
for c in 0..self.shape.c {
pixels.channel.push(ome::Channel {
id: format!("Channel:{}", c),
name: Some(format!("Channel {}", c)),
..Default::default()
});
}
for c in 0..self.shape.c {
for z in 0..self.shape.z {
for t in 0..self.shape.t {
pixels.plane.push(ome::Plane {
the_c: Some(c as i32),
the_z: Some(z as i32),
the_t: Some(t as i32),
delta_t: interval_t.and_then(|v| {
if v > 0.0 {
Some((t as f64 * v) as f32)
} else {
None
}
}),
..Default::default()
});
}
}
}
let mut ome = Ome::default();
ome.image.push(ome::Image {
id: "Image:0".to_string(),
name: self
.path
.file_name()
.map(|n| n.to_string_lossy().to_string()),
pixels,
instrument_ref: None,
objective_settings: None,
acquisition_date: None,
description: None,
experimenter_ref: None,
experiment_ref: None,
experimenter_group_ref: None,
imaging_environment: None,
stage_label: None,
roi_ref: Vec::new(),
microbeam_manipulation_ref: Vec::new(),
annotation_ref: Vec::new(),
});
Ok(ome)
}
fn get_frame(&self, c: usize, z: usize, t: usize) -> Result<Frame, Error> {
let (page_idx, offset, stride) = if self.p_ndim == 3 {
(t * self.shape.z + z, c, self.n_samples)
} else {
(c + z * self.shape.c + t * self.shape.c * self.shape.z, 0, 1)
};
let mut reader = self.get_reader()?;
reader.seek_to_image(page_idx)?;
let data: DecodingResult = if self.planar {
let mut result = DecodingResult::U16(Vec::new());
reader.read_image_to_buffer(&mut result)?;
result
} else {
reader.read_image()?
};
macro_rules! to_frame {
($var:ident, $ty:ty) => {{
let v: Vec<$ty> = match data {
DecodingResult::$var(v) => v,
_ => {
return Err(Error::Parse(format!(
"type mismatch: expected {}",
stringify!($var)
)));
}
};
let yx = self.shape.y * self.shape.x;
if self.planar {
let chan: Vec<$ty> = v.into_iter().skip(c * yx).take(yx).collect();
Ok(ArrayT::$var(Array2::from_shape_vec(
(self.shape.y, self.shape.x),
chan,
)?))
} else if stride > 1 {
let chan: Vec<$ty> = v
.into_iter()
.skip(offset)
.step_by(stride)
.take(yx)
.collect();
Ok(ArrayT::$var(Array2::from_shape_vec(
(self.shape.y, self.shape.x),
chan,
)?))
} else {
Ok(ArrayT::$var(Array2::from_shape_vec(
(self.shape.y, self.shape.x),
v,
)?))
}
}};
}
match self.pixel_type {
PixelType::U8 => to_frame!(U8, u8),
PixelType::U16 => to_frame!(U16, u16),
PixelType::U32 => to_frame!(U32, u32),
PixelType::U64 => to_frame!(U64, u64),
PixelType::I8 => to_frame!(I8, i8),
PixelType::I16 => to_frame!(I16, i16),
PixelType::I32 => to_frame!(I32, i32),
PixelType::I64 => to_frame!(I64, i64),
PixelType::F32 => to_frame!(F32, f32),
PixelType::F64 => to_frame!(F64, f64),
_ => Err(Error::NotImplemented(format!(
"unsupported TIFF pixel type: {:?}",
self.pixel_type
))),
}
}
fn path(&self) -> &Path {
&self.path
}
fn series(&self) -> usize {
self.series
}
fn position(&self) -> usize {
self.position
}
fn shape(&self) -> &Shape {
&self.shape
}
fn pixel_type(&self) -> &PixelType {
&self.pixel_type
}
fn get_available_positions<P>(_path: P, _series: usize) -> Result<HashSet<usize>, Error>
where
P: AsRef<Path>,
{
Ok(HashSet::from([0]))
}
fn get_available_series<P>(_path: P) -> Result<HashSet<usize>, Error>
where
P: AsRef<Path>,
{
Ok(HashSet::from([0]))
}
}
fn tag_first_u16(reader: &mut RefMut<Decoder<File>>, tag: Tag) -> Option<u16> {
let val = reader.get_tag(tag).ok()?;
use tiff::decoder::ifd::Value;
match val {
Value::Short(v) => Some(v),
Value::List(list) => list.first().and_then(|v| match v {
Value::Short(v) => Some(*v),
_ => None,
}),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn open(file: &str) -> Result<TiffReader, Error> {
let path = std::env::current_dir()?
.join("tests")
.join("files")
.join(file);
TiffReader::new(&path, 0, 0)
}
macro_rules! test_metadata {
($($name:ident: $file:expr $(,)?)*) => {
$(
#[test]
fn $name() -> Result<(), Error> {
let ts = open($file)?;
println!("{}", ts.view().squeeze()?.summary()?);
Ok(())
}
)*
};
}
test_metadata! {
// metadata_a: "tiff/20251014_20-Pos_000_000_mask.tif",
metadata_b: "tiff/20251014_20-Pos_000_000_max.tif",
metadata_c: "tiff/20251014_20-Pos_000_000_loc_results_Cy3.tif",
metadata_e: "tiff/1xp53-01-AP1.tiff",
metadata_f: "tiff/test.tif",
// metadata_g: "tiff/YTL1849A111_2023_05_04__14_46_19_cellnr_1_track.tif",
}
}
-316
View File
@@ -1,316 +0,0 @@
use crate::axes::Axis;
use crate::error::Error;
use crate::readers::{DynReader, Reader};
use crate::view::View;
pub use image_registration::transform::Transform;
use ndarray::{Array2, Dimension, Ix2, Ix3, IxDyn, s};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::io::Seek;
use std::path::Path;
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
struct TransformOld {
#[serde(alias = "CenterOfRotationPoint")]
center_of_rotation_point: Vec<f64>,
#[serde(alias = "Size")]
size: Vec<f64>,
#[serde(alias = "TransformParameters")]
transform_parameters: Vec<f64>,
#[serde(alias = "dTransformParameters")]
dtransform_parameters: Vec<f64>,
}
impl TryFrom<HashMap<String, TransformOld>> for Transforms {
type Error = Error;
fn try_from(t: HashMap<String, TransformOld>) -> Result<Self, Error> {
Ok(Self {
channel: t
.into_iter()
.map(|(n, i)| {
Ok((
n,
Transform::new_with_center(
i.transform_parameters,
i.center_of_rotation_point,
i.size.into_iter().map(|j| j as usize).collect(),
)
.with_dparameters(i.dtransform_parameters)
.inverse()?,
))
})
.collect::<Result<_, Error>>()?,
drift: Vec::new(),
})
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct Transforms {
#[serde(skip_serializing_if = "HashMap::is_empty", default = "HashMap::new")]
pub channel: HashMap<String, Transform<IxDyn>>,
#[serde(skip_serializing_if = "Vec::is_empty", default = "Vec::new")]
pub drift: Vec<Transform<IxDyn>>,
}
impl Transforms {
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
let mut file = std::fs::File::open(&path)?;
Ok(
if let Ok(t) = yaml_serde::from_reader::<_, Self>(&file)
&& !(t.channel.is_empty() && t.drift.is_empty())
{
t
} else {
file.seek(std::io::SeekFrom::Start(0))?;
yaml_serde::from_reader::<_, HashMap<String, TransformOld>>(&file)?.try_into()?
},
)
}
pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<(), Error> {
let file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(path)?;
Ok(yaml_serde::to_writer(file, self)?)
}
pub fn calculate_channel_transforms_2d(
bead_files: &[&Path],
main_channel: usize,
default_transform: Option<Transform<Ix2>>,
) -> Result<HashMap<String, Transform<Ix2>>, Error> {
let mut transforms = Vec::new();
let default_transform = default_transform.unwrap_or_default();
for file in bead_files {
let view = View::<_, DynReader>::from_path(file)?;
transforms.push(view.calculate_channel_transforms_2d(main_channel)?)
}
let channels = transforms
.iter()
.flat_map(|t| t.keys())
.collect::<HashSet<_>>();
let mut average_transforms = HashMap::new();
for channel in channels {
let (s, n) = transforms
.iter()
.filter_map(|t| {
if t.contains_key(channel) {
Some((&t[channel] * &default_transform).matrix())
} else {
None
}
})
.fold((Array2::zeros((3, 3)), 0), |(s, n), t| (s + t, n + 1));
let matrix = s / (n as f64);
let (s, n) = transforms
.iter()
.filter_map(|t| {
if t.contains_key(channel) {
Some(((&t[channel] * &default_transform).matrix() - &matrix).powi(2))
} else {
None
}
})
.fold((Array2::zeros((3, 3)), 0), |(s, n), t| (s + t, n + 1));
let dmatrix = (s / n as f64).sqrt();
average_transforms.insert(
channel.clone(),
Transform::default()
.with_matrix(matrix.view())
.with_dmatrix(dmatrix.view()),
);
}
Ok(average_transforms)
}
pub fn calculate_channel_transforms_3d(
bead_files: &[&Path],
main_channel: usize,
default_transform: Option<Transform<Ix3>>,
) -> Result<HashMap<String, Transform<Ix3>>, Error> {
let mut transforms = Vec::new();
let default_transform = default_transform.unwrap_or_default();
for file in bead_files {
let view = View::<_, DynReader>::from_path(file)?;
transforms.push(view.calculate_channel_transforms_3d(main_channel)?)
}
let channels = transforms
.iter()
.flat_map(|t| t.keys())
.collect::<HashSet<_>>();
let mut average_transforms = HashMap::new();
for channel in channels {
let (s, n) = transforms
.iter()
.filter_map(|t| {
if t.contains_key(channel) {
Some((&t[channel] * &default_transform).matrix())
} else {
None
}
})
.fold((Array2::zeros((4, 4)), 0), |(s, n), t| (s + t, n + 1));
let matrix = s / (n as f64);
let (s, n) = transforms
.iter()
.filter_map(|t| {
if t.contains_key(channel) {
Some(((&t[channel] * &default_transform).matrix() - &matrix).powi(2))
} else {
None
}
})
.fold((Array2::zeros((4, 4)), 0), |(s, n), t| (s + t, n + 1));
let dmatrix = (s / n as f64).sqrt();
average_transforms.insert(
channel.clone(),
Transform::default()
.with_matrix(matrix.view())
.with_dmatrix(dmatrix.view()),
);
}
Ok(average_transforms)
}
}
impl<D: Dimension, R: Reader> View<D, R> {
#[cfg(feature = "transforms")]
pub(crate) fn get_transforms(&self) -> &Transforms {
&self.transforms
}
#[cfg(feature = "transforms")]
pub(crate) fn set_transforms(&mut self, transforms: Transforms) {
self.transforms = transforms;
}
pub fn with_transforms_from_yaml(mut self, path: &Path) -> Result<Self, Error> {
self.set_transforms(Transforms::load(path)?);
Ok(self)
}
pub fn load_transforms_from_yaml(&mut self, path: &Path) -> Result<(), Error> {
self.set_transforms(Transforms::load(path)?);
Ok(())
}
pub fn calculate_channel_transforms_2d(
&self,
main_channel: usize,
) -> Result<HashMap<String, Transform<Ix2>>, Error> {
let main_max = self
.slice_cztyx(s![main_channel, .., 0, .., ..])?
.max_proj(Axis::Z)?
.as_array::<f64>()?;
let mut transforms = HashMap::new();
let shape = vec![self.shape().y, self.shape().x];
let center = shape
.iter()
.map(|&i| (i as f64 - 1.0) / 2.0)
.collect::<Vec<_>>();
for (channel, metadata) in self.metadata()?.image[0].pixels.channel.iter().enumerate() {
let name = metadata
.name
.clone()
.unwrap_or_else(|| format!("channel{}", channel));
if channel == main_channel {
transforms.insert(
name,
Transform::default()
.with_shape(shape.clone())
.with_center(center.clone()),
);
} else {
let max = self
.slice_cztyx(s![channel, .., 0, .., ..])?
.max_proj(Axis::Z)?
.as_array::<f64>()?;
transforms.insert(
name,
Transform::register_affine(main_max.view(), max.view())?,
);
}
}
Ok(transforms)
}
pub fn calculate_channel_transforms_3d(
&self,
main_channel: usize,
) -> Result<HashMap<String, Transform<Ix3>>, Error> {
let main_max = self
.slice_cztyx(s![main_channel, .., 0, .., ..])?
.as_array::<f64>()?;
let mut transforms = HashMap::new();
let shape = vec![self.shape().z, self.shape().y, self.shape().x];
let center = shape
.iter()
.map(|&i| (i as f64 - 1.0) / 2.0)
.collect::<Vec<_>>();
for (channel, metadata) in self.metadata()?.image[0].pixels.channel.iter().enumerate() {
let name = metadata
.name
.clone()
.unwrap_or_else(|| format!("channel{}", channel));
if channel == main_channel {
transforms.insert(
name,
Transform::default()
.with_shape(shape.clone())
.with_center(center.clone()),
);
} else {
let max = self
.slice_cztyx(s![channel, .., 0, .., ..])?
.as_array::<f64>()?;
transforms.insert(
name,
Transform::register_affine(main_max.view(), max.view())?,
);
}
}
Ok(transforms)
}
pub fn calculate_drift_transforms_2d(&self) -> Result<Vec<Transform<Ix2>>, Error> {
todo!()
}
pub fn calculate_drift_transforms_3d(&self) -> Result<Vec<Transform<Ix3>>, Error> {
todo!()
}
}
#[cfg(test)]
mod tests {
use crate::transforms::Transforms;
#[test]
fn test_transforms() -> Result<(), Box<dyn std::error::Error>> {
let _t = Transforms::default();
Ok(())
}
#[test]
fn load_transforms() -> Result<(), Box<dyn std::error::Error>> {
let t = Transforms::load("tests/files/transforms.yml")?;
println!("t: {:?}", t);
assert_eq!(t.channel["TV1"].ndim(), 2);
assert_eq!(t.channel["TV2"].ndim(), 2);
Ok(())
}
#[test]
fn load_old_transforms() -> Result<(), Box<dyn std::error::Error>> {
let t = Transforms::load("tests/files/transform_red.yml")?;
println!("t: {:?}", t);
assert_eq!(t.channel["TV1"].ndim(), 2);
assert_eq!(t.channel["TV2"].ndim(), 2);
Ok(())
}
}