Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
2dc1ae5
fix(analyse): use shell volume for outer diameter
holsam Oct 14, 2026
990558f
fix(analyse): correct ellipsoid axes derivation
holsam Oct 14, 2026
4c99090
fix(analyse): return NaN for degenerate axes
holsam Oct 14, 2026
e1013c6
fix(analyse): use same mask for lumen & enclosure
holsam Oct 14, 2026
9afdba5
chore: add workflow to check version increments
holsam Oct 14, 2026
f96ad3b
chore: add workflow to publish releases
holsam Oct 14, 2026
4fc3591
pr: merge holsam/gh-workflows to holsam/v2.0.0-release
holsam Oct 14, 2026
c41cbb8
chore: add github pull request template
holsam Oct 14, 2026
1925792
Merge branch 'v2.0.0-release' into analysis-improvements
holsam Oct 14, 2026
82e50ca
pr: merge holsam/analysis-improvements to holsam/v2.0.0-release
holsam Oct 14, 2026
b97cf6b
fix(label): fix relabelling undoing merges
holsam Oct 14, 2026
3c23835
feat(label): write per-file exclusion tally
holsam Oct 14, 2026
056aa9f
fix(model): read max_workers from model config
holsam Oct 14, 2026
70bec53
fix(model): use beam axis at index 0
holsam Oct 14, 2026
567fd8f
chore(model): document BIC caveat & add RMSE flag
holsam Oct 14, 2026
d92cad1
pr: merge holsam/label-improvements to holsam/v2.0.0-release
holsam Oct 14, 2026
7af77d6
pr: merge holsam/model-improvements to holsam/v2.0.0-release
holsam Oct 14, 2026
eae11e1
fix(mrc): warn on anisotropic voxel size
holsam Oct 15, 2026
a80200b
feat(analyse): flag components cut by z edge
holsam Oct 15, 2026
d4a7278
feat(analyse): derive tomogram membrane thickness
holsam Oct 15, 2026
d343b11
fix(label): separate size-floor arc exclusions
holsam Oct 15, 2026
a67e949
fix(model): warn on single-label input
holsam Oct 15, 2026
50d6eaf
feat(mrc): mark fitted output, skip in analyse
holsam Oct 15, 2026
94adc25
pr: merge holsam/misc-improvements to holsam/v2.0.0-release
holsam Oct 15, 2026
3b3cad4
fix(tools): use degraded shell for convex hull
holsam Oct 15, 2026
884634f
fix(tools): record volume-equivalent ground truth
holsam Oct 15, 2026
70e6d10
fix(tools): use shell volume in missing-wedge
holsam Oct 15, 2026
be618df
fix(tools): add independent geoproxy baseline
holsam Oct 15, 2026
7b06bc5
refactor: move figure builders to shared module
holsam Oct 15, 2026
fdbb5b2
feat(plot): add python data preprocessing utils
holsam Oct 16, 2026
2a21965
feat(plot): add svg figure export
holsam Oct 16, 2026
df1d477
refactor(plot): render viewer figures in python
holsam Oct 16, 2026
c8b36a3
feat(plot): add bland-altman agreement plots
holsam Oct 16, 2026
fce952b
docs(plot): update plot command documentation
holsam Oct 16, 2026
81166d7
pr: merge holsam/figure-improvements to holsam/v2.0.0-release
holsam Oct 16, 2026
dc43c4b
pr: merge holsam/tools-improvements to holsam/v2.0.0-release
holsam Oct 16, 2026
3d73a14
fix(label): reject concentric pairs in merge
holsam Oct 19, 2026
d6ef842
fix(label): lower merge centre tolerance
holsam Oct 19, 2026
fdafd7b
fix(analyse): don't record z boundary touch as qc
holsam Oct 19, 2026
9e92061
feat(analyse): measure diameter from widest xy
holsam Oct 19, 2026
b05ec6b
feat(plot): report summary stats by population
holsam Oct 19, 2026
13454ab
feat(plot): use fitted diameter as size axis
holsam Oct 19, 2026
489ef70
feat(plot): compare groups at tomogram level
holsam Oct 19, 2026
299dafb
feat(plot): update stats to be tomogram-level
holsam Oct 19, 2026
16547fe
docs(plot): update plot documentation
holsam Oct 20, 2026
9d49092
test(plot): fix plot command tests
holsam Oct 20, 2026
d6eb2fa
test(analyse): fix analyse command tests
holsam Oct 20, 2026
6e67b26
test(config): update config unit tests
holsam Oct 20, 2026
81c4297
pr: merge holsam/gen-improvements to holsam/v2.0.0-release
holsam Oct 20, 2026
fdfcd17
chore: update evaluator version to v2.0.0
holsam Oct 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
## Pull Request (PR) description

