Enhanced Visual Analyzer
A high-performance bioimage analysis desktop application written in Rust.
EVAnalyzer is the Rust reimplementation of ImageC and the successor of the EVAnalyzer ImageJ plugin, combining a high-performance image viewer with a configurable analysis pipeline for fluorescence microscopy and high-content screening data.
| 40+ file formats | CZI, ND2, LIF, VSI, OME-TIFF, SLD, SCN, and more via Bio-Formats |
| Multi-channel viewer | Per-channel brightness/contrast, visibility toggles, and colour assignment |
| Z-stack support | Single-plane selection or intensity projections (Max, Min, Average, Sum, Middle) |
| Time-lapse support | Playback through T-stack sequences at configurable frame rates |
| object annotation | Rectangle, oval, and polygon regions of interest drawn directly on the image |
| object classification | Object classes with custom colours, names, and measurement criteria |
| Analysis pipeline | Composable processing steps from a library of algorithms (see below) |
| Multi-well plate layout | Group images by well/plate for high-content screening experiments |
| CSV export | Pipeline results exported per image and per well |
| Whole slide images | Native support for whole slide image formats |
| Navigator minimap | Thumbnail overview with visible viewport indicator |
| Scale bar | Physical scale bar with configurable units (nm, µm, mm) |
| Cross-platform | Linux (Skia renderer) and Windows (software renderer) |
| Category | Algorithms |
|---|---|
| Filters | Gaussian blur, rank filter (min/median/max), rolling-ball background subtraction, enhance contrast, colour filter, intensity transform, Canny/Sobel edge detection, Hessian, Laplacian, structure tensor, weighted deviation |
| Segmentation | Manual & automatic thresholding, connected components, watershed, AI segmentation (Stardist, U-Net — requires the ai build feature) |
| Morphology | Dilation, erosion, opening, closing |
| Classification | Rule-based object classification with configurable measurements |
Two AI segmentation algorithms are available (built with the ai Cargo feature, via tch-rs/libtorch):
| Algorithm | What the model predicts | What you get |
|---|---|---|
| Stardist | Star-convex polygon parameters per grid cell | Separated object instances directly — no further steps needed |
| UNet | Per-pixel semantic mask | A single foreground mask — touching objects are not separated yet |
UNet only produces a semantic mask (foreground vs. background); it has no notion of individual object instances. Getting good results requires two things:
-
Match
output_mode/foreground_channelto your model's export.- Plain background/foreground classifier (a mutually-exclusive softmax head): use
output_mode: SoftmaxClassesand setforeground_channelto the foreground class index (usually1for a 2-class head — this is the default). - Boundary-aware model (e.g. bioimage.io nucleus-boundary models, which export an independent mask channel and a separate boundary channel — these are two unrelated probabilities, not softmax classes): use
output_mode: IndependentChannelsand setforeground_channelto the mask channel's index (commonly0; check the model'srdf.yamlif unsure). Running softmax across mask+boundary, or picking the boundary channel by mistake, is the most common cause of "I get outlines, not filled objects".
- Plain background/foreground classifier (a mutually-exclusive softmax head): use
-
Separate touching objects.
UNetemits one shared class for "foreground", so two touching nuclei become one connected blob. How you split them depends on the model:-
Boundary-aware models — use the boundary channel (recommended). Models like bioimage.io's
affable-shark(NucleiSegmentationBoundaryModel — a U-Net, not StarDist) predict an explicit boundary in a second channel specifically so you can separate touching nuclei. Setboundary_channelto that channel (commonly1) andboundary_threshold(~0.5): a pixel is foreground only where the mask is high and the boundary is low, carving thin gaps between objects. Then a plainConnectedComponentsseparates them — no watershed needed:UNet (foreground_channel: 0, boundary_channel: 1) → ConnectedComponents → ExtractObjectsDiscarding the boundary channel and relying on watershed instead is the most common reason touching nuclei "won't split": a distance-map watershed can't separate a blob that has no waist, and the waist information lives in the boundary channel you didn't use.
-
Mask-only models — distance-map watershed. If the model gives only a foreground mask (no boundary), chain a watershed:
UNet → ConnectedComponents → Watershed → ExtractObjectsConnectedComponentslabels each blob;Watershedre-splits any blob with more than one object. It's a faithful port of ImageJ'sProcess > Binary > Watershed(theMaximumFinderdistance-map algorithm), so the defaultmaximum_finder_toleranceof0.5works for most nuclei. Note this only works when touching nuclei actually form a pinched "peanut"; heavily overlapping nuclei with no waist cannot be split from the mask alone.
-
These patterns (boundary-carving, and distance-map declumping) are the standard approaches for U-Net-style models — the same ideas used by CellProfiler and ilastik. Stardist, by contrast, predicts per-object instances directly and skips all of this — but only genuine StarDist exports (object probability + radial distances) work with the Stardist command; a boundary U-Net like affable-shark will not.
The workspace is organised into focused crates:
| Crate | Description |
|---|---|
evanalyzer_core |
Image I/O (Bio-Formats, native Rust), processing algorithms, object model, pipeline execution |
evanalyzer_cfg |
Project settings, serialisation to JSON, pipeline command configuration |
evanalyzer_app |
Application handle, shared project state |
evanalyzer_gui |
Slint-based desktop GUI — viewport, histogram, object tools, classification panel |
evanalyzer_cli |
Headless CLI: analyze projects, export/view results databases (see Command-Line Interface) |
evanalyzer_bin |
Binary entry point — launches GUI or CLI depending on arguments |
[ Image ]
│
▼
[ Preprocessing ] Gaussian blur, background subtraction, edge detection, …
│
▼
[ Threshold ] Manual or automatic → binary mask
│
▼
[ Connected Components ] Label each foreground region
│
▼
[ Watershed ] Split touching objects
│
▼
[ Extract ROIs ] Assign segmentation class as the first object class
│
▼
[ Classify ROIs ] Rule-based measurement and classification (optional)
│
▼
[ Export ] CSV per image / per well
- Rust 1.80 or later (2024 edition)
- Linux system libraries (for the GUI):
apt-get install libinput10 libxkbcommon0 libfontconfig1 libgbm1
| Resource | Minimum | Recommended |
|---|---|---|
| RAM | 4 GB | 16 GB+ (whole slide images, large batches) |
| CPU | 2 cores | 4+ cores |
| GPU | — (CPU-only build works) | NVIDIA GPU + CUDA 12.x (for the cuda build, AI segmentation) |
| Disk | Enough for the results database (.evadb) per run, plus the input images |
— |
EVAnalyzer checks how much RAM is actually free at startup and scales itself to fit: the number of images/tiles analyzed in parallel is capped accordingly — so on a constrained machine it automatically falls back to fewer parallel workers instead of running out of memory. More RAM and CPU cores let it analyze more images/tiles concurrently, but there's no manual tuning required to stay within what the machine actually has available.
Prebuilt packages are attached to every GitHub release. Download the archive for your platform, extract it, and run the evanalyzer binary — the native dependencies (libtorch, DuckDB) ship inside the archive, next to the binary, so there is nothing else to install.
tar xzf evanalyzer-linux-x86_64.tar.gz
./evanalyzerUnzip the archive and run evanalyzer.exe (keep the .dll files next to it).
tar xzf evanalyzer-macos-arm64.tar.gz
# The build is ad-hoc signed but not notarized, so macOS Gatekeeper quarantines
# it after download. Clear the quarantine flag once, then launch it:
xattr -dr com.apple.quarantine evanalyzer
./evanalyzerKeep every file from the archive in the same folder — the bundled
.dyliblibraries are resolved relative to theevanalyzerbinary.
The CUDA builds bundle the (multi-GB) NVIDIA runtime, so they are published as
split 7-Zip archives (…-cuda.7z.001, .002, …) to stay under GitHub's
per-file limit. Download all volumes into one folder and extract with
7-Zip pointed at the first part:
7z x evanalyzer-linux-x86_64-cuda.7z.001 # finds .002, .003, … automaticallyThen run the evanalyzer binary as for the CPU build. A matching NVIDIA driver
(CUDA 12.x) must be installed on the machine.
Besides the GUI, the evanalyzer binary has a headless batch mode for scripting,
servers, and CI pipelines: evanalyzer cli <command>. Run evanalyzer cli --help
or evanalyzer cli <command> --help for the full flag reference — this section
covers the common workflows.
evanalyzer cli analyze --project my_project.evaproj --images /data/plate1 --threads 8--project— the.evaprojfile to run (required).--images— point the project at a folder and (re-)scan it for images before running. Omit this to use the image list the project already has saved.--threads— images processed in parallel (default: number of CPUs minus one).
Progress is printed as [i/total] <image> while it runs. A new results database
(.evadb) is written under results/<timestamp>__<job-name>/ next to the
project file — the same layout the GUI uses. Press Ctrl+C to cancel; the
in-flight image finishes, no more are started, and the process exits with code
130.
evanalyzer cli project-info --project my_project.evaproj # images, classes, pipelines
evanalyzer cli validate --project my_project.evaproj # do all referenced images exist on disk?Both also accept --json (on project-info) for scripting — see below.
evanalyzer cli view --db results/.../job.evadb --limit 50 --page 0
evanalyzer cli view --db results/.../job.evadb --image sample01.tif --class Nucleus
evanalyzer cli columns --db results/.../job.evadb # column ids for --group-by / chart axesview prints a quick summary (image/class counts, T/Z range) plus a page of object
rows — enough to sanity-check a run without opening the GUI. columns lists every
column id (including per-channel and colocalization-partner columns) available
for grouping and charting.
view, columns, and project-info all accept --json for machine-readable
output, e.g.:
evanalyzer cli view --db job.evadb --json --limit 100 | jq '.rows[].area_px'# CSV / XLSX, optionally grouped and aggregated
evanalyzer cli export csv --db job.evadb --out results.csv
evanalyzer cli export xlsx --db job.evadb --out results.xlsx \
--group-by image --agg avg,median --class Nucleus
# Charts (PNG), rendered with the same code path as the GUI's chart view
evanalyzer cli export chart histogram --db job.evadb --out area.png \
--column area_px --buckets 30 --log-scale
evanalyzer cli export chart scatter --db job.evadb --out scatter.png \
--x area_px --y circularity --color-by class
evanalyzer cli export chart heatmap --db job.evadb --out heatmap.png \
--metric count --cell-size 256All export and view subcommands accept --image <name>, --class <name>
(repeatable) and --colocalized <true|false> to filter rows first.
| Command | Purpose |
|---|---|
analyze |
Run a project's enabled pipelines over its images, writing a new .evadb |
project-info |
Print a project's images/classes/pipelines without running anything |
validate |
Check that every image a project references can be found on disk |
export csv / export xlsx |
Export a results database to a spreadsheet, optionally grouped/aggregated |
export chart histogram/scatter/heatmap |
Render a results database to a chart PNG |
view |
Print a quick summary and a page of rows from a results database |
columns |
List the column ids available for --group-by / chart axes |
cargo build-linuxcargo build-winRequires
cargo-xwin:cargo install cargo-xwin
cargo build-linux-armRequires the cross-toolchain:
apt install gcc-aarch64-linux-gnuandrustup target add aarch64-unknown-linux-gnu
cargo build-macBuild natively on an Apple-Silicon Mac. DuckDB is not bundled on macOS, so set
DUCKDB_LIB_DIR/DUCKDB_INCLUDE_DIRto a prebuilt libduckdb (seelibs/download.sh mac-cpuand thebuild-macosCI job).
EVAnalyzer reads files through Bio-Formats and supports all formats it provides. Common formats include:
| Format | Extension(s) |
|---|---|
| TIFF / BigTIFF | .tif .tiff .btif .btf |
| Zeiss CZI | .czi |
| Nikon ND2 | .nd2 |
| Leica LIF | .lif .lei |
| Olympus VSI | .vsi |
| OME-TIFF | .ome.tiff |
| Slidebook | .sld |
| Leica SCN | .scn |
| JPEG | .jpg .jpeg |
| And many more | .ics .fli .sxm .lim .oir .stk .msr .dm3 .dm4 .svs … |
Pipeline commands (Blur, Threshold, Cellpose, …) are declared once, in
evanalyzer_core, and generated everywhere else. To add one:
-
Write a plain struct in
crates/core/src/algos/<category>/<name>.rs:#[derive(CommandsMeta)] #[cmdsmeta(category = "segment", display_name = "My Command")] pub struct MyCommand { #[cmdsmeta(min = 1, max = 100, step = 1, default = 10)] pub some_number: usize, /// Path to a trained model. #[cmdsmeta(file_extensions = "pt,pth")] pub model_path: PathBuf, } impl ImageAlgorithm for MyCommand { fn execute(&self, ctx: &mut PipelineContext, cache: &mut PipelineCache) -> Result<(), InternalErrors> { /* ... */ } fn name(&self) -> &'static str { "MyCommand" } }
-
Build.
crates/cfg/build/pipeline_commands_generator.rsre-scans every#[derive(CommandsMeta)]struct in the workspace and (re)generatescrates/cfg/src/modules/pipeline_command.rs,pipeline_command_settings.rs, andcrates/core/src/job/algos_from_config.rs(all// @generated - do not edit by hand) - a settings mirror, thePipelineCommandenum variant,to_parameters()/apply_param_change()for the pipeline editor UI, and theFrom<...Settings> for MyCommandconversion all come from this one struct definition. Nothing to wire up by hand.
Field types drive their own UI/behavior generically, purely from the Rust type - no per-field opt-in needed:
PathBuf→ a "Browse…" file picker (ParamType::FilePath) and automatic project-relative storage:evanalyzer_app::extensions::utils:: relativize_file_paths/resolve_file_pathsrewrite everyFilePathfield on save/load (keyed offParamType, not a per-command list), so aPathBuffield on a brand-new command is already portable across moved/ copied project folders with nothing added here.#[cmdsmeta(file_extensions = "...")](comma-separated, no dots) only narrows the file picker's filter - it does not opt the field intoFilePathhandling, that's automatic. See the doc comment oncommands_meta_deriveincrates/core/macros/src/lib.rsfor the full list of field-level#[cmdsmeta(...)]keys (min/max/step/default/unit/regex/optional/visible/file_extensions).
One thing that isn't generated and needs a manual touch: #[derive(...)]
assigns each PipelineCommand variant a numeric id in alphabetical order
by struct name, contiguous from 0. Inserting a new command shifts every
alphabetically-later command's id by one. crates/cfg/tests/pipeline_command_ coverage.rs is a hand-written test file (kept separate because the generated
file it tests can't carry inline tests) that hardcodes some of these ids to
target specific commands - after adding a command, run
cargo test -p evanalyzer_cfg --test pipeline_command_coverage and fix
any id that shifted; a failure there means the test table is stale, not that
the new command broke something.
rustup component add rustfmt
cargo install slint-lsp # Language server for .slint files
cargo install slint-viewer # Live preview of .slint filesAllow X11 forwarding on the host before starting the container:
xhost +local:dockercargo install cargo-llvm-cov
rustup component add llvm-tools-preview
cargo llvm-cov # terminal report
cargo llvm-cov --html # HTML report → target/llvm-cov/
cargo llvm-cov --lcov --output-path lcov.info # lcov format (e.g. VS Code Coverage Gutters)CI (.github/workflows/ci.yml) runs the same cargo llvm-cov on every push to main and every pull request. The full lcov/HTML report is uploaded as a workflow artifact and a text summary is posted to the run's Job Summary; on main it also regenerates the badge above and appends a row to docs/coverage-history.csv (line coverage per commit) - both committed straight to the repo, no external coverage service involved.
cargo install cargo-cyclonedx
cargo cyclonedx --format jsonA CycloneDX SBOM for the shipped evanalyzer binary (covering its full dependency graph, workspace and third-party alike) is generated by .github/workflows/release.yml and attached to every GitHub Release as evanalyzer-<version>-sbom.cdx.json.
cargo install cargo-audit
cargo auditCI (.github/workflows/ci.yml) runs cargo audit against the RustSec advisory database on every push to main and every pull request, same split as coverage: a summary posted to the run's Job Summary on every run, and on main it also regenerates the badge above and appends a row to docs/audit-history.csv. Informational rather than a merge gate for now - it doesn't fail the job on findings.
The About dialog's "Licenses" tab lists every third-party crate and its license text, read from crates/gui/src/generated/third_party_licenses.json - a file committed to the repo, not regenerated automatically by CI or at build time. After adding/upgrading a dependency, regenerate and commit it by hand:
cargo install cargo-about --features cli
cargo about generate docs/about.hbs -o crates/gui/src/generated/third_party_licenses.jsonConfig is in about.toml at the repo root (accepted SPDX licenses); the workspace's own crates are excluded via publish = false + about.toml's [private] section, so the listing only ever covers actual third-party dependencies.
cargo test| Action | Target | Rationale |
|---|---|---|
| Pan / drag | < 10 ms | Must feel attached to the cursor |
| Zoom | < 16 ms | Prevents motion sickness |
| Channel toggle | < 100 ms | Perceived as instant |
| Auto-adjust | < 200 ms | Acceptable for a complex calculation |
Contributions are welcome. Please open an issue before submitting large changes so the direction can be agreed on first.
- Fork the repository
- Create a feature branch:
git checkout -b feat/my-feature - Commit your changes
- Open a pull request
- For questions please visit our forum on image.sc
- For bug reports please create an issue here on github
EVAnalyzer's source code is licensed under the AGPL-3.0. However, the copyright owner grants free use only for academic and non-commercial purposes; commercial use requires a separate commercial license.
| Use case | License |
|---|---|
| Personal, academic, and non-commercial use | AGPL-3.0 — free, source must remain open |
| Commercial use | PolyForm Commercial License — contact us for terms |
If you integrate EVAnalyzer into a commercial product or service, or distribute it as part of a commercial offering, a commercial license is required.
For commercial licensing enquiries, please open an issue or contact the maintainer directly.
- Bio-Formats — Open Microscopy Environment
- Bio-Formats-Rust — Johan Henriksson and his lab for porting Bioformats to Rust
- Slint — cross-platform UI toolkit for Rust
- Kornia-rs — computer vision primitives in Rust
- Skia — 2D graphics renderer
- DuckDB — in-process analytical database