## Issues resolved
n/a
42 changes: 42 additions & 0 deletions .github/workflows/main_publish-release.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Main Branch Create New Release Workflow

on:
push:
branches: [main]

permissions:
contents: write

jobs:
release:
name: Build and publish release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- name: Install uv
uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d

- name: Read version
id: version
run: |
VERSION=$(grep -m1 '^version *= *' pyproject.toml | sed -E 's/version *= *"([^"]+)"/\1/')
if [ -z "$VERSION" ]; then
echo "Could not read version from pyproject.toml" >&2
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"

- name: Build package
run: uv build

- name: Publish release
uses: softprops/action-gh-release@v3
with:
tag_name: v${{ steps.version.outputs.version }}
name: v${{ steps.version.outputs.version }}
generate_release_notes: true
draft: false
files: |
dist/*.whl
dist/*.tar.gz
66 changes: 66 additions & 0 deletions .github/workflows/main_version-update-check.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
name: Main Branch Version Check Workflow

on:
pull_request:
branches: [main]

permissions:
contents: read

jobs:
version-check:
name: Check version incremented
runs-on: ubuntu-latest
steps:
- name: Checkout PR branch
uses: actions/checkout@v7

- name: Read PR version
id: pr_version
run: |
VERSION=$(grep -m1 '^version *= *' pyproject.toml | sed -E 's/version *= *"([^"]+)"/\1/')
if [ -z "$VERSION" ]; then
echo "Could not read version from pyproject.toml" >&2
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"

- name: Read main version
id: main_version
run: |
git fetch origin main --depth=1
VERSION=$(git show origin/main:pyproject.toml | grep -m1 '^version *= *' | sed -E 's/version *= *"([^"]+)"/\1/')
if [ -z "$VERSION" ]; then
echo "Could not read version from main's pyproject.toml" >&2
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"

- name: Install uv
uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d

- name: Compare versions
run: |
PR_VERSION="${{ steps.pr_version.outputs.version }}"
MAIN_VERSION="${{ steps.main_version.outputs.version }}"
echo "PR version: $PR_VERSION"
echo "main version: $MAIN_VERSION"
uv run --with packaging python3 - "$PR_VERSION" "$MAIN_VERSION" <<'EOF'
import sys
from packaging.version import Version, InvalidVersion

pr_raw, main_raw = sys.argv[1], sys.argv[2]

try:
pr = Version(pr_raw)
main = Version(main_raw)
except InvalidVersion as e:
print(f"Could not parse version as PEP 440: {e}", file=sys.stderr)
sys.exit(1)

if pr <= main:
print(f'Version in pyproject.toml ({pr_raw}) is not greater than main ({main_raw}). Versions must strictly increase.', file=sys.stderr)
sys.exit(1)

print(f'OK: {pr_raw} > {main_raw}')
EOF
18 changes: 13 additions & 5 deletions docs/analyse.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Arguments:
Options:
-o, --out-dir PATH Path to output directory. Results will be written under '.../evaluator/analyse/'. [default: .]
--fill-threshold FLOAT [0-1] Override configuration fill threshold parameter for this run.
--membrane-thickness-nm FLOAT [x>0] Override membrane shell thickness (nm) used for diameter derivation, instead of estimating from file.
-h, --help Show this message and exit.

Batch Options:
Expand Down Expand Up @@ -59,6 +60,9 @@ fill_ratio = (filled_volume - original_volume) / filled_volume

A component is classified as enclosed (`is_enclosed = True`) if `fill_ratio > fill_threshold`. The default of `0.05` is deliberately permissive, to accommodate incomplete segmentations where a small fraction of the membrane may not have been captured. Decreasing this threshold includes more components as enclosed; increasing it requires a larger enclosed cavity relative to the membrane.

#### `--membrane-thickness-nm`
This option controls the assumed membrane thickness value used during derivation of a vesicle's outer diameter from the meaured membrane volume. The default value (`"auto"`) causes EValuator to estimate the thickness for each segmentation file as twice the median distance-transform value along the ridge of the membrane mask. Using any other value will override this estimation for every file.

#### `--qc-max-sphere-rmse-rel`, `--qc-max-aspect-ratio`, `--qc-min-solidity`, `--qc-min-arc-coverage`
These four thresholds underpin the vesicle-vs-debris QC flag included in results (`is_vesicle_like` / `qc_flags`, see [Output CSV columns](#output-csv-columns)).

Expand Down Expand Up @@ -87,7 +91,8 @@ Column | Description | Units[^units]
--|--|--
`tomogram` | Filename of the input MRC file from which the EV was identified |
`label` | Unique integer identifier for the EV within its file |
`equiv_diameter_nm`[^equivdiameter] | Diameter of a sphere with the same volume as the membrane component | nm (rounded to 2 d.p.)
`equiv_diameter_nm`[^equivdiameter] | Outer diameter of the vesicle, derived from the membrane shell volume and thickness | nm (rounded to 2 d.p.)
`xy_diameter_nm` | Outer diameter of the widest xy slice (mean of y and x extents), independent of z | nm (rounded to 2 d.p.)
`major_axis_diameter`[^diameters] | Length of the longest axis of the best-fit ellipsoid | nm (rounded to 2 d.p.)
`minor_axis_diameter`[^diameters] | Length of the shortest axis of the best-fit ellipsoid | nm (rounded to 2 d.p.)
`aspect_ratio`[^aspectratio] | Ratio of `major_axis_diameter` to `minor_axis_diameter` | unitless (rounded to 2 d.p.)
Expand All @@ -101,8 +106,11 @@ Column | Description | Units[^units]
`solidity` | Voxel count ÷ convex-hull volume (QuickHull over the component's voxels; ~1 for a solid convex blob, low for sprawling/concave debris) | unitless (0 < s ≲ 1; rounded to 4 d.p.)
`arc_coverage` | Fraction of the fitted sphere's surface (18×36 grid) occupied by the component | unitless (0 ≤ c ≤ 1; rounded to 4 d.p.; `NaN` if < 4 voxels)
`bbox_extent` | Bounding-box fill ratio (`skimage` `regionprops.extent`) | unitless (0 < e ≤ 1; rounded to 4 d.p.)
`touches_z_boundary` | Whether the component's bounding box reaches the first or last z slice (and component therefore may be clipped by reconstruction box) | boolean (True/False)
`is_vesicle_like` | Composite QC flag — `True` unless a vesicle-vs-debris check fails (see [`--qc-*` options](#--qc-max-sphere-rmse-rel---qc-max-aspect-ratio---qc-min-solidity---qc-min-arc-coverage)) | boolean (True/False)
`qc_flags` | Comma-joined names of failed QC checks (`sphere_rmse`, `aspect_ratio`, `solidity`, `open_shell`, `too_small`); empty when clean | string
`membrane_thickness_nm` | Membrane shell thickness used to derive `equiv_diameter_nm` for this file | nm (rounded to 2 d.p.)
`membrane_thickness_source` | How membrane thickness was set: `estimated` (per-file estimate), `override` (config number or CLI) or `fallback` (estimate failed, `2.6` nm used) | text
`voxel_size_nm` | Voxel size in nanometres as read from MRC file header, or None if not present | nm (rounded to 4 d.p.)
`measurement_units` | Units used for measurements | nm if voxel size was available, otherwise vox

Expand All @@ -118,7 +126,7 @@ Pipeline run summary
- Segmentation files with EVs: 9 (90.0%)
- EVs processed: 87
- Number of enclosed EVs: 71 (81.6%)
- Equivalent diameters: 112.4 ± 48.3 nm (mean ± SD)
- Outer diameters: 112.4 ± 48.3 nm (mean ± SD)
- Vesicle-like components: 63 / 87 (72.4%)
Results saved to: .../evaluator/analyse/evaluator-analyse_results.csv
```
Expand All @@ -132,14 +140,14 @@ Results saved to: .../evaluator/analyse/evaluator-analyse_results.csv

[^units]: Units given here assume the voxel size in nanometres was read from the MRC file header. If this is not the case, units will not be scaled to physical units and will be in voxels/voxels³.

[^equivdiameter]: `equiv_diameter_nm` is not the measured diameter of an EV. Given the volume of all membrane voxels (Vm), the equivalent diameter is calculated as `(6Vm/π)**(1/3)`, which assumes the shape of the component is a perfect sphere. This is likely not a valid assumption for biological EVs, and `equiv_diameter_nm` is therefore only used as a rough proxy during size filtering and for subsequent axis scaling.
[^equivdiameter]: `equiv_diameter_nm` is not the measured diameter of an EV but a derived outer diameter estimate. Given the volume of all membrane voxels (Vm), the equivalent diameter is derived from solving `4π/3·(R^3 - (R-t)**3) = Vm` for the outer radius `R` (which provides equivalent diameter via `equiv_diameter_nm = 2R`) and membrane thickness `t` (estimated or provided via `--membrane-thickness-nm`). This estimate assumes a fixed membrane thickness per-file and roughly spherical shell, so is unlikely to be a valid model for biological EVs, and is therefore only used as a rough proxy during size filtering and for subsequent axis scaling.

[^diameters]: `major/minor_axis_diameter` are the more accurate measurements of EV size, calculated from the best-fit ellipsoid that matches the EV morphology. Both measurements are derived from the eigenvalues of the component's inertia tensor, which are scaled using `equiv_diameter_nm`.
[^diameters]: `major/minor_axis_diameter` approximate EV diameters using the best-fit ellipsoid implied by the components inertia tensor eigenvalues after scaling using `equiv_diameter_nm`. They inherit the equivalent diameter's assumptions, but the ratio between diameters reflects the true shape.

[^aspectratio]: A perfect sphere would have an `aspect_ratio` of 1.0. An EV with `aspect_ratio` greater than 1.0 can be described as a prolate ellipsoid (i.e. elongated), whereas an EV with `aspect_ratio` less than 1.0 can be described as an oblate ellipsoid (i.e. flattened).

[^eccentricity]: Eccentricity is calculated as `sqrt(1 - (c/a)^2)` where `a` and `c` are the semi-major and semi-minor axes respectively. A perfect sphere would have `eccentricity` approaching 0, whereas an infinitely elongated ellipsoid would have `eccentricity` approaching 1.

[^lumenvol]: This volume is calculated by filling the membrane mask and subtracting all membrane voxels. Non-enclosed components will have `lumen_volume` of 0.
[^lumenvol]: This volume is calculated by filling the gap-bridged membrane mask (used for is_enclosed check) and subtracting all membrane voxels. Non-enclosed components will have `lumen_volume` of 0.

[^surfacearea]: Surface area is computed using the marching cubes algorithm as implemented by `skimage.measure.marching_cubes`. If the marching cubes algorithm fails (e.g. for very small or degenerate components), `NaN` will be returned.
7 changes: 5 additions & 2 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,13 @@ format = "json"
# Label command default configuration parameters
[label]
min_arc_coverage = 0.30
merge_centre_tol_factor = 1.5
merge_centre_tol_factor = 0.573
merge_radius_tol_pct = 0.30
merge_concentric_radius_pct = 0.10
merge_concentric_centre_frac = 0.25
maximum_diameter_nm = 500.0
minimum_diameter_nm = 20.0
membrane_thickness_nm = 7
membrane_thickness_nm = 2.6
max_workers = 0

# Model command default configuration parameters
Expand All @@ -117,6 +119,7 @@ max_workers = 0
# Analyse command default configuration parameters
[analyse]
fill_threshold = 0.05
membrane_thickness_nm = "auto"
max_workers = 0
qc_max_sphere_rmse_rel = 0.25
qc_max_aspect_ratio = 2.5
Expand Down
2 changes: 1 addition & 1 deletion docs/label.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ After merging, each component's coverage of the surface of its best-fit sphere i
These options filter components by their equivalent sphere diameter, using the configured membrane thickness to convert diameter to an expected voxel count range. The default range (`20`-`500` nm, set in `config.toml`) is appropriate for typical EV preparations, which include small exosomes (~30-150 nm) through to larger MVB-derived vesicles (~200-500 nm). If no voxel size is present in the input MRC header, this filter is skipped and all components pass.

#### Component merge configuration settings
The merge step (centroid-proximity and radius-agreement tolerances) is controlled separately via `merge_centre_tol_factor` and `merge_radius_tol_pct` in `config.toml` under `[label]`; there is no CLI override for these.
The merge step (centroid-proximity and radius-agreement tolerances) is controlled separately via `merge_centre_tol_factor`, `merge_radius_tol_pct`, `merge_concentric_radius_pct` and `merge_concentric_centre_frac` in `config.toml` under `[label]`; there is no CLI override for these. Pairs whose centres are within `merge_concentric_centre_frac` (default a quarter) of the smaller radius but have radii that differ by more than `merge_concentric_radius_pct` are treated as concentric membranes (MVBs, multilamellar vesicles) and are not merged. Concentric pairs with radii differing by less than this fraction are indistinguishable from split fragments, and thus merged to a single component.

## Output

Expand Down
Loading
Loading