diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..b336260
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,44 @@
+# VCS
+.git
+.gitignore
+.github/
+
+# Python cache/bytecode
+__pycache__/
+*.py[cod]
+*$py.class
+
+# Build/packaging artifacts
+build/
+dist/
+*.egg-info/
+.eggs/
+
+# Test and coverage artifacts
+.pytest_cache/
+.coverage
+coverage.xml
+htmlcov/
+.mypy_cache/
+.ruff_cache/
+
+# Virtual environments
+.venv/
+venv/
+env/
+
+# Local editor/tooling
+.vscode/
+.idea/
+.DS_Store
+
+# Local data and outputs
+tmp/
+out/
+results/
+results_cache/
+*.zip
+
+# Optional: keep large or generated content out of image context
+Data/
+fibermorph/test_data/
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..b10b2bd
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,6 @@
+# Normalize line endings: text files always LF in the repo
+* text=auto eol=lf
+*.png binary
+*.tif binary
+*.tiff binary
+*.jpg binary
diff --git a/.github/workflows/conventional-prs.yaml b/.github/workflows/conventional-prs.yaml
index d926109..64073c6 100644
--- a/.github/workflows/conventional-prs.yaml
+++ b/.github/workflows/conventional-prs.yaml
@@ -1,4 +1,5 @@
name: PR
+
on:
pull_request_target:
types:
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index 2b45e93..fad5f16 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -2,7 +2,7 @@ name: Test
on:
push:
- branches: [ main, master ]
+ branches: [ main, master, fibermorph-dev, "feature/**" ]
pull_request:
branches: [ main, master ]
workflow_dispatch:
@@ -23,28 +23,31 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
- - name: Install Poetry
- uses: snok/install-poetry@v1
- with:
- version: latest
- virtualenvs-create: true
- virtualenvs-in-project: true
+ - name: Install system dependencies
+ run: |
+ sudo apt-get update -qq
+ sudo apt-get install -y --no-install-recommends \
+ libgl1 \
+ libglib2.0-0 \
+ libsm6 \
+ libxext6
- - name: Cache Poetry dependencies
+ - name: Cache pip
uses: actions/cache@v4
with:
- path: |
- ~/.cache/pypoetry
- .venv
- key: ${{ runner.os }}-poetry-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }}
+ path: ~/.cache/pip
+ key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('pyproject.toml') }}
restore-keys: |
- ${{ runner.os }}-poetry-${{ matrix.python-version }}-
+ ${{ runner.os }}-pip-${{ matrix.python-version }}-
- - name: Install dependencies
- run: poetry install
+ - name: Install package and test dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e ".[gui]"
+ pip install pytest pytest-cov
- name: Run tests with coverage
- run: poetry run pytest --cov=fibermorph --cov-report=xml --cov-report=term
+ run: pytest --cov=fibermorph --cov-report=xml --cov-report=term
- name: Upload coverage reports
uses: codecov/codecov-action@v4
@@ -53,4 +56,4 @@ jobs:
file: ./coverage.xml
fail_ci_if_error: false
token: ${{ secrets.CODECOV_TOKEN }}
- continue-on-error: true
+ continue-on-error: true
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 3ba121e..3459e79 100644
--- a/.gitignore
+++ b/.gitignore
@@ -132,9 +132,7 @@ __pycache__
coverage.xml
.htmlcov/
*.coverfibermorph/fibermorph_old.py
-
-# Claude Code local config (launch.json, settings, etc.)
-.claude/
+/.claude
# Large test images live in tinalasisi/fibermorph_DemoData and are fetched on demand
*.tif
diff --git a/.streamlit/config.toml b/.streamlit/config.toml
index c86bdea..f8cd142 100644
--- a/.streamlit/config.toml
+++ b/.streamlit/config.toml
@@ -1,8 +1,10 @@
+# Lasisi Lab design system (fibermorph GUI)
[theme]
-primaryColor = "#FF4B4B"
-backgroundColor = "#FFFFFF"
-secondaryBackgroundColor = "#F0F2F6"
-textColor = "#262730"
+base = "light"
+primaryColor = "#05686D" # teal-700 — functional / click colour
+backgroundColor = "#FBFCFD" # paper-2
+secondaryBackgroundColor = "#F2F2F2" # paper-3
+textColor = "#3F4251" # ink-700 body; Open Sans loaded via CSS
font = "sans serif"
[server]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e33677e..0b3f8ce 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,351 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+> These changes refine the (not-yet-published) 2.0.0 line. The current PyPI
+> release is 1.0.1; 2.0.0 lives on the `fibermorph-dev` branch and has not been
+> tagged. At release time these entries can be folded into 2.0.0 or tagged a
+> new version.
+
+### Breaking Changes
+- **`fibermorph-gui` now starts in hosted mode; local mode requires `--local`.**
+ Before, every `fibermorph-gui` launch silently enabled local mode: it set
+ `FIBERMORPH_LOCAL=1`, showed a "Folder on disk" input that reads folders on the
+ machine running the app, and raised the upload cap to 5 GB. Now plain
+ `fibermorph-gui` starts hosted mode: uploads only, a 500 MB upload cap
+ (`--server.maxUploadSize 500`), no folder input, and `FIBERMORPH_LOCAL` is left
+ unset. To run on your own machine as before, use
+ `fibermorph-gui --local` (or set `FIBERMORPH_LOCAL=1` in the environment), which
+ also now listens on `localhost` only (`--server.address localhost`). Servers and
+ containers that start the app with `fibermorph-gui` (for example the repository's
+ Dockerfile) therefore now get hosted mode instead of exposing server folders to
+ every visitor. Hosted mode passes no address or port, so settings such as the
+ Dockerfile's `STREAMLIT_SERVER_PORT=7860` and `STREAMLIT_SERVER_ADDRESS=0.0.0.0`
+ still apply. A container started with `FIBERMORPH_LOCAL=1` now listens on
+ `localhost` only and needs `--server.address 0.0.0.0` on the command to be
+ reachable.
+- **Hosted visitors can no longer set the SAM2 checkpoint.** In hosted mode
+ (plain `fibermorph-gui`) the Cross-Section view has no "SAM2 checkpoint path"
+ box: the server always uses the file named by `SAM2_CHECKPOINT`, and
+ its path is not shown in any view (the Run Remote view's checkpoint box now
+ starts empty). fibermorph does not ship a SAM2 checkpoint, so a host that
+ wants SAM2 segmentation must download one, set `SAM2_CHECKPOINT` to it on a
+ machine with a GPU, and restart the app after changing it. Local mode (`--local`) is unchanged. Details are in
+ **Security** below.
+- **How the launcher's server options interact with Streamlit's settings.**
+ `--server.maxUploadSize` (500 MB hosted, 5000 MB local) is passed on the command
+ line, so it overrides `.streamlit/config.toml`; it is not passed when the
+ `STREAMLIT_SERVER_MAX_UPLOAD_SIZE` environment variable is set, so a host can set
+ its own cap that way. In local mode `--server.address localhost` is always passed
+ and overrides `STREAMLIT_SERVER_ADDRESS` and `config.toml`, so folder input is
+ never opened to other machines by accident; pass `--server.address
` to
+ `fibermorph-gui` to change it, and the startup notice then warns that the app is
+ reachable from other machines. An empty `--server.address=` counts as changing
+ it: Streamlit then listens on every interface, and the notice says so.
+- **Curvature has one method: CLAHE preprocessing and extended curvature are
+ removed.** `--use-clahe` and `--extended-curvature` now stop with an error
+ (exit status 2), the `use_clahe` / `extended_curvature` parameters are gone,
+ the `curl_index`, `curl_index_std`, `wave_count`, `wave_count_per_mm` and
+ `length_total` columns of the `curvature_seq` / batch output are gone (the
+ GUI's own "Total Length (mm)" column stays), and `run_batch` /
+ `workflows.batch` curvature output now uses the original method instead of
+ the medial-axis path. The old batch values varied from run to run and cannot
+ be reproduced exactly, so batch curvature results made before this change
+ should be re-run. SBATCH scripts made earlier by the GUI's Run Remote view
+ (which turned extended curvature on by default) contain
+ `--extended-curvature` and must have it deleted. Details are in **Removed**
+ below.
+- **A zero, negative or absurdly long `--window_size` now stops with an error.**
+ With `--window_unit px`, a zero or negative `--window_size` used to run: the
+ log said `less than 10 pixels, using full length` and every hair was measured
+ over its whole length. It now exits with code 2 and a usage message before any
+ analysis starts, whichever module is chosen. Use a window of 10 pixels or more,
+ or `--window_size none` (or leave the option out) to fit whole hairs. A script
+ that writes such a value, for example one downloaded earlier from the Run
+ Remote view with a "Taubin window (px)" of 0 or less (the view now refuses
+ such values), needs a positive window. A window
+ longer than 1,000,000,000 pixels (for `mm`, after conversion with
+ `--resolution_mm`) is refused in the same way: no image has a hair that long.
+ Such a window used to run and measure nothing (`hair_count` 0), or end in a
+ traceback when it was extremely long.
+- **A negative `--minsize` now stops with an error.**
+ `fibermorph --section --minsize -5` used to run, the negative value acting
+ as a lower bound of 0. It now exits with code 2 and a usage message before
+ any image is read, whichever module is chosen. Use `--minsize 0` or a
+ positive number. A script that writes a negative minimum, for example one
+ downloaded earlier from the Run Remote view with a "Min diameter (µm)" below
+ 0 (the view now refuses that), needs a value of 0 or more.
+
+### Added
+- **Extra command-line arguments to `fibermorph-gui` reach Streamlit.** They are
+ passed through to `streamlit run` after the launcher's own options, so they can
+ override them (e.g. `fibermorph-gui --local --server.port 8600`). Before, every
+ extra argument was silently ignored.
+- **Resolution in either direction.** GUI and CLI now accept resolution as
+ pixels-per-unit *or* unit-per-pixel and convert internally — GUI unit selector
+ (px/µm ↔ µm/px, px/mm ↔ mm/px), CLI `--resolution_mu_units {px_per_um,um_per_px}`
+ and `--resolution_mm_units {px_per_mm,mm_per_px}`. Prevents the
+ µm/px-vs-px/µm mix-up that silently corrupted calibrated measurements.
+ (`fibermorph.utils.units`)
+- **Per-fragment curvature in the GUI.** The Curvature view reports each detected
+ fiber fragment's length and mean/median curvature (one row per fragment), a
+ per-image summary, and per-sample + pooled distribution histograms (shared
+ x-axis for cross-sample comparison).
+- **Run Local.** `fibermorph-gui --local` runs the same GUI on your own machine
+ with the upload cap raised to 5 GB and a "Folder on disk" input that reads images
+ straight from a directory (no upload). A Run Local view documents this and
+ shows whether you are running hosted or local.
+- `fibermorph.gui.inputs`: `save_uploads()`, `display_name()`, `safe_extension()`
+ and `restore_names()` for saving uploaded files under generated names and
+ putting the uploaded name back into error messages, plus `format_upload_cap()`
+ for showing the upload cap as "500 MB" or "5 GB" (all usable and testable
+ without running Streamlit).
+- `fibermorph-gui --help` documents `--local`; the launcher prints which mode it
+ is starting in.
+- **Lasisi Lab GUI design system** (`fibermorph.gui.styles`): a left sidebar
+ console (brand lockup, grouped nav with SVG glyphs, status footer), per-view
+ headers, at-a-glance metric cards, and brand-colored charts.
+- Help text for the Taubin window control in the GUI.
+- **Golden curvature tests** (`fibermorph/test/test_curvature_golden.py`) that
+ compare per-hair and summary curvature against output of the original v0.3.1
+ code (scikit-image 0.16.2) for a window in px, a window in mm and the
+ whole-hair mode. They run offline on a small synthetic image; a second set
+ downloads the two lab demo curvature images (about 6 MB, fetched once per
+ test run) and is skipped if the download fails (or reads them from
+ `FIBERMORPH_DEMO_CURV_DIR`). The references and the script that made them are
+ in `fibermorph/test/test_data/curv_golden/`.
+
+### Changed
+- **The GUI says when SAM2 can't run, and where it can.** The Cross-Section
+ "Use SAM2 segmentation" switch could be turned on in the Streamlit-hosted app,
+ which has no GPU, and every image then quietly used watershed. The app now
+ checks for the `sam2` package and a CUDA GPU (`sam2_runtime_status()` in
+ `fibermorph.processing.section_sam2`). When either is missing the switch is
+ off and locked, a warning gives the one-line reason, and it points to **Run
+ Local** (`fibermorph-gui --local` on a GPU machine) and **Run Remote** (a
+ cluster script with SAM2) as the ways to run SAM2. The Run Local and Run
+ Remote views and the README ("Online vs local") now say the same.
+- **The upload cap shown in the GUI is the cap Streamlit enforces.** The sidebar
+ status and the Run Local view used to say "500 MB" (hosted) or "5 GB" (local)
+ whatever cap was set. They now read Streamlit's `server.maxUploadSize`, so a
+ host that sets `STREAMLIT_SERVER_MAX_UPLOAD_SIZE`, `--server.maxUploadSize` or
+ `config.toml` sees the same number in the app as in the file uploader. With no
+ setting, the text is the same as before (500 MB hosted, 5 GB local).
+- **GUI is now a sidebar console** with four views — **Cross-Section**,
+ **Curvature**, **Run Local**, **Run Remote** — replacing the previous top tab
+ bar (there is no "Submit & Monitor" or "Results" tab).
+- **"Run at scale" → "Run Remote".** It builds a downloadable SBATCH script with
+ generic placeholders; it does not submit or monitor jobs and no longer bakes in
+ personal account/partition/path defaults.
+- **Curvature output refocused.** Fragment-level length + mean/median curvature
+ are the primary output.
+- **Per-image analysis only.** Filename parsing and per-sample grouping removed;
+ each result row records only its `source_file`.
+- `fibermorph-gui` seeds an empty Streamlit credentials file on first run
+ (non-destructive) so it does not stall on Streamlit's one-time email prompt.
+
+### Removed
+- **Curvature diameter metric** (`diameter_mean_mu`, `diameter_cv`) and its
+ medial-axis distance-map computation — a v2 fork addition that is not yet
+ validated.
+- **CLAHE preprocessing and extended curvature metrics for curvature.**
+ Curvature analysis now has exactly one method, the published fibermorph
+ method (v0.3.1): Frangi ridge filter (the scikit-image 0.16.2 version) ->
+ binarize -> remove particles -> skeletonize -> prune -> Taubin circle fits.
+ Neither option was part of it.
+ - What was removed: `filter_curv_clahe` (CLAHE, then the installed
+ scikit-image Frangi filter, then an Otsu threshold computed only on the
+ middle 70% of the image, ignoring the top and bottom 15%); the medial-axis
+ skeleton path; `curl_index_from_skeleton`, `wave_count` and their helper
+ `pixel_length_correction_coords`; the output columns `curl_index`,
+ `curl_index_std`, `wave_count`, `wave_count_per_mm` and `length_total`;
+ the `use_clahe` and `extended_curvature` parameters of `curvature_seq`,
+ `workflows.curvature`, `workflows.batch` and `run_batch`; the CLI flags
+ `--use-clahe` and `--extended-curvature`. In the GUI: the "CLAHE
+ preprocessing" toggle (Curvature and Run Remote views), the "Show extended
+ (experimental) metrics" toggle with the Curl Index, Wave Count and
+ Waves/mm columns of the Curvature summary, and the Run Remote "Extended
+ curvature metrics" switch. The GUI's per-image "Total Length (mm)"
+ (`length_total` in the downloaded CSV) stays: the app sums the fragment
+ lengths itself and it never came from the removed pipeline column.
+ - Why: neither option was validated against the published method. CLAHE
+ used the installed scikit-image's Frangi filter, so its output changed with
+ the scikit-image version, and it ignored the top and bottom 15% of the
+ image. The extended path replaced `skeletonize` with `medial_axis`, which
+ breaks ties at random, so repeated runs on the same image could differ.
+ - Scripts that still use the flags: `--use-clahe` and `--extended-curvature`
+ remain accepted by the parser but are hidden from `--help`. A command that
+ includes either stops before doing any work with exit status 2 and a
+ message saying the option was removed because it is not part of the
+ published curvature method. SBATCH scripts generated earlier by the GUI's
+ Run Remote view usually contain `--extended-curvature` (its switch was on
+ by default); delete the flag from them. Python code that passes
+ `use_clahe=` or `extended_curvature=` gets a `TypeError`, and importing a
+ removed function fails.
+ - Batch curvature output changes: `run_batch` and `workflows.batch`
+ defaulted `extended_curvature` to `True`, as did the Run Remote switch, so
+ batch curvature (the curvature rows of `hair_analysis_per_image.csv`) came
+ from the medial-axis skeleton and carried the five extra columns. It now
+ uses the original method: the extra columns are gone and the values change
+ to the published ones. The old batch values were not reproducible, because
+ `medial_axis` breaks ties at random: on the synthetic test image (50 px
+ window, 132 px/mm) `curv_mean_mean` came out between about 0.80 and 0.82
+ from one run to the next, and is now 0.8024 on every run, the v0.3.1
+ value. Batch curvature results produced before this change cannot be
+ reproduced exactly and should be re-run. `workflows.curvature`,
+ `curvature_seq` and the command line defaulted to off, so their default
+ results are unchanged.
+- **Per-sample batch aggregation** (`hair_analysis_per_sample.csv`); the batch
+ pipeline now emits a single per-image table.
+
+### Fixed
+- **Curvature now reproduces the original published method (v0.3.1) whichever
+ scikit-image is installed.** The ridge-detection
+ step called `skimage.filters.frangi`, whose output changed after scikit-image
+ 0.16.2 (default `gamma` 15 → computed from the image, and changes to the
+ Hessian computation), so the same fibermorph code gave different curvature on
+ different scikit-image versions. Example: on the straight-hair demo image
+ (`027_demo_nocurv`, 50 px window, 132 px/mm) mean curvature is 0.0526 mm⁻¹
+ with scikit-image 0.16.2 but 0.173 mm⁻¹ with 0.26 (3.3×); on the curly demo
+ image (`004_demo_curv`) mean curvature changes by 1.1%, median curvature by
+ 3.4% and length by 2.9%. `filter_curv` now uses
+ `fibermorph.core.frangi_v016.frangi_v016`, a copy of the 0.16.2 filter
+ (BSD-3-Clause, scikit-image copyright and license kept in the file), which
+ matches scikit-image 0.16.2 to about 1e-16. **Curvature values differ from
+ those produced by 0.3.x and 1.0.x releases or by the unreleased 2.0.0 code on newer
+ scikit-image** (they now agree with the original published values).
+ This now holds for every entry point, including `run_batch`,
+ `workflows.batch` and the GUI: the two options that did not reproduce
+ v0.3.1 (CLAHE and extended curvature) are removed, see **Removed**.
+- **Whole-hair curvature mode and per-hair `test` output restored.** The
+ refactor first released in 0.3.7 dropped two behaviors of the original `window_iter`
+ (`fibermorph.core.curvature`): (1) with `window_size=None` (the CLI default
+ when `--window_size` is omitted) it returned an empty table instead of fitting
+ one circle to each hair; it now again fits one Taubin circle per hair, keeps
+ hairs longer than 0.5 × resolution pixels, and returns `ID, curv_mean,
+ curv_median, length_mean, length_median, hair_count`; (2) with `test=True` it
+ returned the image summary instead of one row per hair (`curv_mean,
+ curv_median, length`, or `curv, length` for whole hair), which broke the
+ simulated-data validation (`fibermorph.demo.demo.validation_curv` /
+ `dummy_curv`) that reads `length` and `curv_median` per hair. Whole-hair mode
+ on an image with no hair above the minimum length returns `hair_count` 0 and
+ NaN means rather than raising an error.
+- **Section resolution unit mislabel** (`µm/px` where the code needs `px/µm`) in
+ the GUI and docstrings — the cause of "empty mask" segmentation failures on
+ correctly-focused images.
+- US spelling throughout the GUI (analyze, fiber, color).
+- **Uploads with the same filename were all measured as the last one.** The GUI
+ saved each upload to a temporary path named after the uploaded file, so two
+ uploads with the same name overwrote each other and both result rows measured
+ the second file, even though the on-screen warning said each file is measured
+ separately. Uploads are now saved under distinct generated names, and each is
+ measured on its own; `source_file` (and the `ID` / `mask_filename` columns of
+ cross-section results, the mask previews, the curvature tables and the error
+ messages shown for a file) still show the uploaded filename.
+- **`fibermorph --curvature --window_size` no longer crashes.** The values were
+ read as text (the same bug is in the original v0.3.1 command line), so
+ `--window_unit mm` failed with `TypeError: can't multiply sequence by non-int
+ of type 'float'`, and `--window_size 50.0` with `--window_unit px` failed with
+ `ValueError: invalid literal for int()`; only whole numbers of pixels such as
+ `50` worked, by accident. `--window_size` is now read as numbers: one or more
+ values (a sweep such as `--window_size 25 50 100`), or `none` for the whole
+ hair, which is also the default when the option is left out. With
+ `--window_unit px` each value must be a whole number (`50` and `50.0` both
+ give 50); with `mm` it can be any number greater than 0 (`0.5`). A
+ non-numeric or non-finite value, a fraction of a pixel with `px`, or `none`
+ repeated or combined with numbers is reported as a usage error (exit code 2)
+ instead of a traceback; zero, negative and over-long windows (more than
+ 1,000,000,000 pixels, the largest accepted) are covered under
+ **Breaking Changes**. A window shorter than 10 pixels (for `mm`, after
+ conversion with `--resolution_mm`) is not used as given: each hair is
+ measured over its whole length in one window, a warning is logged, and the
+ output is still named after the window you asked for (`..._WindowSize-0.001mm`).
+ The values are checked whichever module is run. Output file names are
+ unchanged (`..._WindowSize-50px`, `..._WindowSize-0.5mm`).
+ `fibermorph.cli.main` and `parse_args` now accept an optional list of
+ arguments (default: the command line).
+- **Out-of-range numbers on the command line now give a usage message instead of
+ a traceback.** These all ended in a traceback or an empty result: `--jobs 0`
+ (joblib: `n_jobs == 0 in Parallel has no meaning`); `--resolution_mm` or
+ `--resolution_mu` of 0, a negative number, `nan` or `inf` (`Resolution must be
+ greater than 0.`, `cannot convert float NaN to integer`, or `OverflowError`; a
+ zero or negative `--resolution_mm` also stopped `--section` and `--raw2gray`
+ runs, which do not use it; a `nan` or `inf` `--resolution_mu` with
+ `--section` ended in `KeyError: "None of ['ID'] are in the columns"` or in
+ exit code 0 with a table that has no rows); `--maxsize 0`; and a `--minsize`
+ larger than `--maxsize` (no section can match). They now exit with code 2
+ before any image is read, whichever module is chosen, and the message names
+ the option. The rules:
+ `--jobs` must not be 0 (`-1` for every CPU still works), and a `--jobs` larger
+ than the machine's CPU count is reduced to that count with a message (a huge
+ value used to crash joblib with `OverflowError` or start thousands of worker
+ processes); `--resolution_mm` and
+ `--resolution_mu` must be finite and greater than 0, also after conversion
+ from `mm_per_px` or `um_per_px`; `--minsize` must be 0 or more, `--maxsize`
+ greater than 0, and `--minsize` no larger than `--maxsize`. A negative
+ `--minsize`, which used to run as if it were 0, is refused too (see
+ **Breaking Changes**). Not covered: a `--section` run whose limits are valid
+ but match no section (for example `--minsize 400 --maxsize 500` on images
+ with thinner hairs, or an absurdly large `--resolution_mu`) still ends in the
+ same `KeyError`; that comes from the section workflow, which is not changed
+ here.
+
+### Security
+- **Hosted visitors can no longer choose the SAM2 checkpoint path.** In the
+ Cross-Section view's settings, the "SAM2 checkpoint path" text box is now shown
+ only in local mode. In hosted mode the server's own checkpoint (the file named
+ by `SAM2_CHECKPOINT`) is always used, and a caption says whether a
+ checkpoint file was found (and that SAM2 also needs a GPU on the server), without
+ showing the server path. Before, any visitor could type a path and the server
+ would try to load that file as a model. The server's
+ checkpoint path is not shown in any hosted view: the Run Remote view's
+ "SAM2 checkpoint path" box, which only fills in the generated SBATCH script text
+ and is never loaded by the server, now starts empty in hosted mode (in local mode
+ it is still pre-filled), and the script uses `YOUR_SAM2_CHECKPOINT` there if the
+ box is left blank. The first SAM2 model the server loads is kept for the life of
+ the process, so restart the app after changing `SAM2_CHECKPOINT` or replacing the
+ checkpoint file.
+- **Uploaded filenames are no longer used to build paths on the server.** The
+ GUI wrote each upload to `/`, so a crafted name
+ (an absolute path, `..` segments, Windows `\` separators) could write outside the
+ temporary directory. Uploads are now written as `upload_0001.`,
+ `upload_0002.`, ... directly inside the temporary directory (the original
+ extension is kept only if it is tif, tiff, png, jpg or jpeg); the uploaded
+ filename is used only as a display label, reduced to its last path component.
+
+## [2.0.0] - 2026-05-14
+
+### Breaking Changes
+- Section analysis output now includes EFD (40 coefficients), Hu moments (7), radial profile (7 metrics), and `shape_class` columns when `--extended-features` is used
+- Curvature output includes `curl_index`, `wave_count`, `diameter_mean_mu`, `curv_std`, `curv_cv`, `curv_iqr` columns when `--extended-curvature` is used
+
+### Added
+- **SAM2 segmentation** (optional GPU): `--use-sam2` flag; falls back to watershed automatically when SAM2 is unavailable
+ - Install: `pip install git+https://github.com/facebookresearch/segment-anything-2`
+ - Checkpoint: place `sam2.1_hiera_tiny.pt` in `fibermorph/checkpoints/`
+- **Extended section features**: EFD (40 coefficients), Hu moments (7), radial distance profile (7 metrics + asymmetry index), shape classification into 7 morphotypes (`--extended-features`)
+- **CLAHE preprocessing** for curvature: `--use-clahe` flag improves results on images with uneven illumination
+- **Extended curvature metrics**: curl index (chord/arc ratio), wave count (peak detection), diameter statistics from medial axis (`--extended-curvature`)
+- **Multi-factor candidate scoring** for cross-section segmentation: center-bias + circularity + solidity + darkness
+- **Batch pipeline**: `fibermorph.workflows.batch()` produces `hair_analysis_per_image.csv` and `hair_analysis_per_sample.csv`
+- **Filename metadata parsing**: `{SAMPLEID}_{REGION}_{REPLICATE}` convention via `fibermorph.utils.metadata.parse_metadata()`
+- **5-tab Streamlit GUI**: Quick Test, Segmentation Preview, Batch (Cluster), Submit & Monitor, Results
+- **18 publication-ready visualization figures** via `fibermorph.gui.visualizations`
+- **SLURM SBATCH script generation** in the GUI Batch tab (calls `fibermorph` CLI)
+- **GPU Docker target**: two-stage CPU + GPU build (`docker build --target cpu` or `--target gpu`)
+- `opencv-python-headless` as a core dependency (required for headless server and container environments)
+- `seaborn` as an optional dependency (included in `[viz]` and `[gui]` extras)
+
+### Kept from v1
+- `raw2gray` RAW-to-grayscale conversion pipeline (unchanged)
+- Demo data download (`--demo_real_curv`, `--demo_real_section`)
+- `within_element` per-hair curvature CSV (`--within_element`)
+- Multi-window sweep: `--window_size` accepts a list of values
+- Taubin circle fitting core (`taubin_curv`)
+- `pixel_length_correction` (√2 diagonal arc-length correction)
+- Timestamped output directories
+- `--save_image` intermediate image saving
+- All existing CLI flags (backward compatible; new flags are additive and default to off)
+
## [1.0.1] - 2025-11-06
### Fixed
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index a469f1b..58e207b 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -27,7 +27,7 @@ git checkout -b my-feature upstream/fibermorph-dev
python3.12 -m venv .venv && source .venv/bin/activate # Python 3.10–3.12
pip install -e '.[gui]' # editable install with the GUI extra
pytest # run the test suite
-fibermorph-gui # launch the GUI locally
+fibermorph-gui --local # launch the GUI locally (without --local: hosted mode)
```
## Pull requests
@@ -35,7 +35,7 @@ fibermorph-gui # launch the GUI locally
- **Target `fibermorph-dev`.**
- Use a **Conventional Commits** PR title — the `title-format` CI check requires
one. Start with a type: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`,
- `chore:`, `ci:`, etc. Example: `feat: validate wave-count on synthetic fibers`.
+ `chore:`, `ci:`, etc. Example: `fix: handle an empty curvature directory`.
- Make sure `pytest` passes before requesting review.
## Test data
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..713cb33
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,87 @@
+# ── Stage 1: CPU-only (default) ─────────────────────────────────────────────
+FROM python:3.11-slim-bookworm AS cpu
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ PIP_NO_CACHE_DIR=1 \
+ PIP_DISABLE_PIP_VERSION_CHECK=1 \
+ STREAMLIT_SERVER_HEADLESS=true \
+ STREAMLIT_SERVER_PORT=7860 \
+ STREAMLIT_SERVER_ADDRESS=0.0.0.0 \
+ STREAMLIT_BROWSER_GATHER_USAGE_STATS=false
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ libgl1 \
+ libglib2.0-0 \
+ libsm6 \
+ libxext6 \
+ tini \
+ curl \
+ && rm -rf /var/lib/apt/lists/*
+
+RUN useradd -m -u 1000 user
+USER user
+ENV HOME=/home/user \
+ PATH=/home/user/.local/bin:$PATH
+WORKDIR $HOME/app
+
+COPY --chown=user . $HOME/app
+
+RUN python -m pip install --upgrade pip && \
+ python -m pip install ".[gui]"
+
+EXPOSE 7860
+
+HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
+ CMD curl -fsS http://127.0.0.1:7860/_stcore/health || exit 1
+
+ENTRYPOINT ["/usr/bin/tini", "--"]
+CMD ["fibermorph-gui"]
+
+
+# ── Stage 2: GPU (adds CUDA + SAM2) ─────────────────────────────────────────
+FROM nvidia/cuda:12.1.1-cudnn8-runtime-ubuntu22.04 AS gpu
+
+ENV DEBIAN_FRONTEND=noninteractive \
+ PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ PIP_NO_CACHE_DIR=1 \
+ PIP_DISABLE_PIP_VERSION_CHECK=1 \
+ STREAMLIT_SERVER_HEADLESS=true \
+ STREAMLIT_SERVER_PORT=7860 \
+ STREAMLIT_SERVER_ADDRESS=0.0.0.0 \
+ STREAMLIT_BROWSER_GATHER_USAGE_STATS=false
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ python3.11 \
+ python3.11-venv \
+ python3-pip \
+ libgl1 \
+ libglib2.0-0 \
+ libsm6 \
+ libxext6 \
+ tini \
+ curl \
+ git \
+ && rm -rf /var/lib/apt/lists/*
+
+RUN useradd -m -u 1000 user
+USER user
+ENV HOME=/home/user \
+ PATH=/home/user/.local/bin:$PATH
+WORKDIR $HOME/app
+
+COPY --chown=user . $HOME/app
+
+RUN python3.11 -m pip install --upgrade pip && \
+ python3.11 -m pip install ".[gui]" && \
+ python3.11 -m pip install torch --index-url https://download.pytorch.org/whl/cu121 && \
+ python3.11 -m pip install git+https://github.com/facebookresearch/segment-anything-2
+
+EXPOSE 7860
+
+HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
+ CMD curl -fsS http://127.0.0.1:7860/_stcore/health || exit 1
+
+ENTRYPOINT ["/usr/bin/tini", "--"]
+CMD ["fibermorph-gui"]
diff --git a/README.md b/README.md
index 7a18b95..0ae5a86 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,20 @@
-[](https://github.com/lasisilab/fibermorph/actions/workflows/test.yaml) [](https://pypi.org/project/fibermorph/)
+[](https://github.com/lasisilab/fibermorph/actions/workflows/test.yaml) [](https://pypi.org/project/fibermorph/)
# fibermorph
-**Interactive toolkit for analyzing hair fiber morphology**
+**Interactive toolkit for analyzing fiber morphology**
-fibermorph provides powerful image analysis tools for studying hair curvature and cross-sections, with both an intuitive **graphical interface** and a command-line interface for advanced users.
+fibermorph provides powerful image analysis tools for studying fiber curvature and cross-sections, with both an intuitive **graphical interface** and a command-line interface for advanced users.
+
+### What's new in v2.0
+- **Streamlit GUI** with a sidebar console: **Cross-Section**, **Curvature**, **Run Local**, and **Run Remote** views
+- **Per-fragment curvature** in the GUI — each fiber fragment's length and curvature, plus per-image summaries and distribution histograms
+- **Flexible resolution units** — enter px/µm or µm/px (px/mm or mm/px) in the GUI or CLI and it converts for you
+- **Run locally for large images** — `fibermorph-gui --local` raises the upload cap to 5 GB and adds a "Folder on disk" input (read images straight from disk)
+- **SAM2 GPU segmentation** for cross-sections (optional; falls back to watershed on CPU), with extended shape features (`--extended-features`)
+- **SBATCH script generation** from the GUI's Run Remote view (build & download — you submit it yourself)
+- **GPU Docker target** for container deployment with SAM2
## 🚀 Quick Start with the GUI (Recommended)
@@ -19,17 +28,29 @@ conda activate fibermorph_env
# Install fibermorph with the GUI
pip install "fibermorph[gui]"
-# Launch the interactive GUI
-fibermorph-gui
+# Launch the interactive GUI on your own machine
+fibermorph-gui --local
```
-This opens an interactive web interface where you can:
-- 📤 **Upload images**: Single or multiple TIFF files, or provide a URL to download
-- 🔬 **Choose analysis type**: Curvature or Section analysis
-- ⚙️ **Configure parameters**: Adjust settings with interactive controls
-- 💾 **Download results**: Get CSV summary data and ZIP file with all analysis outputs
+> `fibermorph-gui` without `--local` starts the app in *hosted* mode (uploads only, 500 MB cap, no access to folders on the computer running it). Use `--local` when you run it on your own machine; see [Run locally from source](#-run-locally-from-source).
+
+The sidebar console has four views:
+- **Cross-Section**: upload images, segment (SAM2 / watershed) and measure cross-section shape — results and mask previews appear inline
+- **Curvature**: measure per-fragment length and curvature, with per-image summaries and distribution charts
+- **Run Local**: how to run this same GUI on your own machine (no upload limit; read images straight from a folder)
+- **Run Remote**: build a downloadable SBATCH script to run the CLI on an HPC cluster (it does not submit jobs for you)
+
+> The hosted app is upload-only (500 MB per file). To analyze larger images, run it locally — see **Run locally from source** below.
+
+### Online vs local: where SAM2 runs
+
+| | Hosted app (Streamlit) | Local GUI (`fibermorph-gui --local`) | Cluster (Run Remote SBATCH script) |
+|---|---|---|---|
+| Cross-section segmentation | watershed only — **SAM2 unavailable** (no GPU on the server) | SAM2 on a machine with an NVIDIA GPU, else watershed | SAM2 on a GPU node (`--use-sam2`), else watershed |
+| Curvature | ✓ | ✓ | ✓ |
+| Input | uploads, 500 MB cap | uploads (5 GB cap) or a folder on disk | a folder on the cluster |
-**✨ Try it online**: [https://fibermorph.streamlit.app/](https://fibermorph.streamlit.app/)
+The app checks whether SAM2 can run where it is running (the `sam2` package plus a CUDA GPU). When it can't — always the case on the hosted app — the **Use SAM2 segmentation** switch is turned off and locked, with a one-line reason, and cross-sections are segmented with watershed. To use SAM2, run the GUI locally on a GPU machine (see [SAM2 GPU segmentation](#sam2-gpu-segmentation-optional)) or generate a cluster script in **Run Remote** with SAM2 enabled.
## 📦 Installation
@@ -47,66 +68,162 @@ pip install "fibermorph[gui]"
### Alternative: pip with virtual environment
```bash
-# Create virtual environment
python3.11 -m venv fibermorph_env
+source fibermorph_env/bin/activate # macOS/Linux
+# fibermorph_env\Scripts\activate # Windows
+pip install "fibermorph[gui]"
+```
-# Activate the environment
-# On macOS/Linux:
-source fibermorph_env/bin/activate
-# On Windows:
-fibermorph_env\Scripts\activate
+**Supported Python versions:** 3.10, 3.11, 3.12. Python 3.11 is recommended.
-# Install fibermorph with GUI
-pip install "fibermorph[gui]"
+### Optional extras
+
+```bash
+pip install "fibermorph[raw]" # RAW image conversion (rawpy)
+pip install "fibermorph[viz]" # matplotlib + seaborn visualization helpers
+pip install "fibermorph[gui]" # Streamlit GUI (recommended)
+pip install "fibermorph[raw,gui]" # combine extras
```
-**Supported Python versions:** 3.10, 3.11, 3.12. We recommend Python 3.11 for the best compatibility.
+### SAM2 GPU segmentation (optional)
-> **Note**: Python 3.13 support is planned but not yet available due to dependency compatibility issues.
+SAM2 is not on PyPI and must be installed separately. It is only required if you use `--use-sam2`.
-### Optional extras
+```bash
+pip install git+https://github.com/facebookresearch/segment-anything-2
+
+# Download a checkpoint (tiny model is fastest):
+mkdir -p fibermorph/checkpoints
+wget -O fibermorph/checkpoints/sam2.1_hiera_tiny.pt \
+ https://dl.fbaipublicfiles.com/segment_anything_2/092824/sam2.1_hiera_tiny.pt
+```
+
+Without SAM2, fibermorph automatically falls back to the watershed segmentation path — no crash, no manual intervention required.
+
+## 💻 Run locally from source
+
+The published PyPI package can lag behind the latest code. To run the **current**
+code — including everything in the GUI's **Run Local** view — install the repo
+itself in editable mode:
```bash
-pip install "fibermorph[raw]" # enable RAW image conversion via rawpy
-pip install "fibermorph[viz]" # install matplotlib-based visualization helpers
-pip install "fibermorph[gui]" # install Streamlit GUI (recommended!)
+git clone https://github.com/lasisilab/fibermorph.git
+cd fibermorph
+python3.12 -m venv .venv && source .venv/bin/activate # Python 3.10–3.12
+pip install -e '.[gui]' # editable install of this working copy
+fibermorph-gui --local # opens the local GUI at http://localhost:8501
```
-Extras can be combined, e.g. `pip install "fibermorph[raw,viz,gui]"`.
+Launched with `--local`, the GUI runs on your own machine with the upload cap raised
+to **5 GB**, a **"Folder on disk"** input on the Cross-Section and Curvature views,
+and an editable SAM2 checkpoint path, so you can analyze images that are too large to
+upload to the hosted app. Local mode listens on `localhost` only. (On a machine's
+first-ever Streamlit run it may briefly ask for an email — the launcher skips that
+for you.)
+
+> **`--local` is for your own computer, not for servers.** Plain `fibermorph-gui`
+> (no flag) starts the app in hosted mode: uploads only, a 500 MB upload cap, no
+> "Folder on disk" input, and the SAM2 checkpoint is whatever the server is
+> configured with (the file named by `SAM2_CHECKPOINT`; fibermorph does not ship
+> one). Visitors
+> cannot change it, and its path is not shown in any view. Local mode lets anyone
+> who can open the page read folders on the machine it runs on, so don't expose it
+> on a shared server. Setting the environment
+> variable `FIBERMORPH_LOCAL=1` is equivalent to passing `--local`.
+>
+> Any other arguments to `fibermorph-gui` are passed through to `streamlit run` after
+> the launcher's own options, so they can override them, for example
+> `fibermorph-gui --local --server.port 8600`. How the launcher's options interact
+> with Streamlit's other settings:
+>
+> - `--server.maxUploadSize` (500 MB hosted, 5000 MB local) is passed on the command
+> line, which outranks `.streamlit/config.toml`. If the environment variable
+> `STREAMLIT_SERVER_MAX_UPLOAD_SIZE` is set, the launcher passes nothing and that
+> value is used. To change the cap otherwise, add e.g. `--server.maxUploadSize 200`.
+> The cap the app shows in its sidebar and Run Local view is read from Streamlit's
+> own setting, so it follows whichever of these you use (500 MB and 5 GB are only
+> the launcher's defaults).
+> - In local mode `--server.address localhost` is always passed, so
+> `STREAMLIT_SERVER_ADDRESS` and `config.toml` cannot open folder input to other
+> machines by accident. To listen elsewhere, add `--server.address ` to
+> the command (the launcher then prints a warning; an empty `--server.address=`
+> also counts, because Streamlit then listens on every interface).
+> - Hosted mode passes no address or port, so Streamlit's usual settings
+> (`STREAMLIT_SERVER_ADDRESS`, `STREAMLIT_SERVER_PORT`, `config.toml`) apply.
+
+The CLI works from the same checkout too: `fibermorph --help`.
+
+## 🐳 Docker
+
+### CPU-only (default)
-## 🖥️ Command Line Interface (Advanced Users)
+```bash
+docker build --target cpu -t fibermorph:cpu .
+docker run -p 7860:7860 fibermorph:cpu
+# Open http://localhost:7860 in your browser
+```
+
+### GPU (SAM2 + CUDA)
-For automation, scripting, and batch processing, fibermorph provides a powerful CLI:
+```bash
+docker build --target gpu -t fibermorph:gpu .
+docker run --gpus all -p 7860:7860 fibermorph:gpu
+```
+
+## 🖥️ Command Line Interface
### Quick test with demo data
```bash
-fibermorph --demo_real_curv --output_directory ~/fibermorph_demo_curv
+fibermorph --demo_real_curv --output_directory ~/fibermorph_demo_curv
fibermorph --demo_real_section --output_directory ~/fibermorph_demo_section
```
-### Analyze your own data
+### Curvature analysis
-**Curvature analysis:**
```bash
fibermorph --curvature \
--input_directory /path/to/images \
--output_directory /path/to/results \
--resolution_mm 132 \
- --jobs 2
+ --jobs 4
```
-**Section analysis:**
+Curvature analysis uses the published fibermorph method (v0.3.1) and has no alternative processing modes. Its ridge filter is the scikit-image 0.16.2 Frangi filter bundled with fibermorph, so that step gives the published result whichever scikit-image version is installed.
+
+New curvature flag:
+- `--resolution_mm_units {px_per_mm,mm_per_px}` — interpret `--resolution_mm` as pixels-per-mm (default) or mm-per-pixel
+
+Two options from earlier v2.0 development builds, `--use-clahe` (CLAHE contrast enhancement) and `--extended-curvature` (curl index, wave count, medial-axis skeleton), have been removed because they are not part of the published method. They no longer appear in `--help`, and a command that still passes either one stops with an error (exit code 2) that says so; delete the flag from the command (or from an older generated SBATCH script) to run the published method.
+
+### Section analysis
+
```bash
+# Basic (same as v1):
+fibermorph --section \
+ --input_directory /path/to/images \
+ --output_directory /path/to/results \
+ --resolution_mu 4.25 \
+ --minsize 20 --maxsize 150 \
+ --jobs 4
+
+# With SAM2 GPU segmentation and extended features:
fibermorph --section \
--input_directory /path/to/images \
--output_directory /path/to/results \
- --minsize 30 \
- --maxsize 180 \
--resolution_mu 4.25 \
- --jobs 2
+ --use-sam2 --sam2-checkpoint fibermorph/checkpoints/sam2.1_hiera_tiny.pt \
+ --extended-features \
+ --jobs 4
```
+New section flags:
+- `--use-sam2` — enable SAM2 segmentation (requires GPU + SAM2 installed; falls back to watershed automatically)
+- `--sam2-checkpoint PATH` — path to SAM2 `.pt` weights file
+- `--sam2-cfg PATH` — path to SAM2 model config YAML (optional; uses bundled default)
+- `--extended-features` — adds EFD (40 coefficients), Hu moments (7), radial profile (7 metrics), `shape_class`
+- `--resolution_mu_units {px_per_um,um_per_px}` — interpret `--resolution_mu` as pixels-per-micron (default) or microns-per-pixel (e.g. a 0.18 scale)
+
## Install the package
1. After having activated your new virtual environment, you can simply run `pip install fibermorph`.
@@ -141,22 +258,30 @@ To run the demo, you will input something like:
`fibermorph --demo_real_section --output_directory /Users//// --o
```
### Section
-To calculate cross-sectional properties from grayscale TIFF images of hair fibers, the flag `--section` is used with the following flags:
+To calculate cross-sectional properties from grayscale TIFF images of fibers, the flag `--section` is used with the following flags:
```
--resolution_mu Float. Number of pixels per micron for section analysis. Default is 4.25.
+ Must be greater than 0.
--minsize Integer. Minimum diameter in microns for sections. Default is 20.
+ Must be 0 or more and no larger than --maxsize.
--maxsize Integer. Maximum diameter in microns for sections. Default is 150.
+ Must be greater than 0.
```
@@ -199,3 +327,6 @@ A user could enter, for example:
```
fibermorph --raw2gray --input_directory /Users// --output_directory /Users/// --file_extension .RW2 --jobs 4
```
+
+### Checks on numeric options
+Before any image is read, fibermorph checks the numeric options, whichever module is run, and stops with a usage message (exit code 2) if one is out of range: `--jobs` must not be 0 (use a positive number, or -1 for every CPU), and a number larger than the machine's CPU count is reduced to it with a message; `--resolution_mm` and `--resolution_mu` must be finite and greater than 0; `--minsize` must be 0 or more, `--maxsize` greater than 0, and `--minsize` no larger than `--maxsize`; and `--window_size` follows the rules listed under Curvature.
diff --git a/ROADMAP.md b/ROADMAP.md
new file mode 100644
index 0000000..6221152
--- /dev/null
+++ b/ROADMAP.md
@@ -0,0 +1,64 @@
+# fibermorph roadmap
+
+Planned work and known follow-ups that aren't discrete GitHub issues yet. For
+shipped changes see [CHANGELOG.md](CHANGELOG.md); for bugs/features, open an
+issue on [lasisilab/fibermorph](https://github.com/lasisilab/fibermorph/issues).
+
+**Status (2026-07-04):** the working line is **2.0.0** on the `fibermorph-dev`
+branch. The published PyPI release is **1.0.1**. 2.0.0 is not yet tagged or
+published.
+
+---
+
+## Before the next release
+- [ ] Merge `fibermorph-dev` → `main`, then cut the release. Decide the version:
+ fold the `[Unreleased]` changes into 2.0.0, or tag a new number.
+- [ ] Releasing is automated: bump `version` in `pyproject.toml`, commit, then
+ `git tag vX.Y.Z && git push origin vX.Y.Z` — the `Release` workflow
+ (`.github/workflows/publish.yml`) builds and publishes via PyPI Trusted
+ Publishing.
+- [ ] **Verify PyPI Trusted Publishing is configured** for the repo and the
+ `pypi` GitHub environment before the first 2.0.0 publish, or the publish
+ step will fail.
+- [ ] Regenerate `poetry.lock` if dependencies changed.
+
+## Curvature science — validate before trusting
+- Extended curvature metrics (curl index, wave count) and CLAHE preprocessing
+ were removed from curvature: neither is part of the published method (see
+ [CHANGELOG.md](CHANGELOG.md)). Any curvature metric added in future has to be
+ validated against the published method before it ships.
+- [ ] **Diameter** was removed (not trusted). Revisit a medial-axis / skeleton
+ diameter once the method is validated.
+- [ ] **Large curvature images (multi-GB):** investigate *safe* downsampling.
+ Small fragments can merge or vanish and shift curvature, so this needs a
+ measured study first. (Cross-section already auto-downsamples to a target
+ working resolution; curvature does not.)
+
+## GUI polish (design follow-ups)
+- [ ] Custom results table with in-cell **Method pills** (currently `st.dataframe`
+ with a restyled header).
+- [ ] Custom **input bar** matching the design mock (currently the stock uploader
+ plus the Settings card).
+
+## Tech debt & testing
+- [ ] `fibermorph/gui/visualizations.py` (~650 lines) is **orphaned** — never
+ imported. Either wire it into a results/figures view or remove it.
+- [ ] Retire the `fibermorph.fibermorph` / `fibermorph.fibermorph_compat`
+ backward-compatibility shims when the deprecation window closes.
+- [ ] Add tests for currently-untested modules: `cli.py` (arg parsing + the new
+ unit conversion), `utils/imaging.py` (downsampling measurement invariance,
+ <0.5%), `gui/launcher.py`, `processing/section_sam2.py` (checkpoint
+ resolution + watershed fallback), `demo/demo.py` (remote-data error paths).
+- [ ] Extend resolution-aware downsampling to the **batch/CLI section path**
+ (verify classical Chan-Vese invariance first).
+
+## Documentation
+- [ ] A **column reference** for the `--extended-features` output CSVs (which
+ columns the flag adds).
+- [ ] A short **CONTRIBUTING / developer setup** guide (this file plus the
+ "Run locally from source" section of the README are a starting point).
+
+## Student follow-ups (file as GitHub issues)
+File one issue per finding on `lasisilab/fibermorph`, each with file:line
+references and acceptance criteria:
+- [ ] SAM2 reachability / config-default handling for `--use-sam2`.
diff --git a/STREAMLIT_DEPLOYMENT.md b/STREAMLIT_DEPLOYMENT.md
index 2d20ecf..179e9d3 100644
--- a/STREAMLIT_DEPLOYMENT.md
+++ b/STREAMLIT_DEPLOYMENT.md
@@ -99,3 +99,64 @@ For issues related to:
- **Deployment**: Check Streamlit Cloud documentation
- **fibermorph functionality**: Open an issue on GitHub
- **GUI features**: Open an issue on GitHub with "GUI" label
+
+---
+
+## Deploy to Hugging Face Spaces (Docker)
+
+Hugging Face recommends Docker Spaces for Streamlit apps. This repository is now configured for that workflow.
+
+### Required files
+
+- **`Dockerfile`**: Container build and startup definition
+- **`README.md` YAML frontmatter**: Space metadata (`sdk: docker`, `app_port: 7860`)
+- **`.dockerignore`**: Excludes local/cache files from build context
+- **`streamlit_app.py`**: Streamlit entrypoint
+
+### 1) Build and run locally first
+
+```bash
+# Build image from repository root
+docker build -t fibermorph:latest .
+
+# Run container
+docker run --rm -p 7860:7860 fibermorph:latest
+```
+
+Open: `http://localhost:7860`
+
+### 2) Create a Docker Space on Hugging Face
+
+1. Go to
+2. Choose an owner and Space name
+3. Select **SDK: Docker**
+4. Choose visibility and hardware
+5. Create the Space
+
+### 3) Push the repository to the Space
+
+```bash
+# Clone your Space repo
+git clone https://huggingface.co/spaces//
+cd
+
+# Copy project files into the Space repo (or add HF as a remote from this repo)
+# Then commit and push
+git add .
+git commit -m "Deploy fibermorph Streamlit app via Docker"
+git push
+```
+
+Each push triggers an automatic rebuild and restart.
+
+### 4) Configure settings after first push
+
+- In **Space Settings**, add environment variables/secrets if needed.
+- Verify runtime logs in **Build logs** and **Container logs**.
+- Keep the app listening on port `7860` to match `app_port`.
+
+### 5) Operational notes
+
+- Free CPU Spaces can sleep when idle.
+- Storage is ephemeral across restarts unless persistent storage is configured.
+- If startup is slow, optimize image size and dependency install layers.
diff --git a/fibermorph/__init__.py b/fibermorph/__init__.py
index 1c3e475..12d700f 100644
--- a/fibermorph/__init__.py
+++ b/fibermorph/__init__.py
@@ -2,16 +2,17 @@
import importlib.metadata
-__version__ = importlib.metadata.version("fibermorph")
+try:
+ __version__ = importlib.metadata.version("fibermorph")
+except importlib.metadata.PackageNotFoundError:
+ __version__ = "0.0.0.dev"
-# Import main workflow functions for backward compatibility
-from .workflows import raw2gray, curvature, section
-
-# Import main analysis functions
-from .analysis.curvature_pipeline import curvature_seq
-from .analysis.section_pipeline import section_seq
+# Always-available utilities (pure Python / lightweight deps only)
+from .utils.filesystem import make_subdirectory, copy_if_exist, list_images
+from .utils.timing import convert, timing
+from .utils.metadata import collect_images
-# Import core functions
+# Core curvature functions (numpy + scipy only — no cv2)
from .core.curvature import (
taubin_curv,
subset_gen,
@@ -19,57 +20,82 @@
analyze_all_curv,
window_iter,
)
-from .core.section import (
- section_props,
- crop_section,
- segment_section,
- save_sections,
-)
-from .core.filters import filter_curv
-# Import processing functions
-from .processing.binary import check_bin, binarize_curv, remove_particles
-from .processing.morphology import skeletonize, prune, diag
-from .processing.geometry import (
- define_structure,
- find_structure,
- pixel_length_correction,
-)
-
-# Import I/O functions
-from .io.readers import imread
-from .io.writers import save_image
-from .io.converters import raw_to_gray
-
-# Import utility functions
-from .utils.filesystem import make_subdirectory, copy_if_exist, list_images
-from .utils.timing import convert, timing
+# Heavier imports wrapped so the package still loads without opencv/scikit-image
+try:
+ from .workflows import raw2gray, curvature, section, batch
+ from .analysis.curvature_pipeline import curvature_seq
+ from .analysis.section_pipeline import section_seq
+ from .core.section import (
+ section_props,
+ crop_section,
+ segment_section,
+ save_sections,
+ section_props_extended,
+ )
+ from .core.shape_analysis import (
+ compute_efd,
+ compute_radial_profile,
+ extract_features_from_array,
+ classify_shape,
+ )
+ from .core.filters import filter_curv
+ from .processing.binary import check_bin, binarize_curv, remove_particles
+ from .processing.morphology import skeletonize, prune, diag
+ from .processing.geometry import (
+ define_structure,
+ find_structure,
+ pixel_length_correction,
+ )
+ from .io.readers import imread
+ from .io.writers import save_image
+ from .io.converters import raw_to_gray
+except ImportError:
+ pass
-# Import demo module
-from .demo import demo
+# Demo (lightweight; may fail if requests not installed)
+try:
+ from .demo import demo
+except ImportError:
+ pass
__all__ = [
- # Version
"__version__",
+ # Utility functions
+ "make_subdirectory",
+ "copy_if_exist",
+ "list_images",
+ "convert",
+ "timing",
+ "collect_images",
+ # Core curvature
+ "taubin_curv",
+ "subset_gen",
+ "analyze_each_curv",
+ "analyze_all_curv",
+ "window_iter",
# Main workflows
"raw2gray",
"curvature",
"section",
+ "batch",
# Analysis pipelines
"curvature_seq",
"section_seq",
- # Core functions
- "taubin_curv",
- "subset_gen",
- "analyze_each_curv",
- "analyze_all_curv",
- "window_iter",
+ # Core section
"section_props",
"crop_section",
"segment_section",
"save_sections",
+ "section_props_extended",
+ # Shape analysis
+ "compute_efd",
+ "compute_radial_profile",
+ "extract_features_from_array",
+ "classify_shape",
+ # Filters
"filter_curv",
- # Processing functions
+ # Processing
"check_bin",
"binarize_curv",
"remove_particles",
@@ -79,16 +105,10 @@
"define_structure",
"find_structure",
"pixel_length_correction",
- # I/O functions
+ # I/O
"imread",
"save_image",
"raw_to_gray",
- # Utility functions
- "make_subdirectory",
- "copy_if_exist",
- "list_images",
- "convert",
- "timing",
# Demo
"demo",
]
diff --git a/fibermorph/analysis/curvature_pipeline.py b/fibermorph/analysis/curvature_pipeline.py
index 68a4b40..34d42dd 100644
--- a/fibermorph/analysis/curvature_pipeline.py
+++ b/fibermorph/analysis/curvature_pipeline.py
@@ -1,11 +1,14 @@
"""Curvature analysis pipeline for fibermorph package."""
+from __future__ import annotations
+
import pathlib
from typing import Union, List
import logging
import pandas as pd
from tqdm import tqdm
+from PIL import UnidentifiedImageError
logger = logging.getLogger(__name__)
@@ -20,82 +23,106 @@ def curvature_seq(
test: bool,
within_element: bool,
) -> pd.DataFrame:
- """Sequence of functions to be executed for calculating curvature in fibermorph.
+ """Sequence of steps to calculate curvature for a single image.
Parameters
----------
- input_file : str or pathlib.Path
- Path to image that needs to be analyzed.
- output_path : str or pathlib.Path
- Output directory.
- resolution : float
- Number of pixels per mm in original image.
- window_size : float or int or list
- Desired size for window of measurement in mm.
- window_unit : str
- Unit for window size ('px' or 'mm').
- save_img : bool
- True or false for saving images.
- test : bool
- True or false for whether this is being run for validation tests.
- within_element : bool
- True or False for whether to save spreadsheets with within element curvature values.
+ input_file : path to the input image
+ output_path : output directory
+ resolution : pixels per mm
+ window_size : window size for Taubin fit (scalar or list for sweep);
+ None fits one circle to each whole hair
+ window_unit : 'px' or 'mm'
+ save_img : save intermediate images
+ test : validation run: return the per-hair table (one row per
+ hair) instead of the image summary
+ within_element : save per-hair curvature CSV distributions
Returns
-------
pd.DataFrame
- Pandas DataFrame with curvature summary data for all images.
+ With ``test=False``, the curvature summary (one row per image / window
+ size; columns ``ID``, ``curv_mean_mean``, ``curv_mean_median``,
+ ``curv_median_mean``, ``curv_median_median``, ``length_mean``,
+ ``length_median``, ``hair_count``). With ``window_size=None`` the
+ columns are ``ID``, ``curv_mean``, ``curv_median``, ``length_mean``,
+ ``length_median``, ``hair_count``. With ``test=True``, the per-hair
+ table (columns ``curv_mean``, ``curv_median``, ``length`` for a window
+ size; ``curv``, ``length`` for the whole-hair mode).
"""
from ..core.filters import filter_curv
from ..processing.binary import binarize_curv, remove_particles
from ..processing.morphology import skeletonize, prune
from ..core.curvature import analyze_all_curv
- with tqdm(
- total=6,
- desc="curvature analysis sequence",
- unit="steps",
- position=1,
- leave=None,
- ) as pbar:
- # filter
- filter_img, im_name = filter_curv(input_file, output_path, save_img)
- pbar.update(1)
-
- # binarize
- binary_img = binarize_curv(filter_img, im_name, output_path, save_img)
- pbar.update(1)
-
- # remove particles
- clean_im = remove_particles(
- binary_img,
- output_path,
- im_name,
- minpixel=int(resolution / 2),
- prune=False,
- save_img=save_img,
- )
- pbar.update(1)
-
- # skeletonize
- skeleton_im = skeletonize(clean_im, im_name, output_path, save_img)
- pbar.update(1)
-
- # prune
- pruned_im = prune(skeleton_im, im_name, output_path, save_img)
- pbar.update(1)
-
- # analyze
- im_df = analyze_all_curv(
- pruned_im,
- im_name,
- output_path,
- resolution,
- window_size,
- window_unit,
- test,
- within_element,
- )
- pbar.update(1)
-
- return im_df
+ try:
+ with tqdm(
+ total=6,
+ desc="curvature analysis sequence",
+ unit="steps",
+ position=1,
+ leave=None,
+ ) as pbar:
+ # ----------------------------------------------------------------
+ # Step 1 — Filter
+ # ----------------------------------------------------------------
+ filter_img, im_name = filter_curv(input_file, output_path, save_img)
+ pbar.update(1)
+
+ # ----------------------------------------------------------------
+ # Step 2 — Binarize
+ # ----------------------------------------------------------------
+ binary_img = binarize_curv(filter_img, im_name, output_path, save_img)
+ pbar.update(1)
+
+ # ----------------------------------------------------------------
+ # Step 3 — Remove particles
+ # ----------------------------------------------------------------
+ clean_im = remove_particles(
+ binary_img,
+ output_path,
+ im_name,
+ minpixel=int(resolution / 2),
+ prune=False,
+ save_img=save_img,
+ )
+ pbar.update(1)
+
+ # ----------------------------------------------------------------
+ # Step 4 — Skeletonize
+ # ----------------------------------------------------------------
+ skeleton_im = skeletonize(clean_im, im_name, output_path, save_img)
+ pbar.update(1)
+
+ # ----------------------------------------------------------------
+ # Step 5 — Prune
+ # ----------------------------------------------------------------
+ pruned_im = prune(skeleton_im, im_name, output_path, save_img)
+ pbar.update(1)
+
+ # ----------------------------------------------------------------
+ # Step 6 — Analyze curvature
+ # ----------------------------------------------------------------
+ im_df = analyze_all_curv(
+ pruned_im,
+ im_name,
+ output_path,
+ resolution,
+ window_size,
+ window_unit,
+ test,
+ within_element,
+ )
+
+ pbar.update(1)
+ return im_df
+
+ except UnidentifiedImageError as e:
+ logger.error(f"Cannot process image file {input_file}: {e}")
+ raise
+ except FileNotFoundError as e:
+ logger.error(f"Image file not found: {input_file}")
+ raise
+ except Exception as e:
+ logger.error(f"Error processing image {input_file}: {e}")
+ raise
diff --git a/fibermorph/analysis/section_pipeline.py b/fibermorph/analysis/section_pipeline.py
index 5012153..7348366 100644
--- a/fibermorph/analysis/section_pipeline.py
+++ b/fibermorph/analysis/section_pipeline.py
@@ -1,5 +1,7 @@
"""Section analysis pipeline for fibermorph package."""
+from __future__ import annotations
+
import pathlib
from typing import Union
import logging
@@ -21,32 +23,34 @@ def section_seq(
resolution: float,
minsize: float,
maxsize: float,
- save_img: bool
+ save_img: bool,
+ use_sam2: bool = False,
+ sam2_checkpoint: str = "",
+ sam2_cfg: str = "",
+ extended_features: bool = False,
) -> pd.DataFrame:
- """Segments the input image to isolate the section(s).
+ """Segment the input image to isolate the cross-section and extract measurements.
Parameters
----------
- input_file : str or pathlib.Path
- Path to input image file.
- output_path : str or pathlib.Path
- Output directory path.
- resolution : float
- Number of pixels per micron.
- minsize : float
- Minimum diameter in microns for sections.
- maxsize : float
- Maximum diameter in microns for sections.
- save_img : bool
- Whether to save intermediate images.
+ input_file : path to input image file
+ output_path : output directory
+ resolution : pixels per µm
+ minsize : minimum diameter in µm
+ maxsize : maximum diameter in µm
+ save_img : save intermediate images
+ use_sam2 : use SAM2 GPU segmentation (falls back to watershed)
+ sam2_checkpoint : path to SAM2 model checkpoint
+ sam2_cfg : SAM2 model config YAML path
+ extended_features : add EFD, Hu moments, radial profile, and shape class columns
Returns
-------
- pd.DataFrame
- DataFrame with section measurements.
+ pd.DataFrame — section measurements (one row per image)
"""
from ..io.readers import imread
from ..core.section import section_props, crop_section, segment_section, save_sections
+ from ..processing.section_sam2 import segment_section as sam2_segment_fn
with tqdm(
total=3, desc="section analysis sequence", unit="steps", position=1, leave=None
@@ -54,63 +58,96 @@ def section_seq(
section_data = pd.DataFrame()
try:
- # read in file
img, im_name = imread(input_file, use_skimage=True)
-
- # Gets the unique values in the image matrix. Since it is binary, there should only be 2.
- unique, counts = np.unique(img, return_counts=True)
-
- # find center of image
- im_center = list(np.divide(img.shape, 2)) # returns array of two floats
-
- minpixel = minsize * resolution
- maxpixel = maxsize * resolution
-
+ unique, _ = np.unique(img, return_counts=True)
+ im_center = list(np.divide(img.shape, 2))
+ minpixel = minsize * resolution
+ maxpixel = maxsize * resolution
pbar.update(1)
- if len(unique) == 2:
- seg_im = skimage.util.invert(img)
- pbar.update(1)
- label_im, num_elem = skimage.measure.label(
- seg_im, connectivity=2, return_num=True
- )
-
- props = skimage.measure.regionprops(
- label_image=label_im, intensity_image=img
- )
-
- section_data, bin_im, bbox = section_props(
- props, im_name, resolution, minpixel, maxpixel, im_center
- )
-
- pad = 100
- minr = bbox[0] - pad
- minc = bbox[1] - pad
- maxr = bbox[2] + pad
- maxc = bbox[3] + pad
- bbox_pad = [minc, minr, maxc, maxr]
- crop_im = Image.fromarray(img).crop(bbox_pad)
-
- if save_img:
- save_sections(output_path, im_name, crop_im, save_crop=True)
- save_sections(output_path, im_name, bin_im, save_crop=False)
-
- pbar.update(1)
- else:
- crop_im = crop_section(
- img, im_name, resolution, minpixel, maxpixel, im_center
+ # ----------------------------------------------------------------
+ # Segmentation
+ # ----------------------------------------------------------------
+ if use_sam2 or extended_features:
+ # New path: SAM2 / watershed via section_sam2.py
+ sam2_ckpt = sam2_checkpoint or ""
+ sam2_c = sam2_cfg or ""
+ seg_result = sam2_segment_fn(
+ img,
+ resolution_mu=resolution,
+ min_diam=minsize,
+ max_diam=maxsize,
+ use_sam2=use_sam2,
+ checkpoint=sam2_ckpt,
+ model_cfg=sam2_c,
)
pbar.update(1)
- section_data, bin_im = segment_section(
- crop_im, im_name, resolution, minpixel, maxpixel, im_center
- )
+ if seg_result is None:
+ logger.warning(f"No section candidate found in {input_file}")
+ return pd.DataFrame()
+
+ mask_uint8, confidence, method = seg_result
+
+ if extended_features:
+ from ..core.section import section_props_extended
+ section_data = section_props_extended(mask_uint8, im_name, resolution)
+ section_data["segmentation_method"] = method
+ section_data["confidence"] = confidence
+ else:
+ # Basic metrics only, using the new segmentation
+ labeled = skimage.measure.label(mask_uint8 > 0, connectivity=2)
+ props = skimage.measure.regionprops(labeled, intensity_image=img)
+ if props:
+ p = props[0]
+ area_mu = p.filled_area / (resolution ** 2)
+ min_diam = p.minor_axis_length / resolution
+ max_diam = p.major_axis_length / resolution
+ section_data = pd.DataFrame({
+ "ID": [im_name],
+ "area": [area_mu],
+ "eccentricity": [p.eccentricity],
+ "min": [min_diam],
+ "max": [max_diam],
+ "segmentation_method": [method],
+ })
if save_img:
- save_sections(output_path, im_name, crop_im, save_crop=True)
- save_sections(output_path, im_name, bin_im, save_crop=False)
+ save_sections(output_path, im_name,
+ Image.fromarray(mask_uint8), save_crop=True)
pbar.update(1)
- except Exception as e:
- logger.error(f"Error processing {input_file}: {e}")
+
+ else:
+ # Original path: Otsu → crop → morphological active contours
+ if len(unique) == 2:
+ seg_im = skimage.util.invert(img)
+ pbar.update(1)
+ label_im, _ = skimage.measure.label(seg_im, connectivity=2, return_num=True)
+ props = skimage.measure.regionprops(label_image=label_im,
+ intensity_image=img)
+ section_data, bin_im, bbox = section_props(
+ props, im_name, resolution, minpixel, maxpixel, im_center
+ )
+ pad = 100
+ crop_im = Image.fromarray(img).crop(
+ [bbox[1] - pad, bbox[0] - pad, bbox[3] + pad, bbox[2] + pad]
+ )
+ if save_img:
+ save_sections(output_path, im_name, crop_im, save_crop=True)
+ save_sections(output_path, im_name, bin_im, save_crop=False)
+ pbar.update(1)
+ else:
+ crop_im = crop_section(img, im_name, resolution, minpixel, maxpixel, im_center)
+ pbar.update(1)
+ section_data, bin_im = segment_section(
+ crop_im, im_name, resolution, minpixel, maxpixel, im_center
+ )
+ if save_img:
+ save_sections(output_path, im_name, crop_im, save_crop=True)
+ save_sections(output_path, im_name, bin_im, save_crop=False)
+ pbar.update(1)
+
+ except Exception as exc:
+ logger.error(f"Error processing {input_file}: {exc}")
return section_data
diff --git a/fibermorph/cli.py b/fibermorph/cli.py
index 3bfd1f4..b46e036 100644
--- a/fibermorph/cli.py
+++ b/fibermorph/cli.py
@@ -1,19 +1,272 @@
"""Command-line interface for fibermorph package."""
import argparse
+import math
+import os
import sys
import logging
logger = logging.getLogger(__name__)
+# Longest ``--window_size`` accepted, in pixels (see check_window_pixel_size).
+MAX_WINDOW_PX = 1_000_000_000
-def parse_args():
+class _RemovedOption(argparse.Action):
+ """A command-line option that no longer exists.
+
+ The option stays registered, hidden from ``--help``, so that a script
+ which still passes it stops with a clear message (exit code 2) instead of
+ argparse's generic "unrecognized arguments" error.
+ """
+
+ def __init__(self, option_strings, dest, feature):
+ self.feature = feature
+ super().__init__(
+ option_strings,
+ dest,
+ nargs=0,
+ default=argparse.SUPPRESS,
+ help=argparse.SUPPRESS,
+ )
+
+ def __call__(self, parser, namespace, values, option_string=None):
+ raise argparse.ArgumentError(
+ self,
+ f"this option was removed because {self.feature} is not part of "
+ "the published fibermorph curvature method (v0.3.1). Remove it "
+ "from your command; curvature now always runs the published method.",
+ )
+
+
+
+def _window_size_value(text):
+ """Parse one ``--window_size`` value: a number, or ``none`` for the whole hair.
+
+ Used as the argparse ``type`` for ``--window_size``. Unit-dependent checks
+ (positive, whole number of pixels) are made afterwards by
+ :func:`normalize_window_sizes`, once ``--window_unit`` is known.
+
+ Parameters
+ ----------
+ text : str
+ One value as typed on the command line.
+
+ Returns
+ -------
+ float or None
+ The number, or None for ``none`` / ``None`` (any capitalisation).
+ """
+ cleaned = text.strip().lower()
+ if cleaned == "none":
+ return None
+ try:
+ return float(cleaned)
+ except ValueError:
+ raise argparse.ArgumentTypeError(
+ f"invalid window size {text!r}: expected a number or 'none'"
+ ) from None
+
+
+def normalize_window_sizes(values, window_unit):
+ """Validate parsed ``--window_size`` values against ``--window_unit``.
+
+ Parameters
+ ----------
+ values : list of (float or None) or None
+ Values returned by :func:`_window_size_value`, or None when the option
+ was not given.
+ window_unit : str
+ ``"px"`` or ``"mm"``.
+
+ Returns
+ -------
+ list of (int or float) or None
+ None for the whole-hair setting (option omitted, or the single value
+ ``none``). Otherwise one number per value, in the order given: ints
+ for ``px`` (so output files are named ``..._WindowSize-50px``), floats
+ for ``mm`` (``..._WindowSize-0.5mm``).
+
+ Raises
+ ------
+ ValueError
+ If ``none`` is mixed with numbers, a value is not a finite number
+ greater than 0, or a ``px`` value is not a whole number.
+ """
+ if values is None:
+ return None
+ if any(v is None for v in values):
+ if len(values) > 1:
+ raise ValueError(
+ "'none' (fit the whole hair) must be given on its own, "
+ "not repeated or combined with window sizes"
+ )
+ return None
+
+ normalized = []
+ for value in values:
+ if not (math.isfinite(value) and value > 0):
+ raise ValueError(
+ f"window sizes must be finite numbers greater than 0 (got {value:g})"
+ )
+ if window_unit == "px":
+ if not float(value).is_integer():
+ raise ValueError(
+ f"with --window_unit px the window size must be a whole number "
+ f"of pixels (got {value:g}); use --window_unit mm for fractional sizes"
+ )
+ normalized.append(int(value))
+ else:
+ normalized.append(float(value))
+ return normalized
+
+
+def check_numeric_options(args):
+ """Reject numeric options that would fail, or give nothing, deep in a run.
+
+ ``--jobs``, ``--resolution_mm``, ``--resolution_mu``, ``--minsize`` and
+ ``--maxsize`` only have an argparse ``type`` of ``int`` or ``float``, which
+ accepts values such as ``--jobs 0`` (joblib raises ``ValueError``),
+ ``--resolution_mm 0`` or ``nan`` (a traceback), or ``--minsize 200
+ --maxsize 100`` (no section can match). This checks them once ``args`` is
+ parsed, whichever module was chosen.
+
+ Parameters
+ ----------
+ args : argparse.Namespace
+ Parsed arguments, as returned by ``parser.parse_args``.
+
+ Raises
+ ------
+ ValueError
+ With a message that starts "argument --:" if ``--jobs`` is 0
+ (negative values are joblib's count-from-all-CPUs settings, -1 for
+ every CPU and -2 for all but one, and are allowed); a resolution is
+ not a finite number greater than 0, or is so small that its
+ reciprocal overflows when ``mm_per_px`` / ``um_per_px`` is converted
+ to pixels per unit; ``--minsize`` is negative; ``--maxsize`` is not
+ greater than 0; or ``--minsize`` is larger than ``--maxsize``.
+
+ A ``--jobs`` larger than the number of CPUs on this machine is reduced to
+ that number, with a message on stderr: more jobs gain nothing, and a huge
+ value makes joblib crash or start thousands of worker processes.
+ """
+ from .utils.units import resolution_to_px_per_unit
+
+ if args.jobs == 0:
+ raise ValueError(
+ "argument --jobs: must not be 0 (use a positive number of jobs, "
+ "or -1 for every CPU)"
+ )
+ # More jobs than CPUs gains nothing, and a huge value makes joblib crash
+ # (OverflowError, OSError) or start thousands of worker processes. Use at
+ # most one job per CPU on this machine.
+ cpus = os.cpu_count() or 1
+ if args.jobs > cpus:
+ print(
+ f"fibermorph: --jobs {args.jobs} is more than the {cpus} CPUs on this "
+ f"machine; using --jobs {cpus}.",
+ file=sys.stderr,
+ )
+ args.jobs = cpus
+
+ for option, units in (
+ ("resolution_mm", args.resolution_mm_units),
+ ("resolution_mu", args.resolution_mu_units),
+ ):
+ value = getattr(args, option)
+ if not (math.isfinite(value) and value > 0):
+ raise ValueError(
+ f"argument --{option}: must be a finite number greater than 0 "
+ f"(got {value!r})"
+ )
+ if not math.isfinite(resolution_to_px_per_unit(value, units)):
+ raise ValueError(
+ f"argument --{option}: {value!r} is too small to convert from "
+ f"{units} to pixels per unit"
+ )
+
+ if args.minsize < 0:
+ raise ValueError(f"argument --minsize: must not be negative (got {args.minsize})")
+ if args.maxsize <= 0:
+ raise ValueError(f"argument --maxsize: must be greater than 0 (got {args.maxsize})")
+ if args.minsize > args.maxsize:
+ raise ValueError(
+ f"argument --minsize: {args.minsize} is larger than --maxsize "
+ f"({args.maxsize}); no section could match"
+ )
+
+
+def check_window_pixel_size(args):
+ """Reject a ``--window_size`` that is too many pixels long to be real.
+
+ :func:`normalize_window_sizes` only checks that each window is a finite
+ number greater than 0. A very large one still fails later: with ``mm`` the
+ curvature code computes ``int(window_size * resolution)``, which raises
+ ``OverflowError`` once the product is infinite (``--window_size 1e307``),
+ and with ``px`` the digits of a huge whole number go into the output file
+ name, which the operating system refuses as too long (``--window_size
+ 1e300``). Every window is therefore limited to ``MAX_WINDOW_PX`` pixels,
+ after conversion with ``--resolution_mm`` for ``mm``; no image has a hair
+ that long.
+
+ Call it after :func:`check_numeric_options`, which makes sure that
+ ``--resolution_mm`` is a finite number greater than 0.
+
+ Parameters
+ ----------
+ args : argparse.Namespace
+ Parsed arguments whose ``window_size`` has been normalized (None, or a
+ list of ints for ``px`` / floats for ``mm``).
+
+ Raises
+ ------
+ ValueError
+ With a message that starts "argument --window_size:" if a window is
+ longer than ``MAX_WINDOW_PX`` pixels, or so long that its length in
+ pixels is infinite.
+ """
+ from .utils.units import resolution_to_px_per_unit
+
+ if args.window_size is None:
+ return
+
+ if args.window_unit == "mm":
+ px_per_mm = resolution_to_px_per_unit(args.resolution_mm, args.resolution_mm_units)
+
+ for value in args.window_size:
+ if args.window_unit == "px":
+ if value > MAX_WINDOW_PX:
+ raise ValueError(
+ f"argument --window_size: {value:,} px is longer than the largest "
+ f"window allowed ({MAX_WINDOW_PX:,} pixels)"
+ )
+ else:
+ window_px = value * px_per_mm
+ if window_px > MAX_WINDOW_PX:
+ raise ValueError(
+ f"argument --window_size: {value:.12g} mm is {window_px:,.0f} pixels at "
+ f"--resolution_mm {args.resolution_mm:g} {args.resolution_mm_units}, "
+ f"longer than the largest window allowed ({MAX_WINDOW_PX:,} pixels)"
+ )
+
+
+def parse_args(argv=None):
"""Parse command-line arguments.
-
+
+ Parameters
+ ----------
+ argv : list of str, optional
+ Arguments to parse. Defaults to ``sys.argv[1:]``.
+
Returns
-------
argparse.Namespace
- Parser argument namespace.
+ Parser argument namespace. ``window_size`` is None (fit the whole hair)
+ or a list of window sizes: ints for ``--window_unit px``, floats for
+ ``mm``. Out-of-range ``--window_size`` (including one longer than
+ ``MAX_WINDOW_PX`` pixels), ``--jobs``, ``--resolution_mm``,
+ ``--resolution_mu``, ``--minsize`` and ``--maxsize`` values stop the
+ parser with exit code 2, whichever module is chosen.
"""
from . import __version__
@@ -46,7 +299,9 @@ def parse_args():
type=int,
metavar="",
default=1,
- help="Integer. Number of parallel jobs to run. Default is 1.",
+ help="Integer. Number of parallel jobs to run; -1 uses every CPU, 0 is not "
+ "allowed, and a number larger than the machine's CPU count is reduced to it. "
+ "Default is 1.",
)
parser.add_argument(
@@ -64,10 +319,20 @@ def parse_args():
gr_curv.add_argument(
"--resolution_mm",
- type=int,
+ type=float,
metavar="",
- default=132,
- help="Integer. Number of pixels per mm for curvature analysis. Default is 132.",
+ default=132.0,
+ help="Float. Curvature scale, interpreted per --resolution_mm_units. Must be "
+ "greater than 0. Default is 132 (pixels per mm).",
+ )
+
+ gr_curv.add_argument(
+ "--resolution_mm_units",
+ type=str,
+ default="px_per_mm",
+ choices=["px_per_mm", "mm_per_px"],
+ help="Units for --resolution_mm: 'px_per_mm' (default, pixels per mm) or "
+ "'mm_per_px' (mm per pixel). Converted to pixels/mm internally.",
)
gr_curv.add_argument(
@@ -75,9 +340,16 @@ def parse_args():
metavar="",
default=None,
nargs="+",
+ type=_window_size_value,
help="Float or integer or None. Desired size for window of measurement for curvature "
- "analysis in pixels or mm (given the flag --window_unit). If nothing is entered, "
- "the default is None and the entire hair will be used for the curve fitting.",
+ "analysis in pixels or mm (given the flag --window_unit). Give several values "
+ "(e.g. 25 50 100) to measure each window size in turn. With --window_unit px "
+ "each value must be a whole number (50 or 50.0); with mm it can be any number "
+ "greater than 0 (0.5). A window shorter than 10 pixels (for mm, after conversion "
+ "with --resolution_mm) is not used: each hair is measured over its whole length "
+ "in one window, with a warning. A window longer than 1,000,000,000 pixels is "
+ "refused. If nothing is entered, or the value is 'none', the default is None and "
+ "the entire hair will be used for the curve fitting.",
)
gr_curv.add_argument(
@@ -98,6 +370,19 @@ def parse_args():
"of raw curvature measurements for each hair if the --within_element flag is included.",
)
+ # Removed options: rejected with an explanatory error, hidden from --help.
+ gr_curv.add_argument(
+ "--use-clahe",
+ action=_RemovedOption,
+ feature="CLAHE preprocessing",
+ )
+
+ gr_curv.add_argument(
+ "--extended-curvature",
+ action=_RemovedOption,
+ feature="extended curvature (curl index, wave count)",
+ )
+
gr_sect = parser.add_argument_group(
"section options", "arguments used specifically for section module"
)
@@ -107,7 +392,18 @@ def parse_args():
type=float,
metavar="",
default=4.25,
- help="Float. Number of pixels per micron for section analysis. Default is 4.25.",
+ help="Float. Section scale, interpreted per --resolution_mu_units. Must be "
+ "greater than 0. Default is 4.25 (pixels per micron).",
+ )
+
+ gr_sect.add_argument(
+ "--resolution_mu_units",
+ type=str,
+ default="px_per_um",
+ choices=["px_per_um", "um_per_px"],
+ help="Units for --resolution_mu: 'px_per_um' (default, pixels per micron) "
+ "or 'um_per_px' (microns per pixel, e.g. a 0.18 scale). Converted to "
+ "pixels/µm internally.",
)
gr_sect.add_argument(
@@ -115,7 +411,8 @@ def parse_args():
type=int,
metavar="",
default=20,
- help="Integer. Minimum diameter in microns for sections. Default is 20.",
+ help="Integer. Minimum diameter in microns for sections. Must be 0 or more "
+ "and no larger than --maxsize. Default is 20.",
)
gr_sect.add_argument(
@@ -123,7 +420,45 @@ def parse_args():
type=int,
metavar="",
default=150,
- help="Integer. Maximum diameter in microns for sections. Default is 150.",
+ help="Integer. Maximum diameter in microns for sections. Must be greater "
+ "than 0. Default is 150.",
+ )
+
+ gr_sect.add_argument(
+ "--use-sam2",
+ action="store_true",
+ default=False,
+ dest="use_sam2",
+ help="Use SAM2 GPU segmentation for cross-sections. Falls back to watershed "
+ "automatically if SAM2 is not installed or no GPU is available. "
+ "Requires: pip install git+https://github.com/facebookresearch/segment-anything-2",
+ )
+
+ gr_sect.add_argument(
+ "--sam2-checkpoint",
+ type=str,
+ metavar="",
+ default="",
+ dest="sam2_checkpoint",
+ help="Path to SAM2 model checkpoint (.pt file). Only used with --use-sam2.",
+ )
+
+ gr_sect.add_argument(
+ "--sam2-cfg",
+ type=str,
+ metavar="",
+ default="",
+ dest="sam2_cfg",
+ help="Path to SAM2 model config YAML. Only used with --use-sam2.",
+ )
+
+ gr_sect.add_argument(
+ "--extended-features",
+ action="store_true",
+ default=False,
+ dest="extended_features",
+ help="Compute extended cross-section features: EFD (40 coefficients), "
+ "Hu moments (7), radial distance profile (7), and shape classification.",
)
gr_raw = parser.add_argument_group(
@@ -181,7 +516,21 @@ def parse_args():
help="A demo of fibermorph section analysis with real data.",
)
- args = parser.parse_args()
+ args = parser.parse_args(argv)
+
+ # Window sizes: check them against --window_unit (exit code 2 if invalid)
+ try:
+ args.window_size = normalize_window_sizes(args.window_size, args.window_unit)
+ except ValueError as exc:
+ parser.error(f"argument --window_size: {exc}")
+
+ # Other numeric options: --jobs, resolutions and section size limits; then
+ # the window length in pixels, which needs a valid resolution
+ try:
+ check_numeric_options(args)
+ check_window_pixel_size(args)
+ except ValueError as exc:
+ parser.error(str(exc))
# Validate arguments
demo_mods = [args.demo_real_curv, args.demo_real_section]
@@ -200,13 +549,25 @@ def parse_args():
return args
-def main():
- """Main entry point for fibermorph CLI."""
+def main(argv=None):
+ """Main entry point for fibermorph CLI.
+
+ Parameters
+ ----------
+ argv : list of str, optional
+ Command-line arguments. Defaults to ``sys.argv[1:]``.
+ """
from .utils.filesystem import make_subdirectory
- from .workflows import raw2gray, curvature, section
+ from .utils.units import resolution_to_px_per_unit
+ from .workflows import raw2gray, curvature, section, batch
from . import demo
-
- args = parse_args()
+
+ args = parse_args(argv)
+
+ # Normalise resolutions to the pixels-per-unit the pipeline expects,
+ # honouring the reciprocal (µm/px, mm/px) units if the user chose them.
+ resolution_mu = resolution_to_px_per_unit(args.resolution_mu, args.resolution_mu_units)
+ resolution_mm = resolution_to_px_per_unit(args.resolution_mm, args.resolution_mm_units)
if args.demo_real_curv is True:
demo.real_curv(args.output_directory)
@@ -225,7 +586,7 @@ def main():
args.input_directory,
output_dir,
args.jobs,
- args.resolution_mm,
+ resolution_mm,
args.window_size,
args.window_unit,
args.save_image,
@@ -236,10 +597,14 @@ def main():
args.input_directory,
output_dir,
args.jobs,
- args.resolution_mu,
+ resolution_mu,
args.minsize,
args.maxsize,
args.save_image,
+ use_sam2=args.use_sam2,
+ sam2_checkpoint=args.sam2_checkpoint,
+ sam2_cfg=args.sam2_cfg,
+ extended_features=args.extended_features,
)
else:
sys.exit("Error: No valid module selected")
diff --git a/fibermorph/core/curvature.py b/fibermorph/core/curvature.py
index ef8513b..53f34cc 100644
--- a/fibermorph/core/curvature.py
+++ b/fibermorph/core/curvature.py
@@ -305,14 +305,24 @@ def window_iter(
output_path : str or pathlib.Path
Output directory path.
test : bool
- Whether this is a test run.
+ Whether this is a validation run. If True, the per-hair table is
+ returned instead of the image summary (one row per hair, with columns
+ ``curv_mean``, ``curv_median`` and ``length`` for a window size, or
+ ``curv`` and ``length`` for the whole-hair mode).
within_element : bool
Whether to save within-element data.
Returns
-------
pd.DataFrame
- Summary DataFrame for the image.
+ Summary DataFrame for the image (columns ``ID``, ``curv_mean_mean``,
+ ``curv_mean_median``, ``curv_median_mean``, ``curv_median_median``,
+ ``length_mean``, ``length_median``, ``hair_count``). With
+ ``window_size=None`` (whole hair: one circle fitted to each hair, hairs
+ shorter than 0.5 * ``resolution`` pixels skipped) the columns are
+ ``ID``, ``curv_mean``, ``curv_median``, ``length_mean``,
+ ``length_median`` and ``hair_count``. With ``test=True``, the per-hair
+ table described above.
"""
from ..utils.filesystem import make_subdirectory
@@ -371,8 +381,57 @@ def window_iter(
}
)
- return im_sumdf
+ if test:
+ return within_im_curvdf2
+ else:
+ return im_sumdf
- else:
- logger.warning("Window size is None, returning empty DataFrame")
- return pd.DataFrame()
+ elif window_size is None:
+ # Whole-hair mode: fit a single Taubin circle to each hair.
+ window_size_px = None
+ within_element = None
+ minsize = 0.5 * resolution
+ tempdf = [
+ analyze_each_curv(
+ hair, window_size_px, resolution, output_path, name, within_element
+ )
+ for hair in props
+ if hair.area > minsize
+ ]
+
+ if tempdf:
+ within_im_curvdf = pd.concat(tempdf)
+ else:
+ # No hair longer than minsize: report an empty table (NaN means,
+ # hair_count 0), as the window-size branch does, instead of
+ # letting pd.concat raise "No objects to concatenate".
+ within_im_curvdf = pd.DataFrame(columns=["curv", "length"], dtype=float)
+
+ within_im_curvdf2 = within_im_curvdf.dropna()
+
+ output_path = make_subdirectory(output_path, append_name="analysis")
+ save_path = pathlib.Path(output_path) / f"ImageSum_{name}.csv"
+ within_im_curvdf2.to_csv(save_path)
+ logger.debug(f"Saved image summary to {save_path}")
+
+ im_mean = within_im_curvdf2["curv"].mean()
+ im_median = within_im_curvdf2["curv"].median()
+ length_mean = within_im_curvdf2["length"].mean()
+ length_median = within_im_curvdf2["length"].median()
+ hair_count = len(within_im_curvdf2.index)
+
+ im_sumdf = pd.DataFrame(
+ {
+ "ID": name,
+ "curv_mean": [im_mean],
+ "curv_median": [im_median],
+ "length_mean": [length_mean],
+ "length_median": [length_median],
+ "hair_count": [hair_count],
+ }
+ )
+
+ if test:
+ return within_im_curvdf2
+ else:
+ return im_sumdf
diff --git a/fibermorph/core/filters.py b/fibermorph/core/filters.py
index faffd99..284da18 100644
--- a/fibermorph/core/filters.py
+++ b/fibermorph/core/filters.py
@@ -6,7 +6,6 @@
import numpy as np
import skimage
-import skimage.filters
import skimage.io
import skimage.util
@@ -20,6 +19,11 @@ def filter_curv(
) -> Tuple[np.ndarray, str]:
"""Uses a ridge filter to extract curved (or straight) lines from background noise.
+ The ridge filter is the Frangi filter as implemented in scikit-image 0.16.2
+ (``fibermorph.core.frangi_v016.frangi_v016``), so this step gives the same
+ output as the published fibermorph method (v0.3.1) whichever scikit-image
+ version is installed.
+
Parameters
----------
input_file : str or pathlib.Path
@@ -38,14 +42,18 @@ def filter_curv(
"""
from ..io.readers import imread
from ..utils.filesystem import make_subdirectory
+ from .frangi_v016 import frangi_v016
# create pathlib object for input Image
input_path = pathlib.Path(input_file)
gray_img, im_name = imread(input_path)
- # use frangi ridge filter to find hairs, the output will be inverted
- filter_img = skimage.filters.frangi(gray_img)
+ # Use the Frangi ridge filter as scikit-image 0.16.2 computed it (the
+ # version behind the published fibermorph results), not the installed
+ # skimage.filters.frangi, whose output changed in later releases. See
+ # fibermorph/core/frangi_v016.py. The output will be inverted.
+ filter_img = frangi_v016(gray_img)
logger.debug(f"Filtered image size: {filter_img.shape}")
if save_img:
diff --git a/fibermorph/core/frangi_v016.py b/fibermorph/core/frangi_v016.py
new file mode 100644
index 0000000..4537768
--- /dev/null
+++ b/fibermorph/core/frangi_v016.py
@@ -0,0 +1,199 @@
+"""Two-dimensional Frangi vesselness filter exactly as scikit-image 0.16.2 computed it.
+
+Why this module exists
+----------------------
+The ridge filter is the first step of fibermorph's curvature pipeline: it
+turns the grayscale hair image into the map that is thresholded and
+skeletonized. The published fibermorph method (Lasisi et al., v0.3.1,
+November 2020) used ``skimage.filters.frangi`` from scikit-image 0.16.2.
+scikit-image changed that function after 0.16.2, so calling the installed
+``skimage.filters.frangi`` no longer reproduces the published numbers:
+
+* the default ``gamma`` changed from 15 to ``None`` (an automatic value
+ derived from the image), and
+* the Hessian computation changed internally.
+
+With the same fibermorph code, the straight-hair demo image
+(``027_demo_nocurv``, 50 px window, 132 px/mm) has a mean curvature of
+about 0.053 mm^-1 with scikit-image 0.16.2 and about 0.17 mm^-1 with
+scikit-image 0.26, because the Frangi output differs and everything
+downstream (threshold, skeleton, Taubin fits) is deterministic given
+the Frangi output.
+
+This module freezes the 0.16.2 behavior so that curvature results do not
+depend on which scikit-image version is installed. It reproduces the
+0.16.2 output to floating-point round-off (about 1e-16) when run on current
+NumPy, SciPy and scikit-image. The reference arrays in
+``fibermorph/test/test_data/frangi_v016/`` were computed with the real
+scikit-image 0.16.2 and are checked in the test suite.
+
+Only the 2-D case with the settings fibermorph uses is provided. The
+``alpha`` argument is accepted for signature compatibility but has no effect
+in 2-D (the plate-like term is identically 1), as in scikit-image 0.16.2.
+
+Two small helpers are taken from the installed scikit-image instead of being
+copied: ``skimage.util.img_as_float`` and ``skimage.util.invert``. For the
+images fibermorph passes (8-bit, or float64 in [0, 1]) they return the same
+arrays in scikit-image 0.16.2 and 0.26: 8-bit input becomes ``x * (1 / 255)``
+as float64 and is inverted as ``255 - x``; float64 input is unchanged and is
+inverted as ``1 - x``. ``TestInstalledHelpers`` in
+``fibermorph/test/test_frangi_v016.py`` checks exactly this, so a future
+scikit-image that changes either helper is reported by name.
+
+License
+-------
+This code is derived from ``skimage/filters/ridges.py`` and
+``skimage/feature/corner.py`` of scikit-image 0.16.2, which is distributed
+under the BSD 3-Clause license. The copyright notice and license text
+required by that license follow.
+
+ Copyright (C) 2019, the scikit-image team
+ All rights reserved.
+
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are
+ met:
+
+ 1. Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+ 2. Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in
+ the documentation and/or other materials provided with the
+ distribution.
+ 3. Neither the name of skimage nor the names of its contributors may be
+ used to endorse or promote products derived from this software
+ without specific prior written permission.
+
+ THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
+ IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT,
+ INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+ HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+ STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING
+ IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ POSSIBILITY OF SUCH DAMAGE.
+
+Reference: Frangi AF, Niessen WJ, Vincken KL, Viergever MA (1998).
+Multiscale vessel enhancement filtering. MICCAI, pp. 130-137.
+"""
+
+from itertools import combinations_with_replacement
+
+import numpy as np
+from scipy import ndimage as ndi
+from skimage.util import img_as_float, invert
+
+__all__ = ["frangi_v016"]
+
+
+def _divide_nonzero(array1, array2, cval=1e-10):
+ """Divide ``array1`` by ``array2``, replacing zero denominators by ``cval``."""
+ denominator = np.copy(array2)
+ denominator[denominator == 0] = cval
+ return np.divide(array1, denominator)
+
+
+def _hessian_eigvals_v016(image, sigma):
+ """Scale-normalized Hessian eigenvalues of a 2-D image, sorted by absolute value.
+
+ Follows ``compute_hessian_eigenvalues(image, sigma, sorting='abs')`` of
+ scikit-image 0.16.2: Gaussian smoothing with zero padding at the border
+ (``mode='constant', cval=0``), second derivatives from repeated
+ ``np.gradient``, multiplication by ``sigma ** 2``, closed-form eigenvalues
+ of the symmetric 2x2 matrix, then ordering by increasing absolute value.
+
+ Returns an array of shape ``(2,) + image.shape``; index 0 holds the
+ eigenvalue with the smaller absolute value.
+ """
+ image = img_as_float(image)
+
+ gaussian_filtered = ndi.gaussian_filter(image, sigma=sigma, mode="constant", cval=0)
+ gradients = np.gradient(gaussian_filtered)
+
+ axes = reversed(range(image.ndim)) # order='rc'
+ h_elems = [
+ np.gradient(gradients[ax0], axis=ax1)
+ for ax0, ax1 in combinations_with_replacement(axes, 2)
+ ]
+ h_elems = [(sigma ** 2) * e for e in h_elems] # correct for scale
+
+ m00, m01, m11 = h_elems
+ half_trace = (m00 + m11) / 2
+ half_gap = np.sqrt(4 * m01 ** 2 + (m00 - m11) ** 2) / 2
+ eigenvalues = np.array([half_trace + half_gap, half_trace - half_gap])
+
+ order = np.abs(eigenvalues).argsort(axis=0)
+ return np.take_along_axis(eigenvalues, order, axis=0)
+
+
+def frangi_v016(image, sigmas=range(1, 10, 2), alpha=0.5, beta=0.5, gamma=15,
+ black_ridges=True):
+ """Frangi vesselness filter of a 2-D image, as in scikit-image 0.16.2.
+
+ Parameters
+ ----------
+ image : (N, M) ndarray
+ Input image (the fibermorph pipeline passes an 8-bit grayscale image).
+ sigmas : iterable of float, optional
+ Gaussian scales; the output is the per-pixel maximum over scales.
+ alpha : float, optional
+ Accepted for compatibility with ``skimage.filters.frangi``; unused in 2-D.
+ beta : float, optional
+ Sensitivity to deviation from a blob-like structure.
+ gamma : float, optional
+ Sensitivity to areas of high structure. scikit-image 0.16.2 used the
+ fixed default 15; newer scikit-image versions compute it from the
+ image when it is ``None``, which is the main source of the change in
+ results that this module avoids.
+ black_ridges : bool, optional
+ If True (default), detect dark ridges on a light background, which is
+ how hairs appear in fibermorph's curvature images.
+
+ Returns
+ -------
+ out : (N, M) ndarray of float64
+ Filtered image (maximum over scales), identical to the output of
+ ``skimage.filters.frangi`` in scikit-image 0.16.2.
+ """
+ image = np.asarray(image)
+ if image.ndim != 2:
+ raise ValueError(
+ f"frangi_v016 expects a 2-D image, got an array with {image.ndim} dimension(s)"
+ )
+
+ sigmas = np.asarray(sigmas)
+ if np.any(sigmas < 0.0):
+ raise ValueError("Sigma values less than zero are not valid")
+
+ beta_sq = 2 * beta ** 2
+ gamma_sq = 2 * gamma ** 2
+
+ # Invert the image to detect dark ridges on a light background
+ if black_ridges:
+ image = invert(image)
+
+ filtered_array = np.zeros(sigmas.shape + image.shape)
+ lambdas_array = np.zeros(sigmas.shape + image.shape)
+
+ for i, sigma in enumerate(sigmas):
+ lambda1, lambda2 = _hessian_eigvals_v016(image, sigma)
+
+ # Sensitivity to deviation from a blob-like structure (eq. 10 and 15 in
+ # Frangi et al.); np.abs(lambda2) in 2-D
+ r_b = _divide_nonzero(lambda1, np.abs(lambda2)) ** 2
+
+ # Sensitivity to areas of high structure (eq. 12)
+ r_g = lambda1 ** 2 + lambda2 ** 2
+
+ # The plate-like term (1 - exp(-r_a / alpha_sq)) equals 1 in 2-D
+ # because r_a is infinite there.
+ filtered_array[i] = np.exp(-r_b / beta_sq) * (1 - np.exp(-r_g / gamma_sq))
+ lambdas_array[i] = lambda2
+
+ # Remove background
+ filtered_array[lambdas_array > 0] = 0
+
+ return np.max(filtered_array, axis=0)
diff --git a/fibermorph/core/section.py b/fibermorph/core/section.py
index fbce717..a80c32d 100644
--- a/fibermorph/core/section.py
+++ b/fibermorph/core/section.py
@@ -244,6 +244,44 @@ def segment_section(
return section_data, bin_im
+def section_props_extended(
+ mask_uint8: np.ndarray,
+ im_name: str,
+ resolution: float,
+) -> pd.DataFrame:
+ """Extract extended shape features from a segmented hair cross-section mask.
+
+ Calls shape_analysis.extract_features_from_array() for EFD, Hu moments,
+ and radial profile features in addition to the basic geometric metrics.
+
+ Parameters
+ ----------
+ mask_uint8 : 2D uint8 binary mask (0/255)
+ im_name : image name used as the ID column
+ resolution : pixels per µm
+
+ Returns
+ -------
+ pd.DataFrame
+ One-row DataFrame with all extracted features (or NaN on failure).
+ """
+ from ..core.shape_analysis import extract_features_from_array, classify_shape
+
+ feat = extract_features_from_array(
+ mask_uint8,
+ resolution_mu=resolution,
+ source_name=im_name,
+ )
+ if feat is None:
+ return pd.DataFrame({"ID": [im_name]})
+
+ shape_class = classify_shape(feat)
+ feat["shape_class"] = shape_class
+ feat["ID"] = im_name
+
+ return pd.DataFrame([feat])
+
+
def save_sections(
output_path: Union[str, pathlib.Path],
im_name: str,
diff --git a/fibermorph/core/shape_analysis.py b/fibermorph/core/shape_analysis.py
new file mode 100644
index 0000000..493929f
--- /dev/null
+++ b/fibermorph/core/shape_analysis.py
@@ -0,0 +1,307 @@
+"""Hair cross-section shape feature extraction and classification.
+
+Extracts from a binary mask (uint8 numpy array or PNG path):
+ - Basic geometric: circularity, solidity, convexity, aspect_ratio, eccentricity
+ - Radial distance profile: mean/std/min/max/CV, n_peaks, asymmetry_index
+ - Elliptic Fourier Descriptors: 10 harmonics, power spectrum, deviation score
+ - Hu moments: 7 log-transformed invariant moments
+
+Classifies into one of 7 shape classes:
+ Circular, Elliptical, Flattened, Tear-drop, Triangular, Multi-polar, Irregular
+
+Originally developed for the AFREU hair cross-section dataset (SAM2 pipeline).
+"""
+
+import os
+
+import cv2
+import numpy as np
+from scipy.ndimage import map_coordinates
+from scipy.signal import find_peaks
+from skimage.measure import label, regionprops
+
+RESOLUTION_MU = 4.25
+N_RADIAL_ANGLES = 360
+N_EFD_HARMONICS = 10
+
+
+def compute_efd(contour_pts: np.ndarray, n_harmonics: int = 10) -> dict:
+ """Compute Elliptic Fourier Descriptors using piecewise-linear arc-length parameterization.
+
+ Parameters
+ ----------
+ contour_pts : np.ndarray, shape (N, 2) — ordered (x, y) points
+ n_harmonics : int
+
+ Returns
+ -------
+ dict with keys: efd_coeffs, efd_norm, harmonic_power, efd_deviation
+ """
+ pts = contour_pts.astype(np.float64)
+ diffs = pts - np.roll(pts, 1, axis=0)
+ seg_lengths = np.maximum(np.linalg.norm(diffs, axis=1), 1e-10)
+ T = seg_lengths.sum()
+ t_cumul = np.cumsum(seg_lengths)
+ t_prev = t_cumul - seg_lengths
+ dx = diffs[:, 0]
+ dy = diffs[:, 1]
+
+ efd_coeffs = np.zeros((n_harmonics, 4))
+ for n in range(1, n_harmonics + 1):
+ two_pi_n_over_T = 2.0 * np.pi * n / T
+ factor = T / (2.0 * n ** 2 * np.pi ** 2)
+ phi_end = two_pi_n_over_T * t_cumul
+ phi_start = two_pi_n_over_T * t_prev
+ cos_diff = np.cos(phi_end) - np.cos(phi_start)
+ sin_diff = np.sin(phi_end) - np.sin(phi_start)
+ dx_per_dt = dx / seg_lengths
+ dy_per_dt = dy / seg_lengths
+ efd_coeffs[n - 1] = [
+ factor * np.sum(dx_per_dt * cos_diff),
+ factor * np.sum(dx_per_dt * sin_diff),
+ factor * np.sum(dy_per_dt * cos_diff),
+ factor * np.sum(dy_per_dt * sin_diff),
+ ]
+
+ a1, b1, c1, d1 = efd_coeffs[0]
+ theta1 = 0.5 * np.arctan2(
+ 2.0 * (a1 * b1 + c1 * d1),
+ a1 ** 2 - b1 ** 2 + c1 ** 2 - d1 ** 2,
+ )
+ ct, st = np.cos(theta1), np.sin(theta1)
+ size_factor = max(
+ np.sqrt((a1 * ct + b1 * st) ** 2 + (c1 * ct + d1 * st) ** 2), 1e-10
+ )
+ efd_norm = efd_coeffs / size_factor
+
+ harmonic_power = np.linalg.norm(efd_coeffs, axis=1)
+ efd_deviation = harmonic_power[1:].sum() / (harmonic_power[0] + 1e-10)
+
+ return {
+ "efd_coeffs": efd_coeffs,
+ "efd_norm": efd_norm,
+ "harmonic_power": harmonic_power,
+ "efd_deviation": efd_deviation,
+ }
+
+
+def compute_radial_profile(
+ binary_img: np.ndarray,
+ props,
+ n_angles: int = 360,
+ resolution_mu: float = RESOLUTION_MU,
+) -> dict:
+ """Cast rays from centroid and find boundary-crossing radius per angle.
+
+ Parameters
+ ----------
+ binary_img : 2D uint8 array (values 0 or 255)
+ props : skimage regionprops object
+ n_angles : number of rays
+ resolution_mu : pixels per µm
+
+ Returns
+ -------
+ dict with radial_mean_mu, radial_std_mu, radial_min_mu, radial_max_mu,
+ radial_cv, n_radial_peaks, asymmetry_index
+ """
+ cy, cx = props.centroid
+ max_r = max(2, int(props.major_axis_length * 0.65))
+ H, W = binary_img.shape
+ binary_f = (binary_img > 0).astype(np.float32)
+
+ angles = np.linspace(0.0, 2.0 * np.pi, n_angles, endpoint=False)
+ r_vals = np.arange(1, max_r + 1, dtype=np.float32)
+
+ sin_a = np.sin(angles)[:, None]
+ cos_a = np.cos(angles)[:, None]
+ r_ = r_vals[None, :]
+
+ ys = np.clip(cy + r_ * sin_a, 0, H - 1).ravel()
+ xs = np.clip(cx + r_ * cos_a, 0, W - 1).ravel()
+
+ vals = map_coordinates(binary_f, [ys, xs], order=0, mode="constant", cval=0.0)
+ vals = vals.reshape(n_angles, max_r)
+
+ radial_dists = np.empty(n_angles, dtype=np.float32)
+ for i in range(n_angles):
+ row = vals[i]
+ zeros = np.where(row == 0)[0]
+ if len(zeros) == 0:
+ radial_dists[i] = r_vals[-1]
+ elif zeros[0] == 0:
+ radial_dists[i] = r_vals[0]
+ else:
+ radial_dists[i] = r_vals[zeros[0] - 1]
+
+ prominence_thresh = float(radial_dists.mean()) * 0.05
+ min_sep = max(1, int(10 * n_angles / 360))
+ peaks, _ = find_peaks(
+ radial_dists.astype(np.float64),
+ prominence=prominence_thresh,
+ distance=min_sep,
+ )
+
+ orient = props.orientation
+ proj = cos_a.ravel() * (-np.sin(orient)) + sin_a.ravel() * np.cos(orient)
+ side_A = radial_dists[proj >= 0]
+ side_B = radial_dists[proj < 0]
+ mean_total = float(radial_dists.mean()) + 1e-10
+ if len(side_A) > 0 and len(side_B) > 0:
+ asymmetry_index = abs(float(side_A.mean()) - float(side_B.mean())) / mean_total
+ else:
+ asymmetry_index = 0.0
+
+ rm = resolution_mu
+ return {
+ "radial_mean_mu": float(radial_dists.mean()) * rm,
+ "radial_std_mu": float(radial_dists.std()) * rm,
+ "radial_min_mu": float(radial_dists.min()) * rm,
+ "radial_max_mu": float(radial_dists.max()) * rm,
+ "radial_cv": float(radial_dists.std()) / (float(radial_dists.mean()) + 1e-10),
+ "n_radial_peaks": int(len(peaks)),
+ "asymmetry_index": float(asymmetry_index),
+ }
+
+
+def extract_features_from_array(
+ mask_array: np.ndarray,
+ n_angles: int = N_RADIAL_ANGLES,
+ n_harmonics: int = N_EFD_HARMONICS,
+ resolution_mu: float = RESOLUTION_MU,
+ source_name: str = "",
+) -> dict | None:
+ """Extract all shape features from a 2D uint8 binary mask array (values 0/255).
+
+ Returns None if the mask is empty or the contour area is less than 100 px².
+ """
+ img = mask_array
+ if img is None or img.max() == 0:
+ return None
+
+ contours, _ = cv2.findContours(img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
+ if not contours:
+ return None
+ contour = max(contours, key=cv2.contourArea)
+
+ area_px = cv2.contourArea(contour)
+ if area_px < 100:
+ return None
+
+ perimeter_px = cv2.arcLength(contour, True)
+ hull = cv2.convexHull(contour)
+ hull_area_px = cv2.contourArea(hull)
+ hull_peri_px = cv2.arcLength(hull, True)
+
+ circularity = 4.0 * np.pi * area_px / (perimeter_px ** 2 + 1e-10)
+ solidity = area_px / (hull_area_px + 1e-10)
+ convexity = hull_peri_px / (perimeter_px + 1e-10)
+
+ M = cv2.moments(contour)
+ hu = cv2.HuMoments(M).flatten()
+ hu_log = np.array([-np.sign(h) * np.log10(abs(h) + 1e-30) for h in hu])
+
+ labeled = label(img > 0)
+ rp_list = regionprops(labeled)
+ if not rp_list:
+ return None
+ props = rp_list[0]
+
+ aspect_ratio = props.major_axis_length / (props.minor_axis_length + 1e-10)
+ eccentricity = props.eccentricity
+ orientation_deg = float(np.degrees(props.orientation))
+
+ rad = compute_radial_profile(img, props, n_angles, resolution_mu)
+ pts = contour[:, 0, :].astype(np.float64)
+ efd = compute_efd(pts, n_harmonics)
+
+ feat: dict = {
+ "mask_filename": source_name,
+ "area_mu2": area_px / (resolution_mu ** 2),
+ "perimeter_mu": perimeter_px / resolution_mu,
+ "circularity": float(circularity),
+ "solidity": float(solidity),
+ "convexity": float(convexity),
+ "aspect_ratio": float(aspect_ratio),
+ "eccentricity": float(eccentricity),
+ "orientation_deg": orientation_deg,
+ }
+ feat.update(rad)
+
+ for i, v in enumerate(hu_log, start=1):
+ feat[f"hu{i}"] = float(v)
+
+ norm = efd["efd_norm"]
+ for h in range(n_harmonics):
+ feat[f"efd_a{h+1}"] = float(norm[h, 0])
+ feat[f"efd_b{h+1}"] = float(norm[h, 1])
+ feat[f"efd_c{h+1}"] = float(norm[h, 2])
+ feat[f"efd_d{h+1}"] = float(norm[h, 3])
+
+ power = efd["harmonic_power"]
+ for h in range(n_harmonics):
+ feat[f"efd_power_h{h+1}"] = float(power[h])
+
+ feat["efd_deviation"] = float(efd["efd_deviation"])
+ return feat
+
+
+def extract_features(
+ mask_path: str,
+ n_angles: int = N_RADIAL_ANGLES,
+ n_harmonics: int = N_EFD_HARMONICS,
+ resolution_mu: float = RESOLUTION_MU,
+) -> dict | None:
+ """Load a mask PNG from disk and extract all shape features. Returns None on failure."""
+ img = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE)
+ if img is None:
+ return None
+ return extract_features_from_array(
+ img, n_angles, n_harmonics, resolution_mu,
+ source_name=os.path.basename(mask_path),
+ )
+
+
+def classify_shape(feat: dict, debug: bool = False) -> str:
+ """Rule-based shape classification (ordered priority; first match wins).
+
+ Classes (in priority order):
+ Multi-polar, Triangular, Flattened, Irregular, Tear-drop, Circular, Elliptical
+ """
+ circ = feat["circularity"]
+ solid = feat["solidity"]
+ ar = feat["aspect_ratio"]
+ ecc = feat["eccentricity"]
+ n_pk = feat["n_radial_peaks"]
+ asym = feat["asymmetry_index"]
+
+ if solid < 0.82 and n_pk > 3:
+ if debug: print(" → Multi-polar")
+ return "Multi-polar"
+
+ if n_pk == 3 and solid < 0.95 and circ < 0.78:
+ if debug: print(" → Triangular")
+ return "Triangular"
+
+ if ar >= 2.5 or (ar > 2.0 and ecc > 0.88):
+ if debug: print(" → Flattened")
+ return "Flattened"
+
+ if circ < 0.60:
+ if debug: print(" → Irregular")
+ return "Irregular"
+
+ if asym > 0.15 and 0.55 <= circ < 0.80 and 1.1 <= ar < 2.0:
+ if debug: print(" → Tear-drop")
+ return "Tear-drop"
+
+ if circ > 0.85 and ar < 1.3:
+ if debug: print(" → Circular")
+ return "Circular"
+
+ if circ >= 0.72 and 1.3 <= ar < 2.5:
+ if debug: print(" → Elliptical")
+ return "Elliptical"
+
+ if debug: print(" → Irregular (default)")
+ return "Irregular"
diff --git a/fibermorph/gui/__init__.py b/fibermorph/gui/__init__.py
index 429c3ce..e61401b 100644
--- a/fibermorph/gui/__init__.py
+++ b/fibermorph/gui/__init__.py
@@ -1,9 +1,13 @@
"""GUI entry points for fibermorph."""
-from importlib import import_module
-
def main() -> None:
- """Launch the Streamlit application."""
- app = import_module(".app", package=__name__)
- app.main()
+ """Launch the Streamlit application.
+
+ The v2 app (fibermorph/gui/app.py) is a top-level Streamlit script
+ with no main() function, so it must be started through `streamlit
+ run`; delegate to the launcher, which does exactly that.
+ """
+ from .launcher import main as launch
+
+ launch()
diff --git a/fibermorph/gui/app.py b/fibermorph/gui/app.py
index f11a20e..c9db1ef 100644
--- a/fibermorph/gui/app.py
+++ b/fibermorph/gui/app.py
@@ -1,312 +1,1149 @@
-"""Streamlit-powered graphical interface for fibermorph."""
+"""
+fibermorph/gui/app.py — fibermorph Streamlit interface
+======================================================
+Four tabs:
+
+ Cross-Section — segment and measure cross-section images in-process;
+ per-image results and mask previews appear inline.
+
+ Curvature — measure curvature images in-process; per-fragment length
+ and curvature (plus per-image summaries) appear inline.
+
+ Run Local — how to install and launch this same GUI on your own
+ machine (no upload limit; read images straight from a
+ folder on disk). Those extra options appear only when
+ launched via `fibermorph-gui --local`.
+
+ Run Remote — documentation + an SBATCH script scaffold for running the
+ fibermorph CLI on an HPC cluster. It generates a script to
+ download and submit yourself; it does not submit anything
+ or connect to any cluster.
+
+Start via:
+ fibermorph-gui # hosted mode: uploads only, 500 MB default cap
+ fibermorph-gui --local # local mode: folder input, 5 GB default cap, localhost only
+ # (the cap shown in the app is Streamlit's server.maxUploadSize, so it follows
+ # STREAMLIT_SERVER_MAX_UPLOAD_SIZE or --server.maxUploadSize if you set one)
+ # or directly:
+ python -m streamlit run fibermorph/gui/app.py --server.port 8501
+"""
from __future__ import annotations
-import io
-import logging
-import shutil
+import os
import tempfile
-import zipfile
-from dataclasses import dataclass
from pathlib import Path
-from typing import Iterable, List, Optional
-from urllib.parse import urlsplit
-
-try: # pragma: no cover - exercised at runtime
- import streamlit as st
-except ModuleNotFoundError: # pragma: no cover - exercised at runtime
- st = None # type: ignore[assignment]
import pandas as pd
-import requests
+import streamlit as st
-from fibermorph import __version__
-from fibermorph.workflows import curvature, section
+from fibermorph.utils.units import resolution_to_px_per_unit
+from fibermorph.gui import inputs, styles
-LOGGER = logging.getLogger(__name__)
+# ---------------------------------------------------------------------------
+# Page config — must be the first Streamlit call
+# ---------------------------------------------------------------------------
+st.set_page_config(
+ page_title="fibermorph — Lasisi Lab",
+ page_icon="🔬",
+ layout="wide",
+ initial_sidebar_state="expanded",
+)
+# Lasisi Lab design system — fonts, sidebar, cards, table, buttons.
+st.markdown(styles.css(), unsafe_allow_html=True)
-@dataclass
-class CurvatureOptions:
- resolution_mm: float = 132.0
- window_size: Optional[float] = 50.0
- window_unit: str = "px"
- jobs: int = 1
- save_images: bool = False
- within_element: bool = False
+# ---------------------------------------------------------------------------
+# Session state defaults
+# ---------------------------------------------------------------------------
+for _key, _default in [
+ ("active_view", "section"),
+ ("sbatch_script", ""),
+ ("section_results", None),
+ ("curvature_fragments", None),
+ ("curvature_summary", None),
+ ("seg_store", {}),
+]:
+ if _key not in st.session_state:
+ st.session_state[_key] = _default
+# ---------------------------------------------------------------------------
+# Resolve default SAM2 checkpoint
+# ---------------------------------------------------------------------------
+_HERE = Path(__file__).resolve().parent # fibermorph/gui/
+_PKG_ROOT = _HERE.parent # fibermorph/
-@dataclass
-class SectionOptions:
- resolution_mu: float = 4.25
- minsize: float = 20.0
- maxsize: float = 150.0
- jobs: int = 1
- save_images: bool = False
+def _resolve_checkpoint() -> str:
+ if os.environ.get("SAM2_CHECKPOINT"):
+ return os.environ["SAM2_CHECKPOINT"]
+ candidates = [
+ _PKG_ROOT / "checkpoints" / "sam2.1_hiera_tiny.pt",
+ _PKG_ROOT.parent.parent / "sam2" / "checkpoints" / "sam2.1_hiera_tiny.pt",
+ ]
+ for p in candidates:
+ if p.exists():
+ return str(p)
+ return str(candidates[0])
-def _ensure_streamlit_installed() -> None:
- if st is None:
- raise RuntimeError(
- "Streamlit is not installed. Install GUI dependencies with "
- "`pip install fibermorph[gui]` and try again."
- )
+_DEFAULT_CHECKPOINT = _resolve_checkpoint()
-def _write_uploaded_files(
- uploaded_files: Iterable["st.runtime.uploaded_file_manager.UploadedFile"], # type: ignore[attr-defined]
- target_dir: Path,
-) -> List[Path]:
- saved: List[Path] = []
- for file in uploaded_files:
- filename = Path(file.name).name
- suffix = Path(filename).suffix.lower()
- if suffix == ".zip":
- with zipfile.ZipFile(io.BytesIO(file.getvalue())) as archive:
- archive.extractall(target_dir)
- else:
- destination = target_dir / filename
- destination.write_bytes(file.getbuffer())
-
- saved = sorted(
- [
- p
- for p in target_dir.rglob("*")
- if p.is_file() and p.suffix.lower() in {".tif", ".tiff"}
- ]
+# ---------------------------------------------------------------------------
+# In-process helpers for the Cross-Section and Curvature tabs
+# ---------------------------------------------------------------------------
+
+def _process_section_gui(
+ tmp_path: str,
+ resolution_mu: float,
+ min_diam: float,
+ max_diam: float,
+ use_sam2: bool,
+ sam2_checkpoint: str,
+ return_mask: bool = False,
+ display_name: str | None = None,
+):
+ """Run section analysis on a single image.
+
+ Returns dict of measurements, or (dict, gray_img, mask_uint8) when
+ return_mask=True. Returns None if no cross-section was detected.
+
+ display_name is the image's name as the user knows it (for an upload, the
+ uploaded filename, not the generated name of the temporary file). It is
+ recorded in the result's ID and mask_filename fields; it defaults to the
+ basename of tmp_path.
+ """
+ import cv2
+ from fibermorph.processing.section_sam2 import segment_section
+ from fibermorph.core.section import section_props_extended
+ from fibermorph.utils.imaging import downsample_to_resolution
+
+ gray = cv2.imread(tmp_path, cv2.IMREAD_GRAYSCALE)
+ if gray is None:
+ return None
+
+ # Large scans carry far more pixels than a shape measurement needs. Downsample
+ # to the target working resolution and scale resolution_mu with it — the µm
+ # measurements are preserved (<0.5%) while processing is much lighter.
+ gray, resolution_mu = downsample_to_resolution(gray, resolution_mu)
+
+ # Note: don't pass model_cfg="" — that empty string would override
+ # segment_section's default SAM2 config and make SAM2 silently fail to
+ # load (falling back to watershed). Omit it so the default applies.
+ seg_result = segment_section(
+ gray,
+ resolution_mu=resolution_mu,
+ min_diam=min_diam,
+ max_diam=max_diam,
+ use_sam2=use_sam2,
+ checkpoint=sam2_checkpoint,
)
- return saved
+ if seg_result is None:
+ return None
+ mask_uint8, confidence, method = seg_result
-def _latest_child_dir(directory: Path) -> Optional[Path]:
- children = [p for p in directory.iterdir() if p.is_dir()]
- if not children:
+ df = section_props_extended(
+ mask_uint8, display_name or os.path.basename(tmp_path), resolution_mu
+ )
+ if df is None or df.empty:
return None
- return max(children, key=lambda p: p.stat().st_mtime)
+ row = df.iloc[0].to_dict()
+ row["confidence"] = confidence
+ row["segmentation_method"] = method
-def _download_dataset(url: str, target_dir: Path) -> List[Path]:
- response = requests.get(url, stream=True, timeout=60)
- response.raise_for_status()
+ if return_mask:
+ return row, gray, mask_uint8
+ return row
- filename = Path(urlsplit(url).path).name or "dataset"
- suffix = Path(filename).suffix.lower()
- if suffix == ".zip":
- with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
- archive.extractall(target_dir)
- files = sorted(
- [
- p
- for p in target_dir.rglob("*")
- if p.is_file() and p.suffix.lower() in {".tif", ".tiff"}
- ]
+def _process_curvature_gui(
+ tmp_path: str,
+ resolution_mm: float,
+ window_size: int,
+):
+ """Run curvature analysis on a single image.
+
+ Returns the per-fragment DataFrame, or None if nothing was measured.
+
+ The pipeline detects each connected fiber fragment, measures its length and
+ curvature, and writes that per-fragment table to analysis/ImageSum_.csv
+ (columns curv_mean, curv_median, length — one row per fragment). We read that
+ back before the temp dir is removed so the GUI can show per-fragment results,
+ not just the per-image aggregate the pipeline returns.
+ """
+ import glob
+ import tempfile as _tmpmod
+ from fibermorph.analysis.curvature_pipeline import curvature_seq
+
+ with _tmpmod.TemporaryDirectory() as out_dir:
+ df = curvature_seq(
+ tmp_path,
+ out_dir,
+ resolution=resolution_mm,
+ window_size=window_size,
+ window_unit="px",
+ save_img=False,
+ test=False,
+ within_element=False,
)
- if not files:
- raise ValueError("No TIFF files were found inside the downloaded archive.")
- return files
-
- if suffix in {".tif", ".tiff"}:
- destination = target_dir / filename
- with destination.open("wb") as handle:
- for chunk in response.iter_content(chunk_size=8192):
- handle.write(chunk)
- return [destination]
-
- raise ValueError("URL must point to a .tif/.tiff image or a .zip archive containing TIFFs.")
-
-
-def _load_csvs(root: Path, pattern: str) -> List[pd.DataFrame]:
- frames: List[pd.DataFrame] = []
- for csv_path in sorted(root.rglob(pattern)):
- try:
- frames.append(pd.read_csv(csv_path))
- except Exception as exc: # pragma: no cover - defensive logging
- LOGGER.warning("Unable to read %s: %s", csv_path, exc)
- return frames
-
-
-def _zip_directory(directory: Path) -> bytes:
- archive_path = shutil.make_archive(
- base_name=str(directory),
- format="zip",
- root_dir=directory.parent,
- base_dir=directory.name,
- )
- data = Path(archive_path).read_bytes()
- Path(archive_path).unlink(missing_ok=True)
- return data
-
-
-def _run_curvature(
- input_dir: Path,
- output_dir: Path,
- options: CurvatureOptions,
-) -> List[pd.DataFrame]:
- curvature(
- input_directory=str(input_dir),
- main_output_path=str(output_dir),
- jobs=options.jobs,
- resolution=options.resolution_mm,
- window_size=options.window_size,
- window_unit=options.window_unit,
- save_img=options.save_images,
- within_element=options.within_element,
- )
- result_dir = _latest_child_dir(output_dir)
- if result_dir is None:
+ if df is None or (hasattr(df, "empty") and df.empty):
+ return None
+
+ fragments = None
+ matches = glob.glob(os.path.join(out_dir, "**", "ImageSum_*.csv"),
+ recursive=True)
+ if matches:
+ fdf = pd.read_csv(matches[0])
+ keep = [c for c in ("curv_mean", "curv_median", "length")
+ if c in fdf.columns]
+ if keep:
+ fdf = fdf[keep].dropna(how="all").reset_index(drop=True)
+ fdf.insert(0, "fragment", range(1, len(fdf) + 1))
+ fragments = fdf
+
+ return fragments
+
+
+def _warn_duplicate_names(names):
+ """Warn if any inputs share a filename (ambiguous source_file)."""
+ dups = sorted({n for n in names if names.count(n) > 1})
+ if dups:
+ st.warning(
+ "Duplicate filenames — the `source_file` column will be ambiguous for "
+ "these (each file is still measured separately): " + ", ".join(dups)
+ )
+
+
+# ---------------------------------------------------------------------------
+# Local mode: read images straight from a folder on disk (no upload, no size
+# cap). Only offered when the app was launched with `fibermorph-gui --local`
+# (which sets FIBERMORPH_LOCAL=1) on the user's own machine — a hosted app stays
+# upload-only, because local mode lets every visitor read folders on the server.
+# ---------------------------------------------------------------------------
+_LOCAL = os.environ.get("FIBERMORPH_LOCAL") == "1"
+
+
+def _sam2_status() -> tuple[bool, str]:
+ """(can SAM2 run in this app, reason if not). See sam2_runtime_status."""
+ from fibermorph.processing import section_sam2
+ return section_sam2.sam2_runtime_status()
+
+
+# Shown wherever SAM2 is unavailable in this app, so users know where it does run.
+_SAM2_ELSEWHERE = (
+ "To segment with SAM2, run the app on a machine with a GPU (**Run Local**, "
+ "`fibermorph-gui --local`) or generate a cluster script with SAM2 enabled "
+ "(**Run Remote**)."
+)
+
+
+def _upload_cap() -> str:
+ """The upload cap Streamlit is enforcing, as text for the page ("500 MB",
+ "5 GB").
+
+ Read from Streamlit's own setting, so it follows whatever the host chose
+ (the launcher's default, STREAMLIT_SERVER_MAX_UPLOAD_SIZE,
+ --server.maxUploadSize or config.toml) and always matches the limit the
+ file uploader shows.
+ """
+ try:
+ return inputs.format_upload_cap(st.get_option("server.maxUploadSize"))
+ except (TypeError, ValueError):
+ return inputs.format_upload_cap(5000 if _LOCAL else 500)
+
+
+_UPLOAD_CAP = _upload_cap()
+
+
+def _list_folder_images(folder):
+ """Image files in a local folder matching the accepted upload types."""
+ import glob
+ if not folder or not os.path.isdir(folder):
return []
- return _load_csvs(result_dir, "curvature_summary_data*.csv")
-
-
-def _run_section(
- input_dir: Path,
- output_dir: Path,
- options: SectionOptions,
-) -> List[pd.DataFrame]:
- section(
- input_directory=str(input_dir),
- main_output_path=str(output_dir),
- jobs=options.jobs,
- resolution=options.resolution_mu,
- minsize=options.minsize,
- maxsize=options.maxsize,
- save_img=options.save_images,
+ files = []
+ for ext in _UPLOAD_TYPES:
+ files += glob.glob(os.path.join(folder, f"*.{ext}"))
+ files += glob.glob(os.path.join(folder, f"*.{ext.upper()}"))
+ return sorted(set(files))
+
+
+def _render_input_picker(kind, key_prefix):
+ """Render the input control for an analysis tab.
+
+ Returns ("folder", path) or ("upload", uploaded_files). The folder option
+ only appears in local mode.
+ """
+ if _LOCAL:
+ mode = st.radio(
+ "Input source", ["Upload files", "Folder on disk"],
+ horizontal=True, key=f"{key_prefix}_mode",
+ help="Running locally — you can read images straight from a folder on "
+ "this computer, with no upload and no file-size limit.",
+ )
+ if mode == "Folder on disk":
+ folder = st.text_input(
+ f"Folder containing {kind} images", placeholder="/path/to/images",
+ key=f"{key_prefix}_folder",
+ )
+ if folder:
+ n = len(_list_folder_images(folder))
+ st.caption(f"{n} image(s) found." if n
+ else "No images found in that folder.")
+ return ("folder", folder)
+ ups = st.file_uploader(
+ f"Upload {kind} images (TIFF, PNG, JPG)", type=_UPLOAD_TYPES,
+ accept_multiple_files=True, key=f"{key_prefix}_uploads",
)
- result_dir = _latest_child_dir(output_dir)
- if result_dir is None:
- return []
- return _load_csvs(result_dir, "summary_section_data.csv")
+ return ("upload", ups)
+
+
+def _gather_inputs(source, tmpdir):
+ """Turn a picker result into a list of (display_name, filepath).
+
+ Folder mode reads paths directly from disk; upload mode persists each
+ uploaded file into tmpdir first, under a generated name (the client's
+ filename is only used as the display name — see fibermorph.gui.inputs).
+ """
+ mode, payload = source
+ if mode == "folder":
+ return [(os.path.basename(p), p) for p in _list_folder_images(payload)]
+ return inputs.save_uploads(payload, tmpdir)
+
+
+def _source_is_empty(source):
+ """True when a picker result has no images to process."""
+ mode, payload = source
+ if mode == "folder":
+ return not _list_folder_images(payload)
+ return not payload
+
+
+def _metric_histograms(df, metrics, suptitle):
+ """Simple ungrouped histograms of the given (column, label) metrics.
+
+ Returns a matplotlib Figure, or None if no metric has 2+ values. This is a
+ per-image sanity view — not a grouped statistical analysis.
+ """
+ import matplotlib.pyplot as plt
+ import numpy as np
+
+ series = []
+ for col, label in metrics:
+ if col not in df.columns:
+ continue
+ vals = df[col].replace([np.inf, -np.inf], np.nan).dropna()
+ if len(vals) >= 2:
+ series.append((label, vals))
+ if not series:
+ return None
+ fig, axes = plt.subplots(1, len(series), figsize=(5 * len(series), 4))
+ if len(series) == 1:
+ axes = [axes]
+ for i, (ax, (label, vals)) in enumerate(zip(axes, series)):
+ ax.hist(vals, bins="auto",
+ color=styles.CHART_COLORS[i % len(styles.CHART_COLORS)],
+ edgecolor="white")
+ ax.set_xlabel(label)
+ ax.set_ylabel("Count")
+ ax.spines["top"].set_visible(False)
+ ax.spines["right"].set_visible(False)
+ fig.suptitle(suptitle, fontsize=12, fontweight="bold")
+ fig.tight_layout()
+ return fig
+
+
+def _faceted_histograms(df, group_col, metrics, suptitle):
+ """One row of histograms per group (e.g. per source image), one column per
+ metric — so each sample's distribution is on its own paneled row.
+ Each metric column shares one x-axis (and common bin edges) across all
+ sample rows, so the distributions line up for at-a-glance comparison.
-def main() -> None:
- """Launch the Streamlit interface."""
- _ensure_streamlit_installed()
+ Returns a matplotlib Figure, or None if nothing is plottable.
+ """
+ import matplotlib.pyplot as plt
+ import numpy as np
- st.set_page_config(page_title="fibermorph GUI", layout="wide")
- st.title("fibermorph")
- st.caption(f"Version {__version__}")
+ groups = list(dict.fromkeys(df[group_col].tolist())) # unique, order-preserving
+ cols = [(c, label) for c, label in metrics if c in df.columns]
+ if not groups or not cols:
+ return None
+
+ # Common bin edges per metric (from all samples pooled) so the bars align
+ # and every row shares the same x-scale.
+ col_bins = []
+ for col, _ in cols:
+ allvals = df[col].replace([np.inf, -np.inf], np.nan).dropna()
+ col_bins.append(np.histogram_bin_edges(allvals, bins="auto")
+ if len(allvals) >= 1 else "auto")
+
+ nrows, ncols = len(groups), len(cols)
+ fig, axes = plt.subplots(nrows, ncols, figsize=(5 * ncols, 2.6 * nrows),
+ squeeze=False, sharex="col")
+ for r, g in enumerate(groups):
+ sub = df[df[group_col] == g]
+ for c, (col, label) in enumerate(cols):
+ ax = axes[r][c]
+ vals = sub[col].replace([np.inf, -np.inf], np.nan).dropna()
+ if len(vals) >= 1:
+ ax.hist(vals, bins=col_bins[c],
+ color=styles.CHART_COLORS[c % len(styles.CHART_COLORS)],
+ edgecolor="white")
+ ax.spines["top"].set_visible(False)
+ ax.spines["right"].set_visible(False)
+ if r == 0:
+ ax.set_title(label, fontsize=10)
+ if c == 0:
+ ax.set_ylabel(str(g), fontsize=9)
+ if r == nrows - 1:
+ ax.set_xlabel(label)
+ fig.suptitle(suptitle, fontsize=12, fontweight="bold")
+ fig.tight_layout()
+ return fig
+
+
+# ---------------------------------------------------------------------------
+# Constants
+# ---------------------------------------------------------------------------
+_FILENAME_NOTE = (
+ "Each result row records its **source filename**. The app analyzes one image "
+ "at a time and does no grouping — name your files however you'll want to group "
+ "them (within/between individual) in your own downstream analysis."
+)
+_UPLOAD_TYPES = inputs.UPLOAD_TYPES
- analysis_mode = st.radio(
- "Choose analysis workflow", options=("Curvature", "Section"), horizontal=True
+# ---------------------------------------------------------------------------
+# Sidebar: brand + navigation + status (replaces the top tab bar)
+# ---------------------------------------------------------------------------
+_NAV_GROUPS = [
+ ("Analyze", [("section", "Cross-Section"), ("curvature", "Curvature")]),
+ ("Run", [("local", "Run Local"), ("remote", "Run Remote")]),
+]
+
+with st.sidebar:
+ st.markdown(styles.brand_html(), unsafe_allow_html=True)
+ for _eyebrow, _items in _NAV_GROUPS:
+ st.markdown(f'{_eyebrow}
', unsafe_allow_html=True)
+ for _key, _label in _items:
+ _active = st.session_state.active_view == _key
+ if st.button(_label, key=f"nav_{_key}", use_container_width=True,
+ type="primary" if _active else "secondary"):
+ st.session_state.active_view = _key
+ st.rerun()
+ _status = (f"Local · {_UPLOAD_CAP} cap · folder input" if _LOCAL
+ else f"Hosted · {_UPLOAD_CAP} upload cap")
+ st.markdown(styles.footer_html(_status, "v2.0 · SAM2 + watershed"),
+ unsafe_allow_html=True)
+
+_view = st.session_state.active_view
+
+
+# ============================================================
+# TAB 1 — Cross-Section (file upload + in-process run)
+# ============================================================
+if _view == "section":
+ st.markdown(styles.view_header(
+ "Cross-Section",
+ "Segment & measure cross-sections",
+ "Upload cross-section images; segment them (SAM2 / watershed) and measure "
+ "shape per image.",
+ ), unsafe_allow_html=True)
+ st.info(
+ "Segment and measure cross-section images here. For images too large to "
+ "upload, run this app locally (see **Run Local**); for a whole study on a "
+ "cluster, see **Run Remote**."
)
- input_mode = st.radio(
- "Image source",
- options=("Upload TIFF files", "Download from URL"),
- horizontal=True,
+ _sam2_ok, _sam2_reason = _sam2_status()
+ if not _sam2_ok:
+ # SAM2 cannot run here (e.g. the Streamlit-hosted app has no GPU): say so
+ # up front instead of letting it fall back to watershed unannounced.
+ _where = "this hosted app" if not _LOCAL else "this machine"
+ st.warning(
+ f"**SAM2 is unavailable on {_where}** ({_sam2_reason}); cross-sections "
+ "are segmented with watershed. " + _SAM2_ELSEWHERE
+ )
+ st.caption(_FILENAME_NOTE)
+
+ sec_source = _render_input_picker("cross-section", "sec")
+
+ with st.expander("Settings", expanded=False):
+ c1, c2, c3, c4 = st.columns(4)
+ sec_res_val = c1.number_input(
+ "Resolution", value=4.25, step=0.01, min_value=0.0001, key="sec_res_val",
+ help="Enter your microscope scale in whichever unit you have; pick the "
+ "matching unit on the right and it is converted for you.")
+ sec_res_unit = c2.selectbox("Unit", ["px/µm", "µm/px"], key="sec_res_unit")
+ sec_min_d = c3.number_input("Min diameter (µm)", value=30.0, step=1.0, key="sec_min_d")
+ sec_max_d = c4.number_input("Max diameter (µm)", value=150.0, step=1.0, key="sec_max_d")
+ sec_res_mu = resolution_to_px_per_unit(sec_res_val, sec_res_unit)
+ st.caption(f"Working resolution: **{sec_res_mu:.4g} px/µm**")
+ sec_sam2 = st.toggle(
+ "Use SAM2 segmentation (GPU required)", value=False, key="sec_sam2",
+ disabled=not _sam2_ok,
+ help=None if _sam2_ok else f"Unavailable here: {_sam2_reason}.")
+ if not _sam2_ok:
+ # Locked off; the warning above says why and where SAM2 does run.
+ sec_sam2 = False
+ sec_ckpt = _DEFAULT_CHECKPOINT
+ elif _LOCAL:
+ sec_ckpt = st.text_input("SAM2 checkpoint path", value=_DEFAULT_CHECKPOINT, key="sec_ckpt")
+ else:
+ # Hosted: visitors must not choose which file the server loads as a
+ # model, so use the server-configured checkpoint and don't show its path.
+ sec_ckpt = _DEFAULT_CHECKPOINT
+ # Reached only when the sam2 package and a CUDA GPU are present, so
+ # the checkpoint file is the one thing left to report on.
+ st.caption(
+ "SAM2 checkpoint: file found on this server."
+ if os.path.isfile(sec_ckpt) else
+ "SAM2 checkpoint: no file found on this server, so SAM2 "
+ "segmentation falls back to watershed."
+ )
+
+ if st.button("▶ Analyze cross-sections", type="primary", key="sec_run"):
+ if _source_is_empty(sec_source):
+ st.error("Provide at least one cross-section image.")
+ else:
+ with tempfile.TemporaryDirectory() as tmpdir:
+ sec_inputs = _gather_inputs(sec_source, tmpdir)
+ _warn_duplicate_names([n for n, _ in sec_inputs])
+ rows = []
+ seg_store = []
+ failed_sections = []
+ progress = st.progress(0, text="Processing…")
+
+ for idx, (name, path) in enumerate(sec_inputs):
+ progress.progress(idx / len(sec_inputs),
+ text=f"Processing {name}…")
+ try:
+ out = _process_section_gui(
+ path,
+ resolution_mu=float(sec_res_mu),
+ min_diam=float(sec_min_d),
+ max_diam=float(sec_max_d),
+ use_sam2=bool(sec_sam2),
+ sam2_checkpoint=str(sec_ckpt),
+ return_mask=True,
+ display_name=name,
+ )
+ except Exception as e:
+ st.warning(f"{name}: {inputs.restore_names(e, path, name)}")
+ out = None
+
+ if out is not None:
+ result, gray_img, mask_img = out
+ seg_store.append((name, gray_img, mask_img))
+ row = {"image_type": "section", "source_file": name}
+ row.update(result)
+ rows.append(row)
+ else:
+ failed_sections.append(name)
+
+ progress.progress(1.0, text="Done.")
+
+ if failed_sections:
+ st.warning(
+ "No cross-section was detected in: "
+ + ", ".join(failed_sections)
+ + ". If a cross-section is clearly present, the most common cause is the "
+ "**Resolution** — it must be in **pixels per µm** (e.g. enter **5.556** "
+ "for a 0.18 µm/pixel scale), not µm/pixel. Also check the min/max "
+ "diameter range."
+ )
+
+ if not rows:
+ # Clear any prior run's results so stale tables/masks don't linger.
+ st.session_state.pop("section_results", None)
+ st.session_state.pop("seg_store", None)
+ st.error("No cross-sections were measured.")
+ else:
+ st.session_state["section_results"] = pd.DataFrame(rows)
+ st.session_state["seg_store"] = seg_store
+ st.success(f"Measured {len(rows)} / {len(sec_inputs)} cross-section(s).")
+
+ # ---- Cross-section results ----
+ sec_df = st.session_state.get("section_results")
+ if sec_df is not None and not sec_df.empty:
+ import matplotlib.pyplot as plt
+
+ st.markdown(' ', unsafe_allow_html=True)
+ _cards = [{"label": "Measured", "value": len(sec_df), "hero": True}]
+ if "area_mu2" in sec_df.columns:
+ _cards.append({"label": "Mean area",
+ "value": f"{sec_df['area_mu2'].mean():,.0f}", "unit": "µm²"})
+ if "eccentricity" in sec_df.columns:
+ _cards.append({"label": "Mean eccentricity",
+ "value": f"{sec_df['eccentricity'].mean():.3f}"})
+ if "segmentation_method" in sec_df.columns:
+ _pills = []
+ for _m, _n in sec_df["segmentation_method"].value_counts().items():
+ _sam = str(_m).lower().startswith("sam")
+ _pills.append((f"{'SAM2' if _sam else str(_m).title()} · {_n}",
+ "teal" if _sam else "indigo"))
+ _cards.append({"label": "Segmentation", "pills": _pills})
+ st.markdown(styles.metric_cards(_cards), unsafe_allow_html=True)
+
+ sec_cols = {
+ "source_file": "File",
+ "area_mu2": "Area (µm²)",
+ "circularity": "Circularity",
+ "aspect_ratio": "Aspect Ratio",
+ "eccentricity": "Eccentricity",
+ "solidity": "Solidity",
+ "segmentation_method": "Method",
+ }
+ present = {k: v for k, v in sec_cols.items() if k in sec_df.columns}
+ st.markdown("**Key Measurements**")
+ st.dataframe(
+ sec_df[list(present.keys())].rename(columns=present).style.format({
+ "Area (µm²)": "{:.0f}",
+ "Circularity": "{:.3f}",
+ "Aspect Ratio": "{:.2f}",
+ "Eccentricity": "{:.3f}",
+ "Solidity": "{:.3f}",
+ }),
+ use_container_width=True,
+ )
+
+ if len(sec_df) >= 2:
+ fig = _metric_histograms(
+ sec_df,
+ [("area_mu2", "Area (µm²)"), ("eccentricity", "Eccentricity")],
+ "Distribution across uploaded images",
+ )
+ if fig is not None:
+ st.pyplot(fig)
+ plt.close(fig)
+ else:
+ st.info("Upload 2 or more cross-section images to see a distribution. "
+ "The per-image measurements are in the table above and the CSV.")
+
+ st.download_button(
+ "📥 Download cross-section CSV",
+ data=sec_df.to_csv(index=False),
+ file_name="section_results.csv",
+ mime="text/csv",
+ )
+
+ # Segmentation mask preview for this run
+ seg_store = st.session_state.get("seg_store", {})
+ if seg_store:
+ with st.expander(f"Segmentation masks ({len(seg_store)})", expanded=False):
+ import numpy as _np
+
+ def _to_u8(arr):
+ a = arr.astype(float)
+ lo, hi = a.min(), a.max()
+ if hi > lo:
+ a = (a - lo) / (hi - lo) * 255
+ return a.astype("uint8")
+
+ for fname, gray, mask in seg_store:
+ st.markdown(f"**{fname}**")
+ col_img, col_mask, col_overlay = st.columns(3)
+ gray_u8 = _to_u8(gray)
+ mask_u8 = (mask > 0).astype("uint8") * 255
+ rgb = _np.stack([gray_u8, gray_u8, gray_u8], axis=-1)
+ green_ch = rgb[:, :, 1].copy()
+ green_ch[mask_u8 > 0] = _np.clip(
+ green_ch[mask_u8 > 0].astype(int) + 80, 0, 255
+ ).astype("uint8")
+ rgb[:, :, 1] = green_ch
+ col_img.image(gray_u8, caption="Input", use_container_width=True, clamp=True)
+ col_mask.image(mask_u8, caption="Mask", use_container_width=True, clamp=True)
+ col_overlay.image(rgb, caption="Overlay", use_container_width=True, clamp=True)
+
+
+# ============================================================
+# TAB 2 — Curvature
+# ============================================================
+elif _view == "curvature":
+ st.markdown(styles.view_header(
+ "Curvature",
+ "Measure the shape of each fiber",
+ "Per-fragment length and curvature by the Taubin circle-fit, aggregated "
+ "per image.",
+ ), unsafe_allow_html=True)
+ st.info(
+ "Measure curvature images here. For images too large to upload, run this "
+ "app locally (see **Run Local**); for a whole study on a cluster, see "
+ "**Run Remote**."
)
+ st.caption(_FILENAME_NOTE)
+
+ curv_source = _render_input_picker("curvature", "curv")
+
+ with st.expander("Settings", expanded=False):
+ c1, c2, c3 = st.columns(3)
+ curv_res_val = c1.number_input(
+ "Resolution", value=132.0, step=1.0, min_value=0.0001, key="curv_res_val",
+ help="Enter your scale in whichever unit you have; pick the matching "
+ "unit on the right and it is converted for you.")
+ curv_res_unit = c2.selectbox("Unit", ["px/mm", "mm/px"], key="curv_res_unit")
+ curv_window = c3.number_input(
+ "Taubin window (px)", value=50, step=5, key="curv_window",
+ help="Length (in pixels) of the sliding window used to fit a circle "
+ "to the fiber (Taubin method) and measure local curvature at each "
+ "step along it. Larger = smoother, more global curvature; smaller "
+ "= more local detail. Keep it well below your fragment length.")
+ curv_res_mm = resolution_to_px_per_unit(curv_res_val, curv_res_unit)
+ st.caption(f"Working resolution: **{curv_res_mm:.4g} px/mm**")
+
+ if st.button("▶ Analyze curvature", type="primary", key="curv_run"):
+ if _source_is_empty(curv_source):
+ st.error("Provide at least one curvature image.")
+ else:
+ with tempfile.TemporaryDirectory() as tmpdir:
+ curv_inputs = _gather_inputs(curv_source, tmpdir)
+ _warn_duplicate_names([n for n, _ in curv_inputs])
+ frag_frames = []
+ summ_rows = []
+ failed = []
+ progress = st.progress(0, text="Processing…")
+ for idx, (name, path) in enumerate(curv_inputs):
+ progress.progress(idx / len(curv_inputs),
+ text=f"Processing {name}…")
+ try:
+ frags = _process_curvature_gui(
+ path,
+ resolution_mm=float(curv_res_mm),
+ window_size=int(curv_window),
+ )
+ except Exception as e:
+ st.warning(f"{name}: {inputs.restore_names(e, path, name)}")
+ frags = None
+
+ if frags is not None and not frags.empty:
+ f = frags.copy()
+ f.insert(0, "source_file", name)
+ frag_frames.append(f)
+ summ = {
+ "source_file": name,
+ "n_fragments": int(len(frags)),
+ "curv_mean": float(frags["curv_mean"].mean()),
+ "curv_median": float(frags["curv_mean"].median()),
+ "length_mean": float(frags["length"].mean()),
+ "length_median": float(frags["length"].median()),
+ "length_total": float(frags["length"].sum()),
+ }
+ summ_rows.append(summ)
+ else:
+ failed.append(name)
+ progress.progress(1.0, text="Done.")
+
+ if failed:
+ st.warning(
+ "No fiber fragments were measured in: " + ", ".join(failed)
+ + ". Check the **Resolution** (px/mm) and that the fibers are "
+ "visible against the background."
+ )
+ if not frag_frames:
+ st.session_state.pop("curvature_fragments", None)
+ st.session_state.pop("curvature_summary", None)
+ st.error("No curvature fragments were measured.")
+ else:
+ st.session_state["curvature_fragments"] = pd.concat(
+ frag_frames, ignore_index=True)
+ st.session_state["curvature_summary"] = pd.DataFrame(summ_rows)
+ n_frag = int(sum(len(f) for f in frag_frames))
+ st.success(f"Measured {n_frag} fragment(s) across "
+ f"{len(summ_rows)} / {len(curv_inputs)} image(s).")
- uploaded_files = None
- dataset_url = ""
- if input_mode == "Upload TIFF files":
- uploaded_files = st.file_uploader(
- "Upload grayscale TIFF images or ZIP archives",
- type=["tif", "tiff", "zip"],
- accept_multiple_files=True,
- help="You can upload individual TIFFs or a ZIP folder containing multiple images.",
+ frag_df = st.session_state.get("curvature_fragments")
+ summ_df = st.session_state.get("curvature_summary")
+ if frag_df is not None and not frag_df.empty:
+ import matplotlib.pyplot as plt
+
+ st.markdown(' ', unsafe_allow_html=True)
+ _cards = [
+ {"label": "Fragments", "value": len(frag_df), "hero": True},
+ {"label": "Images", "value": int(frag_df["source_file"].nunique())},
+ ]
+ if "length" in frag_df.columns:
+ _cards.append({"label": "Mean length",
+ "value": f"{frag_df['length'].mean():.1f}", "unit": "mm"})
+ if "curv_mean" in frag_df.columns:
+ _cards.append({"label": "Mean curvature",
+ "value": f"{frag_df['curv_mean'].mean():.3f}", "unit": "mm⁻¹"})
+ st.markdown(styles.metric_cards(_cards), unsafe_allow_html=True)
+
+ # --- Per-fragment table (primary output) ---
+ st.markdown("**Per-fragment measurements** — one row per fiber fragment")
+ frag_cols = {
+ "source_file": "File",
+ "fragment": "Fragment",
+ "length": "Length (mm)",
+ "curv_mean": "Mean Curvature (mm⁻¹)",
+ "curv_median": "Median Curvature (mm⁻¹)",
+ }
+ fpresent = {k: v for k, v in frag_cols.items() if k in frag_df.columns}
+ st.dataframe(
+ frag_df[list(fpresent.keys())].rename(columns=fpresent).style.format({
+ "Length (mm)": "{:.3f}",
+ "Mean Curvature (mm⁻¹)": "{:.4f}",
+ "Median Curvature (mm⁻¹)": "{:.4f}",
+ }),
+ use_container_width=True,
)
- else:
- dataset_url = st.text_input(
- "Dataset URL",
- placeholder="https://zenodo.org/record/.../fibermorph_dataset.zip",
- help="Provide a direct link to a .zip archive or a single .tif/.tiff file.",
- ).strip()
- st.caption(
- "Tip: host sample data on GitHub Releases or Zenodo. "
- "Archived downloads are extracted automatically."
+ st.download_button(
+ "📥 Download per-fragment CSV",
+ data=frag_df.to_csv(index=False),
+ file_name="curvature_per_fragment.csv",
+ mime="text/csv",
)
- if analysis_mode == "Curvature":
- options = CurvatureOptions()
- with st.expander("Curvature settings", expanded=False):
- options.resolution_mm = st.number_input(
- "Resolution (pixels per mm)", min_value=1.0, value=options.resolution_mm
+ # --- Per-image summary (aggregated across fragments) ---
+ if summ_df is not None and not summ_df.empty:
+ st.markdown("**Per-image summary** — aggregated across each image's fragments")
+ summ_labels = {
+ "source_file": "File",
+ "n_fragments": "Fragments",
+ "curv_mean": "Mean Curvature (mm⁻¹)",
+ "curv_median": "Median Curvature (mm⁻¹)",
+ "length_mean": "Mean Length (mm)",
+ "length_median": "Median Length (mm)",
+ "length_total": "Total Length (mm)",
+ }
+ spresent = {k: v for k, v in summ_labels.items() if k in summ_df.columns}
+ fmt = {v: "{:.4f}" for k, v in spresent.items()
+ if k not in ("source_file", "n_fragments")}
+ st.dataframe(
+ summ_df[list(spresent.keys())].rename(columns=spresent).style.format(fmt),
+ use_container_width=True,
)
- options.window_unit = st.selectbox("Window unit", options=["px", "mm"], index=0)
- window_size = st.number_input(
- f"Window size ({options.window_unit})",
- min_value=0.0,
- value=options.window_size if options.window_size else 0.0,
- help="Set to 0 to use the full length of each hair.",
+ st.download_button(
+ "📥 Download per-image summary CSV",
+ data=summ_df.to_csv(index=False),
+ file_name="curvature_per_image.csv",
+ mime="text/csv",
)
- options.window_size = None if window_size == 0 else window_size
- options.jobs = st.slider("Parallel jobs", min_value=1, max_value=8, value=options.jobs)
- options.save_images = st.checkbox("Save intermediate images", value=options.save_images)
- options.within_element = st.checkbox(
- "Save within-element curvature tables", value=options.within_element
- )
- else:
- options = SectionOptions()
- with st.expander("Section settings", expanded=False):
- options.resolution_mu = st.number_input(
- "Resolution (pixels per micron)", min_value=0.1, value=options.resolution_mu
- )
- options.minsize = st.number_input(
- "Minimum diameter (μm)", min_value=0.0, value=options.minsize
+
+ # --- Distributions of fragment length + mean curvature ---
+ hist_metrics = [("length", "Fragment Length (mm)"),
+ ("curv_mean", "Fragment Mean Curvature (mm⁻¹)")]
+
+ # Per-sample: one paneled row per uploaded image (only when >1 sample).
+ if frag_df["source_file"].nunique() >= 2:
+ fig_by = _faceted_histograms(
+ frag_df, "source_file", hist_metrics, "Per-sample distributions",
)
- options.maxsize = st.number_input(
- "Maximum diameter (μm)", min_value=0.0, value=options.maxsize
+ if fig_by is not None:
+ st.pyplot(fig_by)
+ plt.close(fig_by)
+
+ # Joint: all fragments from all samples pooled together.
+ if len(frag_df) >= 2:
+ fig = _metric_histograms(
+ frag_df, hist_metrics,
+ "Joint distribution — all samples pooled",
)
- options.jobs = st.slider("Parallel jobs", min_value=1, max_value=8, value=options.jobs)
- options.save_images = st.checkbox("Save intermediate images", value=options.save_images)
-
- if st.button("Run analysis", type="primary"):
- if input_mode == "Upload TIFF files" and not uploaded_files:
- st.error("Please upload at least one TIFF file.")
- return
-
- if input_mode == "Download from URL" and not dataset_url:
- st.error("Enter a dataset URL to download.")
- return
-
- with st.spinner("Running fibermorph analysis..."):
- with tempfile.TemporaryDirectory() as tmp_in, tempfile.TemporaryDirectory() as tmp_out:
- input_dir = Path(tmp_in)
- output_dir = Path(tmp_out)
- if input_mode == "Upload TIFF files":
- saved_files = _write_uploaded_files(uploaded_files, input_dir) # type: ignore[arg-type]
- else:
- try:
- saved_files = _download_dataset(dataset_url, input_dir)
- except Exception as exc:
- st.error(f"Failed to download dataset: {exc}")
- return
+ if fig is not None:
+ st.pyplot(fig)
+ plt.close(fig)
- if not saved_files:
- st.error("No TIFF files were available for analysis.")
- return
- if analysis_mode == "Curvature":
- results = _run_curvature(input_dir, output_dir, options)
- else:
- results = _run_section(input_dir, output_dir, options)
-
- result_dir = _latest_child_dir(output_dir)
- if not results or result_dir is None:
- st.warning("Analysis finished but no summary files were produced.")
- return
-
- combined = pd.concat(results, ignore_index=True)
- st.success("Analysis complete!")
- st.dataframe(combined)
-
- csv_bytes = combined.to_csv(index=False).encode("utf-8")
- st.download_button(
- label="Download table as CSV",
- data=csv_bytes,
- file_name="fibermorph_results.csv",
- mime="text/csv",
- )
+# ============================================================
+# TAB 3 — Run Local (same GUI, on your own machine)
+# ============================================================
+elif _view == "local":
+ st.markdown(styles.view_header(
+ "Run Local",
+ "The same app, on your own machine",
+ "Run this identical interface locally for images too large to upload — no "
+ "size cap, and you can read straight from a folder on disk.",
+ ), unsafe_allow_html=True)
+ st.markdown(
+ "**Why:** a hosted copy of this app runs on a shared server, so it can't reach "
+ "files on your computer and it caps uploads"
+ + ("" if _LOCAL else f" ({_UPLOAD_CAP} here)")
+ + ", so a larger scan — a multi-gigabyte curvature image, say — won't "
+ "upload.\n\n"
+ "**Fix:** fibermorph is an ordinary Python package, and this whole interface "
+ "ships with it. Install it once and launch the *same* app on your own "
+ "machine — Streamlit runs perfectly well locally — where you can point it "
+ "straight at a folder of images, with nothing to upload:\n\n"
+ "```bash\n"
+ "pip install 'fibermorph[gui]'\n"
+ "fibermorph-gui --local\n"
+ "```\n\n"
+ "That opens the identical interface in your browser at "
+ "`http://localhost:8501`, but running on your computer. On the "
+ "**Cross-Section** and **Curvature** tabs you then get an extra "
+ "**“Folder on disk”** option — choose it, paste the path to your images, and "
+ "they are read directly from disk (no upload, any size).\n\n"
+ "The `--local` flag matters: plain `fibermorph-gui` starts the app in hosted "
+ "mode (uploads only, no folder input). Local mode lets anyone who can open the "
+ "page read folders on the machine it runs on, so it listens on localhost "
+ "only — don't use it on a shared server."
+ )
+ if _LOCAL:
+ st.success(
+ "✅ You're running locally right now — the **Folder on disk** option is "
+ "available on the Cross-Section and Curvature tabs, and uploads are "
+ f"capped at {_UPLOAD_CAP}."
+ )
+ else:
+ st.info(
+ f"You're on the hosted app (upload-only, {_UPLOAD_CAP}). Follow the steps above "
+ "(including `fibermorph-gui --local`) to run locally for large images."
+ )
+ st.caption(
+ "No GPU or cluster needed — this runs on an ordinary laptop or desktop. For "
+ "a whole study on a shared HPC cluster, see the **Run Remote** tab."
+ )
+ st.markdown(
+ "**SAM2 segmentation** does not run on the hosted app. To use it, run "
+ "`fibermorph-gui --local` on a machine with an NVIDIA GPU after installing "
+ "SAM2 and a checkpoint:\n\n"
+ "```bash\n"
+ "pip install git+https://github.com/facebookresearch/segment-anything-2\n"
+ "wget https://dl.fbaipublicfiles.com/segment_anything_2/092824/sam2.1_hiera_tiny.pt\n"
+ "SAM2_CHECKPOINT=$PWD/sam2.1_hiera_tiny.pt fibermorph-gui --local\n"
+ "```\n\n"
+ "Then switch on **Use SAM2 segmentation** on the Cross-Section tab (the "
+ "checkpoint path box there can also be edited). No GPU? Use **Run Remote** "
+ "to run SAM2 on a cluster."
+ )
- zip_bytes = _zip_directory(result_dir)
- st.download_button(
- label="Download full output (ZIP)",
- data=zip_bytes,
- file_name="fibermorph_results.zip",
- mime="application/zip",
- )
+# ============================================================
+# TAB 4 — Run Remote (HPC)
+# ============================================================
+elif _view == "remote":
+ st.markdown(styles.view_header(
+ "Run Remote",
+ "Build an SBATCH script for your cluster",
+ "Generate a ready-to-edit SBATCH script that runs the fibermorph CLI on an "
+ "HPC cluster. It builds the script only — it does not submit anything.",
+ ), unsafe_allow_html=True)
+ st.markdown(
+ "The **Cross-Section** and **Curvature** views analyze a few uploaded images "
+ "right here in the browser. "
+ "For a whole study — or images too large to upload — run the **fibermorph "
+ "command-line tool** where your images already live (your workstation or an "
+ "HPC cluster):\n\n"
+ "```bash\n"
+ "pip install fibermorph\n"
+ "fibermorph --section -i /path/to/images -o /path/to/output --resolution_mu 5.556\n"
+ "```\n\n"
+ "This tab builds a matching **SBATCH script** you can download, edit, and "
+ "submit yourself with `sbatch`. It does **not** submit anything or connect to "
+ "any cluster — nothing here leaves your browser."
+ )
+
+ st.divider()
+ st.subheader("1 · Inputs")
+ col_sec, col_curv = st.columns(2)
+ with col_sec:
+ section_path = st.text_input(
+ "Cross-section image directory (on the machine you'll run on)",
+ placeholder="/path/to/section/input/",
+ key="section_path",
+ )
+ st.caption("Leave blank to skip cross-section analysis.")
+
+ with col_curv:
+ curv_path = st.text_input(
+ "Curvature image directory (on the machine you'll run on)",
+ placeholder="/path/to/curvature/input/",
+ key="curv_path",
+ )
+ st.caption("Leave blank to skip curvature analysis.")
+
+ output_dir_batch = st.text_input(
+ "Output directory",
+ value="fibermorph_output",
+ key="output_dir_input",
+ help="Where fibermorph writes its CSVs. Relative to where you submit the "
+ "job, or an absolute path on your cluster.",
+ )
+
+ st.divider()
+ st.subheader("2 · Settings")
+
+ with st.expander("Cross-section settings", expanded=False):
+ col1, col2, col3, col4 = st.columns(4)
+ sec_res_val_b = col1.number_input(
+ "Section resolution", value=4.25, step=0.01, min_value=0.0001,
+ key="batch_sec_res_val")
+ sec_res_unit_b = col2.selectbox("Unit", ["px/µm", "µm/px"], key="batch_sec_res_unit")
+ # Lower limits match the fibermorph CLI's checks (--minsize >= 0,
+ # --maxsize > 0), so the generated script never carries a value the CLI
+ # refuses.
+ min_diam = col3.number_input("Min diameter (µm)", value=30.0, step=1.0,
+ min_value=0.0)
+ max_diam = col4.number_input("Max diameter (µm)", value=150.0, step=1.0,
+ min_value=1.0)
+ resolution_mu = resolution_to_px_per_unit(sec_res_val_b, sec_res_unit_b)
+ st.caption(f"Script will pass **--resolution_mu {resolution_mu:.4g}** (px/µm).")
+ if int(min_diam) > int(max_diam):
+ st.warning("Min diameter is larger than Max diameter, so no cross-section "
+ "could match; fibermorph will refuse this script.")
+ use_sam2 = st.toggle("Enable SAM2 segmentation (requires GPU)", value=False)
+ st.caption(
+ "SAM2 runs on the cluster's GPU node, so this works even where the app "
+ "itself can't run SAM2. The cluster environment needs the sam2 package "
+ "and a checkpoint file (see the README)."
+ )
+ # This value only goes into the generated script text for another machine.
+ # Hosted: don't pre-fill the server's own checkpoint path (it is not
+ # useful on a cluster and visitors must not see it); leave it empty.
+ sam2_checkpoint = st.text_input(
+ "SAM2 checkpoint path",
+ value=_DEFAULT_CHECKPOINT if _LOCAL else "",
+ placeholder="/path/to/sam2.1_hiera_tiny.pt",
+ help="Path to the SAM2 checkpoint on the machine you'll run on. Left "
+ "blank, the script uses YOUR_SAM2_CHECKPOINT for you to fill in.",
+ )
+ ext_features = st.toggle(
+ "Extended features (EFD, Hu moments, radial profile, shape class)", value=True
+ )
+
+ with st.expander("Curvature settings", expanded=False):
+ col5, col6, col7 = st.columns(3)
+ curv_res_val_b = col5.number_input(
+ "Curvature resolution", value=132.0, step=1.0, min_value=0.0001,
+ key="batch_curv_res_val")
+ curv_res_unit_b = col6.selectbox("Unit", ["px/mm", "mm/px"], key="batch_curv_res_unit")
+ window_size = col7.number_input(
+ "Taubin window (px)", value=50, step=5, min_value=1,
+ help="Length (in pixels) of the sliding window used to fit a circle "
+ "to the fiber (Taubin method) and measure local curvature. Larger "
+ "= smoother/more global; smaller = more local detail. Keep it well "
+ "below your fragment length.")
+ resolution_mm = resolution_to_px_per_unit(curv_res_val_b, curv_res_unit_b)
+ st.caption(f"Script will pass **--resolution_mm {resolution_mm:.4g}** (px/mm).")
+
+ with st.expander("SLURM settings", expanded=False):
+ col_a, col_b, col_c = st.columns(3)
+ slurm_account = col_a.text_input("Account", value="", placeholder="YOUR_ACCOUNT")
+ slurm_cpus = col_b.number_input("CPUs", value=4, step=1, min_value=1)
+ slurm_time = col_c.text_input("Walltime", value="04:00:00")
+ col_d, col_e = st.columns(2)
+ slurm_partition = col_d.text_input("Partition (CPU)", value="", placeholder="e.g. standard")
+ slurm_gpu_partition = col_e.text_input("Partition (GPU, for SAM2)", value="", placeholder="e.g. gpu")
+ st.caption(
+ "Account and partition names are specific to your cluster — check its "
+ "docs. Left blank, the script uses YOUR_ACCOUNT / YOUR_PARTITION "
+ "placeholders for you to fill in."
+ )
+
+ st.divider()
+ st.subheader("3 · Generate script")
+ if st.button("Generate SBATCH script", type="primary", key="gen_sbatch"):
+ if not section_path and not curv_path:
+ st.error("Provide at least one image directory.")
+ elif not output_dir_batch:
+ st.error("Provide an output directory.")
+ else:
+ account = slurm_account.strip() or "YOUR_ACCOUNT"
+ if use_sam2:
+ partition = slurm_gpu_partition.strip() or "YOUR_GPU_PARTITION"
+ else:
+ partition = slurm_partition.strip() or "YOUR_PARTITION"
+ mem_gb = int(slurm_cpus) * 8
+
+ # Build fibermorph CLI commands
+ commands = []
+ if section_path:
+ sec_flags = [
+ "fibermorph --section",
+ f" -i '{section_path}'",
+ f" -o '{output_dir_batch}'",
+ f" --resolution_mu {resolution_mu:g}",
+ f" --minsize {int(min_diam)}",
+ f" --maxsize {int(max_diam)}",
+ f" --jobs {int(slurm_cpus)}",
+ ]
+ if use_sam2:
+ ckpt_arg = sam2_checkpoint.strip() or "YOUR_SAM2_CHECKPOINT"
+ sec_flags += [
+ " --use-sam2",
+ f" --sam2-checkpoint '{ckpt_arg}'",
+ ]
+ if ext_features:
+ sec_flags.append(" --extended-features")
+ commands.append(" \\\n".join(sec_flags))
+
+ if curv_path:
+ curv_flags = [
+ "fibermorph --curvature",
+ f" -i '{curv_path}'",
+ f" -o '{output_dir_batch}'",
+ f" --resolution_mm {resolution_mm:g}",
+ f" --window_size {int(window_size)}",
+ f" --jobs {int(slurm_cpus)}",
+ ]
+ commands.append(" \\\n".join(curv_flags))
+
+ directives = [
+ "#!/bin/bash",
+ "#SBATCH --job-name=fibermorph",
+ f"#SBATCH --account={account}",
+ f"#SBATCH --partition={partition}",
+ ]
+ if use_sam2:
+ directives.append("#SBATCH --gpus=1")
+ directives += [
+ "#SBATCH --nodes=1",
+ "#SBATCH --ntasks=1",
+ f"#SBATCH --cpus-per-task={int(slurm_cpus)}",
+ f"#SBATCH --mem={mem_gb}G",
+ f"#SBATCH --time={slurm_time}",
+ f"#SBATCH --output={output_dir_batch}/slurm_%j.out",
+ f"#SBATCH --error={output_dir_batch}/slurm_%j.err",
+ ]
+
+ env_lines = [
+ "",
+ "set -euo pipefail",
+ f'mkdir -p "{output_dir_batch}"',
+ "",
+ "# Activate the environment where you installed fibermorph, e.g.:",
+ "# module load python # adjust for your cluster",
+ "# source ~/fibermorph-venv/bin/activate",
+ "",
+ ]
+ if use_sam2:
+ env_lines += [
+ "# Load CUDA for SAM2 GPU segmentation (adjust module name for your cluster)",
+ "module load cuda",
+ "",
+ 'echo "GPU(s): $(nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null || echo none)"',
+ "",
+ ]
+ env_lines += [
+ 'echo "Host: $(hostname)"',
+ 'echo "Start: $(date)"',
+ "",
+ ]
+ for cmd in commands:
+ env_lines.append(cmd)
+ env_lines.append("")
+ env_lines += [
+ 'echo "Done: $(date)"',
+ "",
+ ]
+
+ script = "\n".join(directives) + "\n" + "\n".join(env_lines)
+ st.session_state["sbatch_script"] = script
+
+ if st.session_state["sbatch_script"]:
+ st.download_button(
+ "⬇ Download run_fibermorph.sbatch",
+ data=st.session_state["sbatch_script"],
+ file_name="run_fibermorph.sbatch",
+ mime="text/plain",
+ )
+ st.code(st.session_state["sbatch_script"], language="bash")
+ st.caption(
+ "Edit the placeholders, copy this to your cluster, and run "
+ "`sbatch run_fibermorph.sbatch`. fibermorph writes a timestamped "
+ "subfolder per module under your output directory, each with a summary "
+ "CSV (`summary_section_data.csv` / `curvature_summary_data*.csv`) — one "
+ "row per image, carrying its source filename for downstream grouping."
+ )
-if __name__ == "__main__": # pragma: no cover
- main()
diff --git a/fibermorph/gui/assets/logo-horizontal.png b/fibermorph/gui/assets/logo-horizontal.png
new file mode 100644
index 0000000..8d5c900
Binary files /dev/null and b/fibermorph/gui/assets/logo-horizontal.png differ
diff --git a/fibermorph/gui/assets/logo-icon.png b/fibermorph/gui/assets/logo-icon.png
new file mode 100644
index 0000000..1076ba7
Binary files /dev/null and b/fibermorph/gui/assets/logo-icon.png differ
diff --git a/fibermorph/gui/assets/logo-white.png b/fibermorph/gui/assets/logo-white.png
new file mode 100644
index 0000000..0a2c36e
Binary files /dev/null and b/fibermorph/gui/assets/logo-white.png differ
diff --git a/fibermorph/gui/assets/pattern-branches-mask.png b/fibermorph/gui/assets/pattern-branches-mask.png
new file mode 100644
index 0000000..a480892
Binary files /dev/null and b/fibermorph/gui/assets/pattern-branches-mask.png differ
diff --git a/fibermorph/gui/inputs.py b/fibermorph/gui/inputs.py
new file mode 100644
index 0000000..7ea143e
--- /dev/null
+++ b/fibermorph/gui/inputs.py
@@ -0,0 +1,100 @@
+"""Safe handling of files uploaded through the Streamlit app.
+
+The filename an uploader sends is chosen by the client and must never be used
+to build a path on the server: it can be absolute, contain ``..`` or Windows
+separators, or collide with another upload's name. Uploads are therefore
+written under names generated here (``upload_0001.tif``, ``upload_0002.png``,
+...), and the client's name is kept only as a display label, reduced to its
+last path component.
+
+This module has no Streamlit dependency so it can be unit-tested directly.
+"""
+
+from __future__ import annotations
+
+import os
+import re
+
+# File types the upload widgets accept (lower case, no dot).
+UPLOAD_TYPES = ["tif", "tiff", "png", "jpg", "jpeg"]
+
+_CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f]")
+
+
+def format_upload_cap(megabytes) -> str:
+ """Format an upload cap given in MB for display to people using the app.
+
+ Whole thousands of MB are shown in GB (``5000`` -> ``"5 GB"``); anything
+ else stays in MB (``500`` -> ``"500 MB"``, ``2500`` -> ``"2500 MB"``).
+ """
+ mb = int(megabytes)
+ if mb >= 1000 and mb % 1000 == 0:
+ return f"{mb // 1000} GB"
+ return f"{mb} MB"
+
+
+def display_name(client_name, fallback: str = "upload") -> str:
+ """Reduce a client-supplied filename to a safe label for display.
+
+ Keeps only the last path component (splitting on both ``/`` and ``\\``, so
+ Windows-style and POSIX-style paths are handled the same way on any
+ platform) and drops control characters. Returns ``fallback`` when nothing
+ usable is left (empty name, ``.``, ``..``, a bare separator).
+ """
+ name = _CONTROL_CHARS.sub("", str(client_name or ""))
+ name = re.split(r"[\\/]", name)[-1].strip()
+ if name in ("", ".", ".."):
+ return fallback
+ return name
+
+
+def safe_extension(client_name) -> str:
+ """Return the lower-case extension (with dot) of ``client_name`` if it is
+ one of ``UPLOAD_TYPES``, else an empty string."""
+ ext = os.path.splitext(display_name(client_name, fallback=""))[1].lower()
+ return ext if ext.lstrip(".") in UPLOAD_TYPES else ""
+
+
+def save_uploads(uploads, tmpdir):
+ """Write uploaded files into ``tmpdir`` and return ``(display_name, path)``.
+
+ Each upload is saved as ``upload_NNNN`` (1-based, in upload order),
+ where ```` is the original extension only if it is an accepted type.
+ The client-supplied name is used for nothing but the returned display
+ label, so every file lands directly inside ``tmpdir`` and two uploads with
+ the same name are stored (and later measured) as two separate files.
+
+ ``uploads`` is any iterable of objects with a ``name`` attribute and a
+ ``read()`` method returning bytes (e.g. Streamlit ``UploadedFile``), or
+ ``None``.
+ """
+ out = []
+ for i, up in enumerate(uploads or [], start=1):
+ saved_name = f"upload_{i:04d}{safe_extension(up.name)}"
+ path = os.path.join(tmpdir, saved_name)
+ with open(path, "wb") as fh:
+ fh.write(up.read())
+ out.append((display_name(up.name, fallback=saved_name), path))
+ return out
+
+
+def restore_names(text, path, name) -> str:
+ """Replace the saved file's path and generated name in ``text`` (typically
+ an error message from the image reader) with the name the user knows it by.
+
+ Only whole names are replaced: ``upload_0001`` (a file saved without an
+ extension) does not match inside ``upload_00010.tif``.
+
+ Leaves ``text`` unchanged when ``path`` already ends in ``name`` (e.g. a
+ file read straight from a folder on disk).
+ """
+ text = str(text)
+ saved_name = os.path.basename(path)
+ if saved_name == name:
+ return text
+ return _replace_whole(_replace_whole(text, path, name), saved_name, name)
+
+
+def _replace_whole(text, old, new) -> str:
+ """Replace ``old`` with ``new`` where ``old`` is not part of a longer word."""
+ return re.sub(rf"(? None:
+ """Write an empty Streamlit credentials file if the user has none, so the
+ one-time first-run email prompt doesn't block a fresh `fibermorph-gui`.
-def main() -> None:
- """Launch the Streamlit GUI by running streamlit with the app module."""
+ Non-destructive: never overwrites an existing credentials file.
+ """
+ cred = Path.home() / ".streamlit" / "credentials.toml"
+ try:
+ if not cred.exists():
+ cred.parent.mkdir(parents=True, exist_ok=True)
+ cred.write_text('[general]\nemail = ""\n')
+ except OSError:
+ pass # non-fatal: worst case, Streamlit shows its usual prompt
+
+
+def _build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="fibermorph-gui",
+ description=(
+ "Launch the fibermorph GUI. With no flags it starts in hosted mode: "
+ "uploads only, with a 500 MB upload cap. Use --local to run on your "
+ "own machine."
+ ),
+ epilog=(
+ "Any other arguments are passed through to `streamlit run` after "
+ "fibermorph's defaults, so they can override them "
+ "(for example: fibermorph-gui --local --server.port 8600). "
+ "fibermorph passes --server.maxUploadSize (500 MB hosted, 5000 MB "
+ "local) unless the STREAMLIT_SERVER_MAX_UPLOAD_SIZE environment "
+ "variable is set, and in local mode always passes "
+ "--server.address localhost. Command-line options take precedence "
+ "over environment variables and .streamlit/config.toml, so to "
+ "change the bind address use --server.address on this command "
+ "line (the STREAMLIT_SERVER_ADDRESS variable has no effect in "
+ "local mode)."
+ ),
+ allow_abbrev=False,
+ )
+ parser.add_argument(
+ "--local",
+ action="store_true",
+ help=(
+ "Run in local mode, for use on your own computer: adds a 'Folder on "
+ "disk' input that reads images from this machine, raises the upload "
+ "cap to 5 GB, and listens on localhost only. Do not use on a shared "
+ "server: every visitor could read folders on it. Setting the "
+ "environment variable FIBERMORPH_LOCAL=1 does the same."
+ ),
+ )
+ return parser
+
+
+def _option_value(args, name):
+ """Last value given for ``name`` in ``args`` (as ``--name value`` or
+ ``--name=value``), or None if it was not given."""
+ value = None
+ for i, arg in enumerate(args):
+ if arg == name and i + 1 < len(args):
+ value = args[i + 1]
+ elif arg.startswith(name + "="):
+ value = arg[len(name) + 1:]
+ return value
+
+
+def _upload_cap_mb(local: bool, extra_args) -> str:
+ """The upload cap (MB) the app will run with, for the startup notice."""
+ given = _option_value(extra_args, "--server.maxUploadSize")
+ if given is not None:
+ return given
+ if os.environ.get(UPLOAD_CAP_ENV):
+ return os.environ[UPLOAD_CAP_ENV]
+ return str(LOCAL_MAX_UPLOAD_MB if local else HOSTED_MAX_UPLOAD_MB)
+
+
+def _build_streamlit_argv(local: bool, app_path: Path, extra_args) -> list:
+ """Return the ``sys.argv`` for ``streamlit run``.
+
+ Defaults come first and user-supplied ``extra_args`` last, so a user can
+ override any default (Streamlit uses the last value given for an option).
+ """
+ defaults = []
+ if not os.environ.get(UPLOAD_CAP_ENV):
+ # A command-line option would outrank the host's environment variable,
+ # so only pass the cap when the host has not chosen one.
+ cap = LOCAL_MAX_UPLOAD_MB if local else HOSTED_MAX_UPLOAD_MB
+ defaults += ["--server.maxUploadSize", str(cap)]
+ if local:
+ # Always passed (even if STREAMLIT_SERVER_ADDRESS is set): local mode
+ # reads folders on this machine, so only an explicit command-line
+ # --server.address may open it to other machines.
+ defaults += ["--server.address", "localhost"]
+ return ["streamlit", "run", str(app_path), *defaults, *extra_args]
+
+
+def _startup_notice(local: bool, extra_args) -> str:
+ cap = _upload_cap_mb(local, extra_args)
+ if not local:
+ return (
+ f"fibermorph-gui: starting in hosted mode (uploads only, {cap} MB "
+ "upload cap). To run on your own machine with folder input and "
+ "5 GB uploads, use: fibermorph-gui --local"
+ )
+ address = _option_value(extra_args, "--server.address")
+ if address is None:
+ address = "localhost" # the launcher's own default (see above)
+ notice = f"fibermorph-gui: starting in local mode (folder input, {cap} MB uploads"
+ if address in _LOOPBACK_ADDRESSES:
+ return notice + ", localhost only)."
+ # An empty --server.address is not "unset" to Streamlit: it listens on
+ # every interface (0.0.0.0), so it is reported as wider than localhost.
+ listening = address or "all network interfaces"
+ return (
+ notice + f", listening on {listening}). WARNING: anyone who can reach this "
+ "machine over the network can read folders on it."
+ )
+
+
+def main(argv=None) -> None:
+ """Launch the Streamlit GUI by running streamlit with the app module.
+
+ ``argv`` defaults to ``sys.argv[1:]``.
+ """
try:
from streamlit.web import cli as stcli
except ImportError:
@@ -18,11 +176,31 @@ def main() -> None:
)
sys.exit(1)
+ args, extra_args = _build_parser().parse_known_args(
+ sys.argv[1:] if argv is None else argv
+ )
+
+ # Local mode is opt-in: the --local flag, or FIBERMORPH_LOCAL=1 already set
+ # in the environment. It unlocks features that only make sense on the
+ # user's own machine (reading images straight from a folder on disk), and
+ # lets anyone who can reach the app read folders on this machine. Hosted
+ # mode never sets the variable.
+ local = args.local or os.environ.get("FIBERMORPH_LOCAL") == "1"
+ if local:
+ os.environ["FIBERMORPH_LOCAL"] = "1"
+
+ # On a machine's first-ever Streamlit run, Streamlit interactively prompts
+ # for an email and blocks until the user answers — confusing for a launcher.
+ # Pre-seed an empty credentials file (only if the user has none) so
+ # `fibermorph-gui` starts straight into the app.
+ _skip_streamlit_email_prompt()
+
# Get the path to the app.py file
app_path = Path(__file__).parent / "app.py"
- # Use streamlit's CLI to run the app
- sys.argv = ["streamlit", "run", str(app_path)]
+ print(_startup_notice(local, extra_args), file=sys.stderr)
+
+ sys.argv = _build_streamlit_argv(local, app_path, extra_args)
sys.exit(stcli.main())
diff --git a/fibermorph/gui/styles.py b/fibermorph/gui/styles.py
new file mode 100644
index 0000000..46ad3a8
--- /dev/null
+++ b/fibermorph/gui/styles.py
@@ -0,0 +1,315 @@
+"""Lasisi Lab design system for the fibermorph Streamlit GUI.
+
+One place for the visual identity: the injected CSS (fonts, tokens, sidebar,
+buttons, cards, table, inputs) plus small HTML-builder helpers for the sidebar
+brand lockup, per-view headers, and at-a-glance metric cards.
+
+Colors, type and spacing follow the design handoff tokens exactly. Assets
+(logo + dendritic "branch" mask) live in gui/assets/ and are embedded as base64
+data URIs so the app stays self-contained (no static-file server needed).
+"""
+
+from __future__ import annotations
+
+import base64
+import html as _html
+from functools import lru_cache
+from pathlib import Path
+
+_ASSETS = Path(__file__).resolve().parent / "assets"
+
+# --- Palette (design tokens) -------------------------------------------------
+TEAL_900 = "#023943" # identity / headlines / hero
+TEAL_800 = "#034D54"
+TEAL_700 = "#05686D" # links / functional / click
+TEAL_500 = "#2D8489"
+TEAL_400 = "#4FB5B5"
+TEAL_200 = "#A4D2CD"
+TEAL_50 = "#DCEDEB"
+PINK_600 = "#A82F88" # accent CTA
+PINK_800 = "#6E1A56" # accent hover
+PINK_100 = "#F7E1ED"
+INDIGO_700 = "#3A2E78"
+INDIGO_50 = "#F1EDF8"
+INK_700 = "#3F4251"
+INK_500 = "#6B6F7E"
+
+# Brand teals for the matplotlib histograms (replaces the default #4C72B0).
+CHART_COLORS = [TEAL_500, TEAL_400, TEAL_700, INDIGO_700]
+
+
+@lru_cache(maxsize=8)
+def _data_uri(name: str) -> str:
+ """Return a base64 data URI for an asset PNG (cached)."""
+ data = (_ASSETS / name).read_bytes()
+ return "data:image/png;base64," + base64.b64encode(data).decode("ascii")
+
+
+# Simple stroke-outline nav glyphs (from the design): an ellipse for the
+# cross-section, an arc for curvature, a laptop for local, a server for remote.
+# Used as CSS masks so the icon colour follows the nav item's text colour.
+_NAV_ICONS = {
+ "section": (''
+ ' '),
+ "curvature": (''
+ ' '),
+ "local": (' '
+ ' '),
+ "remote": (' '
+ ' '
+ ' '),
+}
+
+# Nav sub-labels (lighter second line under each item).
+_NAV_SUBS = {
+ "section": "Segment & measure",
+ "curvature": "Count & measure",
+ "local": "On your machine",
+ "remote": "On a cluster",
+}
+
+# Magic-wand-with-sparkles glyph for the Generate SBATCH button.
+_WAND_SVG = (''
+ ' '
+ ' ')
+
+
+def _svg_uri(svg: str) -> str:
+ """Base64 data URI for an inline SVG (avoids URL-escaping issues in CSS)."""
+ return "data:image/svg+xml;base64," + base64.b64encode(svg.encode("utf-8")).decode("ascii")
+
+
+@lru_cache(maxsize=1)
+def css() -> str:
+ """The full
+"""
+
+
+def brand_html() -> str:
+ """Sidebar brand lockup: logo badge + fibermorph / LASISI LAB."""
+ logo = _data_uri("logo-icon.png")
+ return (
+ f''
+ f'
'
+ f'
fibermorph
'
+ f'
Lasisi Lab
'
+ )
+
+
+def footer_html(status: str, version: str) -> str:
+ """Sidebar footer: status dot + line, and a mono version line."""
+ return (
+ f''
+ )
+
+
+def view_header(eyebrow: str, title: str, subtitle: str) -> str:
+ """Per-view header: pink eyebrow, Mulish h2, sub-copy, hairline rule."""
+ return (
+ f'{_html.escape(eyebrow)}
'
+ f'{_html.escape(title)}
'
+ f'{_html.escape(subtitle)}
'
+ )
+
+
+def metric_cards(cards) -> str:
+ """Render a 4-up grid of metric cards.
+
+ Each card is a dict: {"label": str, "value": str, "unit": str|None,
+ "hero": bool, "pills": [(text, kind), ...]}. `value`/`unit` are shown as a
+ big number; if `pills` is given it replaces the number with pills.
+ """
+ cells = []
+ for c in cards:
+ cls = "fm-card fm-card--hero" if c.get("hero") else "fm-card"
+ label = f'{_html.escape(c["label"])}
'
+ if c.get("pills"):
+ pills = "".join(
+ f'{_html.escape(t)} '
+ for t, k in c["pills"]
+ )
+ body = f'{pills}
'
+ else:
+ unit = (f' {_html.escape(c["unit"])} '
+ if c.get("unit") else "")
+ body = f'{_html.escape(str(c["value"]))}{unit}
'
+ cells.append(f'{label}{body}
')
+ return f'{"".join(cells)}
'
diff --git a/fibermorph/gui/visualizations.py b/fibermorph/gui/visualizations.py
new file mode 100644
index 0000000..026e420
--- /dev/null
+++ b/fibermorph/gui/visualizations.py
@@ -0,0 +1,652 @@
+"""Figure functions for hair cross-section and curvature analysis results.
+
+Each function accepts a pandas DataFrame and returns a matplotlib Figure.
+No side effects — no plt.show(), no file I/O.
+
+Usage in Streamlit:
+ from fibermorph.gui.visualizations import section_figures, curvature_figures
+ for group, title, fig in section_figures(per_image_df):
+ st.pyplot(fig)
+ plt.close(fig)
+"""
+
+import warnings
+from typing import Generator
+
+import matplotlib
+matplotlib.use("Agg")
+import matplotlib.patches as mpatches
+import matplotlib.pyplot as plt
+import numpy as np
+import pandas as pd
+import seaborn as sns
+
+warnings.filterwarnings("ignore")
+
+REGION_PALETTE = {"A": "#4C72B0", "B": "#C44E52"}
+
+sns.set_theme(style="whitegrid", font_scale=1.05)
+plt.rcParams.update({
+ "figure.dpi": 130,
+ "savefig.bbox": "tight",
+ "font.family": "DejaVu Sans",
+ "axes.spines.top": False,
+ "axes.spines.right": False,
+})
+
+_FLIER = {"marker": ".", "markersize": 3, "alpha": 0.4}
+
+
+def _has(df: pd.DataFrame, *cols: str) -> bool:
+ return all(c in df.columns for c in cols)
+
+
+def _empty(msg: str) -> plt.Figure:
+ fig, ax = plt.subplots(figsize=(6, 2))
+ ax.text(0.5, 0.5, f"[!] {msg}", ha="center", va="center",
+ fontsize=11, color="gray", transform=ax.transAxes)
+ ax.axis("off")
+ fig.tight_layout()
+ return fig
+
+
+# ===========================================================================
+# SECTION FIGURES
+# ===========================================================================
+
+def fig_geometric_boxplots(df: pd.DataFrame) -> plt.Figure:
+ metrics = [
+ ("circularity", "Circularity (4πA/P²)"),
+ ("aspect_ratio", "Aspect Ratio"),
+ ("eccentricity", "Eccentricity"),
+ ("solidity", "Solidity"),
+ ("area_mu2", "Area (µm²)"),
+ ("convexity", "Convexity"),
+ ("radial_cv", "Radial Profile CV"),
+ ("asymmetry_index","Asymmetry Index"),
+ ]
+ metrics = [(c, l) for c, l in metrics if c in df.columns]
+ if not metrics:
+ return _empty("No geometric feature columns found")
+
+ n = len(metrics)
+ ncols = 4
+ nrows = (n + ncols - 1) // ncols
+ fig, axes = plt.subplots(nrows, ncols, figsize=(20, 4.5 * nrows))
+ fig.suptitle("Geometric Features by Region", fontsize=13, fontweight="bold")
+
+ if "region" in df.columns:
+ regions = sorted(df["region"].dropna().unique())
+ palette = {r: REGION_PALETTE.get(r, "#888888") for r in regions}
+ for ax, (col, label) in zip(axes.flat, metrics):
+ sns.boxplot(data=df, x="region", y=col, order=regions,
+ palette=palette, ax=ax, linewidth=0.9, flierprops=_FLIER)
+ ax.set_title(label, fontsize=10)
+ ax.set_xlabel("")
+ ax.set_ylabel("")
+ else:
+ for ax, (col, label) in zip(axes.flat, metrics):
+ ax.boxplot(df[col].dropna(), flierprops=_FLIER)
+ ax.set_title(label, fontsize=10)
+ ax.set_xlabel("")
+ ax.set_ylabel("")
+
+ for ax in axes.flat[len(metrics):]:
+ ax.set_visible(False)
+
+ fig.tight_layout()
+ return fig
+
+
+def fig_size_distributions(df: pd.DataFrame) -> plt.Figure:
+ pairs = [
+ ("area_mu2", "Cross-Sectional Area (µm²)"),
+ ("radial_mean_mu","Mean Radius (µm)"),
+ ("radial_min_mu", "Min Radius (µm)"),
+ ("radial_max_mu", "Max Radius (µm)"),
+ ]
+ pairs = [(c, l) for c, l in pairs if c in df.columns]
+ if not pairs:
+ return _empty("No size-related columns found")
+
+ fig, axes = plt.subplots(1, len(pairs), figsize=(5 * len(pairs), 4.5))
+ if len(pairs) == 1:
+ axes = [axes]
+ fig.suptitle("Size Distributions by Region", fontsize=13, fontweight="bold")
+
+ if "region" in df.columns:
+ regions = sorted(df["region"].dropna().unique())
+ palette = {r: REGION_PALETTE.get(r, "#888888") for r in regions}
+ for ax, (col, label) in zip(axes, pairs):
+ for reg in regions:
+ vals = df[df["region"] == reg][col].dropna()
+ if len(vals) < 5:
+ continue
+ sns.kdeplot(vals, ax=ax, color=palette[reg],
+ label=f"Region {reg}", linewidth=1.8,
+ fill=True, alpha=0.12)
+ ax.set_xlabel(label, fontsize=10)
+ ax.set_ylabel("Density")
+ handles = [mpatches.Patch(color=REGION_PALETTE.get(r, "#888888"),
+ label=f"Region {r}") for r in regions]
+ axes[-1].legend(handles=handles, title="Region", fontsize=9)
+ else:
+ for ax, (col, label) in zip(axes, pairs):
+ vals = df[col].dropna()
+ if len(vals) >= 5:
+ sns.kdeplot(vals, ax=ax, color="#4C72B0",
+ linewidth=1.8, fill=True, alpha=0.2)
+ ax.set_xlabel(label, fontsize=10)
+ ax.set_ylabel("Density")
+
+ fig.tight_layout()
+ return fig
+
+
+def fig_efd_power_spectrum(df: pd.DataFrame) -> plt.Figure:
+ efd_cols = [f"efd_power_h{i}" for i in range(1, 11)]
+ efd_cols = [c for c in efd_cols if c in df.columns]
+ if not efd_cols:
+ return _empty("No EFD power columns found")
+
+ harmonics = [int(c.split("h")[1]) for c in efd_cols]
+ fig, axes = plt.subplots(1, 2, figsize=(14, 5))
+ fig.suptitle("Elliptic Fourier Descriptor Harmonic Power Spectrum",
+ fontsize=13, fontweight="bold")
+
+ if "region" in df.columns:
+ regions = sorted(df["region"].dropna().unique())
+ palette = {r: REGION_PALETTE.get(r, "#888888") for r in regions}
+ for ax, start, title in [
+ (axes[0], 0, "All Harmonics (h1 = ellipse fit)"),
+ (axes[1], 1, "Shape Complexity — h2–h10"),
+ ]:
+ hs = harmonics[start:]
+ for reg in regions:
+ vals = df[df["region"] == reg][efd_cols[start:]].mean().values
+ ax.plot(hs, vals, marker="o", markersize=5,
+ color=palette[reg], label=f"Region {reg}", linewidth=1.8)
+ ax.set_yscale("log")
+ ax.set_xlabel("Harmonic Number")
+ ax.set_ylabel("Mean Power (log scale)")
+ ax.set_title(title)
+ ax.set_xticks(hs)
+ ax.legend(title="Region", fontsize=9)
+ else:
+ for ax, start, title in [
+ (axes[0], 0, "All Harmonics (h1 = ellipse fit)"),
+ (axes[1], 1, "Shape Complexity — h2–h10"),
+ ]:
+ hs = harmonics[start:]
+ vals = df[efd_cols[start:]].mean().values
+ ax.plot(hs, vals, marker="o", markersize=5,
+ color="#4C72B0", linewidth=1.8)
+ ax.set_yscale("log")
+ ax.set_xlabel("Harmonic Number")
+ ax.set_ylabel("Mean Power (log scale)")
+ ax.set_title(title)
+ ax.set_xticks(hs)
+
+ fig.tight_layout()
+ return fig
+
+
+def fig_radial_features(df: pd.DataFrame) -> plt.Figure:
+ metrics = [
+ ("efd_deviation", "EFD Deviation\n(shape complexity)"),
+ ("radial_cv", "Radial Profile CV\n(boundary irregularity)"),
+ ("n_radial_peaks", "Radial Peak Count\n(number of lobes)"),
+ ("asymmetry_index","Asymmetry Index\n(tear-drop detector)"),
+ ]
+ metrics = [(c, l) for c, l in metrics if c in df.columns]
+ if not metrics:
+ return _empty("No radial feature columns found")
+
+ fig, axes = plt.subplots(1, len(metrics), figsize=(5 * len(metrics), 5))
+ if len(metrics) == 1:
+ axes = [axes]
+ fig.suptitle("Shape Complexity & Boundary Features", fontsize=13, fontweight="bold")
+
+ if "region" in df.columns:
+ regions = sorted(df["region"].dropna().unique())
+ palette = {r: REGION_PALETTE.get(r, "#888888") for r in regions}
+ for ax, (col, label) in zip(axes, metrics):
+ sns.violinplot(data=df, x="region", y=col, order=regions,
+ palette=palette, ax=ax, inner="box",
+ linewidth=0.9, cut=0)
+ ax.set_xlabel("")
+ ax.set_ylabel("")
+ ax.set_title(label, fontsize=9)
+ else:
+ for ax, (col, label) in zip(axes, metrics):
+ sns.boxplot(data=df, y=col, ax=ax, linewidth=0.9,
+ flierprops=_FLIER, color="#4C72B0")
+ ax.set_title(label, fontsize=9)
+
+ fig.tight_layout()
+ return fig
+
+
+def fig_correlation_heatmap(df: pd.DataFrame) -> plt.Figure:
+ key_cols = [
+ "area_mu2", "circularity", "aspect_ratio", "eccentricity",
+ "solidity", "convexity", "radial_cv", "radial_mean_mu",
+ "asymmetry_index", "efd_deviation",
+ "efd_power_h2", "efd_power_h3", "n_radial_peaks",
+ ]
+ available = [c for c in key_cols if c in df.columns]
+ if len(available) < 3:
+ return _empty("Not enough feature columns for correlation")
+
+ labels = {
+ "area_mu2": "Area", "circularity": "Circularity",
+ "aspect_ratio": "Aspect Ratio", "eccentricity": "Eccentricity",
+ "solidity": "Solidity", "convexity": "Convexity",
+ "radial_cv": "Radial CV", "radial_mean_mu": "Radial Mean",
+ "asymmetry_index": "Asymmetry", "efd_deviation": "EFD Deviation",
+ "efd_power_h2": "EFD h2", "efd_power_h3": "EFD h3",
+ "n_radial_peaks": "Radial Peaks",
+ }
+ corr = df[available].corr()
+ corr.index = [labels.get(c, c) for c in corr.index]
+ corr.columns = [labels.get(c, c) for c in corr.columns]
+
+ fig, ax = plt.subplots(figsize=(11, 9))
+ mask = np.triu(np.ones_like(corr, dtype=bool))
+ sns.heatmap(corr, mask=mask, annot=True, fmt=".2f", cmap="RdBu_r",
+ center=0, vmin=-1, vmax=1, linewidths=0.4,
+ annot_kws={"size": 7.5}, ax=ax,
+ cbar_kws={"shrink": 0.7, "label": "Pearson r"})
+ ax.set_title("Feature Correlation Matrix", fontsize=12, fontweight="bold", pad=10)
+ fig.tight_layout()
+ return fig
+
+
+def fig_region_comparison(df: pd.DataFrame) -> plt.Figure:
+ if "region" not in df.columns:
+ return _empty("No 'region' column found")
+
+ metrics = [
+ ("circularity", "Circularity"),
+ ("aspect_ratio", "Aspect Ratio"),
+ ("eccentricity", "Eccentricity"),
+ ("area_mu2", "Area (µm²)"),
+ ("efd_deviation","EFD Deviation"),
+ ("radial_cv", "Radial CV"),
+ ]
+ metrics = [(c, l) for c, l in metrics if c in df.columns]
+ if not metrics:
+ return _empty("No feature columns for region comparison")
+
+ regions = sorted(df["region"].dropna().unique())
+ palette = dict(zip(regions, ["#4C72B0", "#C44E52", "#55A868", "#CCB974"][:len(regions)]))
+
+ fig, axes = plt.subplots(2, 3, figsize=(14, 8))
+ fig.suptitle("Region Comparison — Morphological Features",
+ fontsize=13, fontweight="bold")
+
+ for ax, (col, lab) in zip(axes.flat, metrics):
+ sns.boxplot(data=df, x="region", y=col, order=regions,
+ palette=palette, ax=ax, width=0.5, linewidth=1,
+ flierprops=_FLIER)
+ ax.set_title(lab, fontsize=10)
+ ax.set_xlabel("")
+ ax.set_ylabel("")
+ for i, reg in enumerate(regions):
+ n = df[df["region"] == reg][col].notna().sum()
+ ax.text(i, ax.get_ylim()[0], f"n={n}",
+ ha="center", va="bottom", fontsize=8, color="gray")
+
+ for ax in axes.flat[len(metrics):]:
+ ax.set_visible(False)
+
+ fig.tight_layout()
+ return fig
+
+
+def fig_segmentation_breakdown(df: pd.DataFrame) -> plt.Figure:
+ if "segmentation_method" not in df.columns:
+ return _empty("No segmentation_method column found")
+
+ fig, axes = plt.subplots(1, 2, figsize=(12, 4.5))
+ fig.suptitle("Segmentation Method Summary", fontsize=13, fontweight="bold")
+
+ counts = df["segmentation_method"].value_counts()
+ colors = {"sam2": "#4C72B0", "watershed": "#55A868"}
+ bar_colors = [colors.get(m, "#888") for m in counts.index]
+
+ ax = axes[0]
+ bars = ax.bar(counts.index, counts.values, color=bar_colors, edgecolor="white")
+ ax.set_ylabel("Image Count")
+ ax.set_title("Segmentation Method Used")
+ total = counts.sum()
+ for bar, v in zip(bars, counts.values):
+ ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + total * 0.005,
+ f"{v:,}\n({v/total*100:.1f}%)", ha="center", va="bottom", fontsize=9)
+
+ if "confidence" in df.columns:
+ ax = axes[1]
+ for method in counts.index:
+ vals = df[df["segmentation_method"] == method]["confidence"].dropna()
+ if len(vals) < 3:
+ continue
+ sns.kdeplot(vals, ax=ax, label=method, color=colors.get(method, "#888"),
+ linewidth=1.8, fill=True, alpha=0.15)
+ ax.set_xlabel("Segmentation Confidence Score")
+ ax.set_ylabel("Density")
+ ax.set_title("Confidence Distribution by Method")
+ ax.legend()
+ else:
+ axes[1].set_visible(False)
+
+ fig.tight_layout()
+ return fig
+
+
+# ===========================================================================
+# CURVATURE FIGURES
+# ===========================================================================
+
+def fig_curvature_distributions(df: pd.DataFrame) -> plt.Figure:
+ metrics = [
+ ("curv_mean", "Mean Curvature (mm⁻¹)"),
+ ("curv_median", "Median Curvature (mm⁻¹)"),
+ ("curv_std", "Curvature Std Dev"),
+ ("curv_iqr", "Curvature IQR"),
+ ]
+ metrics = [(c, l) for c, l in metrics if c in df.columns]
+ if not metrics:
+ return _empty("No curvature columns found")
+
+ regions = sorted(df["region"].dropna().unique()) if "region" in df.columns else []
+ palette = dict(zip(regions, ["#4C72B0", "#C44E52", "#55A868", "#CCB974"][:len(regions)]))
+
+ fig, axes = plt.subplots(1, len(metrics), figsize=(5 * len(metrics), 4.5))
+ if len(metrics) == 1:
+ axes = [axes]
+ fig.suptitle("Curvature Distributions", fontsize=13, fontweight="bold")
+
+ for ax, (col, label) in zip(axes, metrics):
+ if regions:
+ for reg in regions:
+ vals = df[df["region"] == reg][col].dropna()
+ if len(vals) < 3:
+ continue
+ sns.kdeplot(vals, ax=ax, label=f"Region {reg}",
+ color=palette[reg], linewidth=1.8,
+ fill=True, alpha=0.15)
+ ax.legend(fontsize=9)
+ else:
+ sns.kdeplot(df[col].dropna(), ax=ax, linewidth=1.8,
+ color="#4C72B0", fill=True, alpha=0.2)
+ ax.set_xlabel(label, fontsize=10)
+ ax.set_ylabel("Density")
+
+ fig.tight_layout()
+ return fig
+
+
+def fig_curl_and_waves(df: pd.DataFrame) -> plt.Figure:
+ metrics = [
+ ("curl_index", "Curl Index\n(0 = coiled, 1 = straight)"),
+ ("curl_index_std", "Curl Index Std Dev"),
+ ("wave_count", "Wave Count\n(peaks per image)"),
+ ("wave_count_per_mm", "Wave Count per mm"),
+ ]
+ metrics = [(c, l) for c, l in metrics if c in df.columns]
+ if not metrics:
+ return _empty("No curl / wave columns found")
+
+ regions = sorted(df["region"].dropna().unique()) if "region" in df.columns else []
+ palette = dict(zip(regions, ["#4C72B0", "#C44E52", "#55A868"][:len(regions)]))
+
+ fig, axes = plt.subplots(1, len(metrics), figsize=(5 * len(metrics), 4.5))
+ if len(metrics) == 1:
+ axes = [axes]
+ fig.suptitle("Curl Index & Wave Count", fontsize=13, fontweight="bold")
+
+ for ax, (col, label) in zip(axes, metrics):
+ if regions:
+ for reg in regions:
+ vals = df[df["region"] == reg][col].dropna()
+ if len(vals) < 3:
+ continue
+ sns.kdeplot(vals, ax=ax, label=f"Region {reg}",
+ color=palette[reg], linewidth=1.8,
+ fill=True, alpha=0.15)
+ ax.legend(fontsize=9)
+ else:
+ sns.kdeplot(df[col].dropna(), ax=ax, linewidth=1.8,
+ color="#55A868", fill=True, alpha=0.2)
+ ax.set_xlabel(label.split("\n")[0], fontsize=10)
+ ax.set_ylabel("Density")
+ ax.set_title(label, fontsize=9)
+
+ fig.tight_layout()
+ return fig
+
+
+def fig_curvature_vs_curl(df: pd.DataFrame) -> plt.Figure:
+ if not _has(df, "curv_mean", "curl_index"):
+ return _empty("Requires curv_mean and curl_index")
+
+ fig, ax = plt.subplots(figsize=(8, 5.5))
+ regions = sorted(df["region"].dropna().unique()) if "region" in df.columns else []
+ palette = dict(zip(regions, ["#4C72B0", "#C44E52", "#55A868"][:len(regions)]))
+
+ if regions:
+ for reg in regions:
+ sub = df[df["region"] == reg]
+ ax.scatter(sub["curv_mean"], sub["curl_index"],
+ color=palette[reg], alpha=0.5, s=18,
+ edgecolors="none", label=f"Region {reg}", rasterized=True)
+ ax.legend(fontsize=10)
+ else:
+ ax.scatter(df["curv_mean"], df["curl_index"],
+ color="#4C72B0", alpha=0.5, s=18, edgecolors="none")
+
+ ax.set_xlabel("Mean Curvature (mm⁻¹)", fontsize=11)
+ ax.set_ylabel("Curl Index", fontsize=11)
+ ax.set_title("Curvature vs Curl Index\n"
+ "(high curv + low curl = tight regular coil; high curl = straight)")
+ fig.tight_layout()
+ return fig
+
+
+def fig_fiber_diameter(df: pd.DataFrame) -> plt.Figure:
+ cols = [c for c in ["diameter_mean_mu", "diameter_cv"] if c in df.columns]
+ if not cols:
+ return _empty("No diameter columns found")
+
+ regions = sorted(df["region"].dropna().unique()) if "region" in df.columns else []
+ palette = dict(zip(regions, ["#4C72B0", "#C44E52", "#55A868"][:len(regions)]))
+ labels = {"diameter_mean_mu": "Mean Fiber Diameter (µm)",
+ "diameter_cv": "Fiber Diameter CV"}
+
+ fig, axes = plt.subplots(1, len(cols), figsize=(6 * len(cols), 4.5))
+ if len(cols) == 1:
+ axes = [axes]
+ fig.suptitle("Fiber Diameter Statistics", fontsize=13, fontweight="bold")
+
+ for ax, col in zip(axes, cols):
+ if regions:
+ for reg in regions:
+ vals = df[df["region"] == reg][col].dropna()
+ if len(vals) < 3:
+ continue
+ sns.kdeplot(vals, ax=ax, label=f"Region {reg}",
+ color=palette[reg], linewidth=1.8,
+ fill=True, alpha=0.15)
+ ax.legend(fontsize=9)
+ else:
+ sns.kdeplot(df[col].dropna(), ax=ax, linewidth=1.8,
+ color="#CCB974", fill=True, alpha=0.2)
+ ax.set_xlabel(labels[col], fontsize=10)
+ ax.set_ylabel("Density")
+
+ fig.tight_layout()
+ return fig
+
+
+def fig_curvature_summary_heatmap(df: pd.DataFrame) -> plt.Figure:
+ feature_cols = [
+ "curv_mean", "curv_median", "curv_std", "curv_max", "curv_cv",
+ "curv_iqr", "curl_index", "wave_count_per_mm",
+ "length_mean", "diameter_mean_mu", "diameter_cv",
+ ]
+ display = {
+ "curv_mean": "Mean Curv", "curv_median": "Median Curv",
+ "curv_std": "Curv Std", "curv_max": "Max Curv (p99)",
+ "curv_cv": "Curv CV", "curv_iqr": "Curv IQR",
+ "curl_index": "Curl Index", "wave_count_per_mm": "Waves/mm",
+ "length_mean": "Mean Length", "diameter_mean_mu": "Diameter",
+ "diameter_cv": "Diameter CV",
+ }
+ available = [c for c in feature_cols if c in df.columns]
+ if len(available) < 3 or "region" not in df.columns:
+ return _empty("Not enough curvature columns or no region column")
+
+ regions = sorted(df["region"].dropna().unique())
+ means = df.groupby("region")[available].mean().reindex(regions)
+ means.columns = [display[c] for c in available]
+ z = (means - means.mean()) / (means.std() + 1e-10)
+
+ fig, ax = plt.subplots(figsize=(13, max(3, len(regions) * 0.9)))
+ sns.heatmap(z.T, annot=means.T, fmt=".2f", cmap="RdBu_r",
+ center=0, linewidths=0.5, ax=ax,
+ annot_kws={"size": 8.5},
+ cbar_kws={"label": "Z-score", "shrink": 0.6})
+ ax.set_title("Mean Curvature Feature per Region",
+ fontsize=11, fontweight="bold")
+ ax.tick_params(axis="x", rotation=0)
+ ax.tick_params(axis="y", rotation=0)
+ fig.tight_layout()
+ return fig
+
+
+# ===========================================================================
+# SAMPLE-LEVEL FIGURES
+# ===========================================================================
+
+def fig_replicate_variability(df: pd.DataFrame, image_type: str = "section") -> plt.Figure:
+ sub = df[df["image_type"] == image_type].copy() \
+ if "image_type" in df.columns else df.copy()
+
+ if "sample_id" not in sub.columns:
+ return _empty("No sample_id column for variability analysis")
+
+ metrics = (["circularity", "aspect_ratio", "area_mu2", "eccentricity"]
+ if image_type == "section"
+ else ["curv_mean", "curl_index", "wave_count_per_mm", "diameter_mean_mu"])
+ metrics = [c for c in metrics if c in sub.columns]
+ if not metrics:
+ return _empty("No numeric feature columns for variability")
+
+ counts = sub["sample_id"].value_counts()
+ valid = counts[counts >= 2].index
+ sub = sub[sub["sample_id"].isin(valid)]
+ if sub.empty:
+ return _empty("No samples with >= 2 replicates")
+
+ cv_df = (sub.groupby("sample_id")[metrics].std() /
+ sub.groupby("sample_id")[metrics].mean()).dropna()
+
+ labels = {
+ "circularity": "Circularity", "aspect_ratio": "Aspect Ratio",
+ "area_mu2": "Area (µm²)", "eccentricity": "Eccentricity",
+ "curv_mean": "Mean Curvature", "curl_index": "Curl Index",
+ "wave_count_per_mm": "Waves/mm", "diameter_mean_mu": "Diameter (µm)",
+ }
+
+ fig, axes = plt.subplots(1, len(metrics), figsize=(4.5 * len(metrics), 4.5))
+ if len(metrics) == 1:
+ axes = [axes]
+ fig.suptitle(f"Within-Individual Replicate Variability — {image_type.title()}\n"
+ f"(N = {len(cv_df)} individuals)",
+ fontsize=12, fontweight="bold")
+
+ for ax, col in zip(axes, metrics):
+ data = cv_df[col].dropna()
+ ax.hist(data, bins=25, color="#4C72B0", edgecolor="white", alpha=0.85)
+ med = data.median()
+ ax.axvline(med, color="#C44E52", lw=1.8, ls="--",
+ label=f"Median = {med:.3f}")
+ ax.set_title(labels.get(col, col), fontsize=10)
+ ax.set_xlabel("CV (std / mean)")
+ ax.set_ylabel("Individuals" if ax is axes[0] else "")
+ ax.legend(fontsize=8)
+
+ fig.tight_layout()
+ return fig
+
+
+def fig_sample_overview(df: pd.DataFrame) -> plt.Figure:
+ useful = [
+ "circularity_mean", "aspect_ratio_mean", "eccentricity_mean",
+ "area_mu2_mean", "radial_cv_mean", "efd_deviation_mean",
+ "curv_mean_mean", "curl_index_mean", "wave_count_per_mm_mean",
+ "diameter_mean_mu_mean",
+ ]
+ available = [c for c in useful if c in df.columns]
+ if len(available) < 2:
+ return _empty("Not enough aggregated columns in per-sample CSV")
+
+ sort_col = "n_valid" if "n_valid" in df.columns else available[0]
+ top = df.nlargest(min(30, len(df)), sort_col).copy()
+
+ id_col = next((c for c in ["sample_id", "region"] if c in top.columns), None)
+ if id_col:
+ top.index = top[id_col].astype(str)
+
+ data = top[available]
+ z = (data - data.mean()) / (data.std() + 1e-10)
+ z.columns = [c.replace("_mean", "").replace("_", " ").title()
+ for c in z.columns]
+
+ fig, ax = plt.subplots(figsize=(14, max(5, len(top) * 0.35 + 2)))
+ sns.heatmap(z.T, cmap="RdBu_r", center=0, linewidths=0.3,
+ ax=ax, cbar_kws={"label": "Z-score", "shrink": 0.6})
+ ax.set_title(f"Per-Sample Feature Overview (top {len(top)} samples)",
+ fontsize=11, fontweight="bold")
+ ax.tick_params(axis="x", rotation=45)
+ ax.tick_params(axis="y", rotation=0)
+ fig.tight_layout()
+ return fig
+
+
+# ===========================================================================
+# Generator catalogues — used by GUI app
+# ===========================================================================
+
+FigureSpec = tuple # (group, title, figure)
+
+
+def section_figures(df: pd.DataFrame) -> Generator[FigureSpec, None, None]:
+ """Yield (group, title, fig) tuples for all cross-section figures."""
+ sec = df[df["image_type"] == "section"] if "image_type" in df.columns else df
+
+ yield "Overview", "Segmentation Method", fig_segmentation_breakdown(sec)
+ yield "Shape Space", "Geometric Feature Boxplots", fig_geometric_boxplots(sec)
+ yield "Shape Space", "Size & Radius Distributions", fig_size_distributions(sec)
+ yield "Boundary & Complexity", "Radial & EFD Features", fig_radial_features(sec)
+ yield "Boundary & Complexity", "EFD Harmonic Power Spectrum", fig_efd_power_spectrum(sec)
+ yield "Comparison", "Region Comparison", fig_region_comparison(sec)
+ yield "Summary", "Feature Correlation Heatmap", fig_correlation_heatmap(sec)
+ yield "Variability", "Within-Individual Variability", fig_replicate_variability(df, "section")
+
+
+def curvature_figures(df: pd.DataFrame) -> Generator[FigureSpec, None, None]:
+ """Yield (group, title, fig) tuples for all curvature figures."""
+ curv = df[df["image_type"] == "curvature"] if "image_type" in df.columns else df
+
+ yield "Distributions", "Curvature Distributions", fig_curvature_distributions(curv)
+ yield "Distributions", "Curl Index & Wave Count", fig_curl_and_waves(curv)
+ yield "Distributions", "Fiber Diameter", fig_fiber_diameter(curv)
+ yield "Relationships", "Curvature vs Curl Index", fig_curvature_vs_curl(curv)
+ yield "Summary", "Curvature Feature Heatmap", fig_curvature_summary_heatmap(curv)
+ yield "Variability", "Within-Individual Variability", fig_replicate_variability(df, "curvature")
+
+
+def sample_figures(df: pd.DataFrame) -> Generator[FigureSpec, None, None]:
+ """Yield figures derived from the per-sample aggregated CSV."""
+ yield "Sample Overview", "Per-Sample Feature Heatmap", fig_sample_overview(df)
diff --git a/fibermorph/io/readers.py b/fibermorph/io/readers.py
index 532f468..a4d6e1a 100644
--- a/fibermorph/io/readers.py
+++ b/fibermorph/io/readers.py
@@ -7,7 +7,7 @@
import numpy as np
import skimage
import skimage.io
-from PIL import Image
+from PIL import Image, UnidentifiedImageError
logger = logging.getLogger(__name__)
@@ -30,18 +30,41 @@ def imread(
A grayscale array based on the input image (uint8).
im_name : str
A string with the image name.
+
+ Raises
+ ------
+ UnidentifiedImageError
+ If the file is not a valid image file.
+ FileNotFoundError
+ If the file does not exist.
"""
input_path = pathlib.Path(input_file)
- if use_skimage:
- try:
- img_float = skimage.io.imread(input_file, as_gray=True)
- img = skimage.img_as_ubyte(img_float)
- except ValueError as e:
- logger.warning(f"skimage failed to read {input_file}, falling back to PIL: {e}")
+ if not input_path.exists():
+ raise FileNotFoundError(f"Image file not found: {input_path}")
+
+ if not input_path.is_file():
+ raise ValueError(f"Path is not a file: {input_path}")
+
+ try:
+ if use_skimage:
+ try:
+ img_float = skimage.io.imread(input_file, as_gray=True)
+ img = skimage.img_as_ubyte(img_float)
+ except ValueError as e:
+ logger.warning(f"skimage failed to read {input_file}, falling back to PIL: {e}")
+ img = np.array(Image.open(str(input_path)).convert("L"))
+ else:
img = np.array(Image.open(str(input_path)).convert("L"))
- else:
- img = np.array(Image.open(str(input_path)).convert("L"))
+ except UnidentifiedImageError as e:
+ logger.error(f"Cannot identify image file: {input_path}")
+ raise UnidentifiedImageError(
+ f"Cannot identify image file '{input_path.name}'. "
+ f"Please ensure the file is a valid TIFF image."
+ ) from e
+ except Exception as e:
+ logger.error(f"Error reading image file {input_path}: {e}")
+ raise
im_name = input_path.stem
return img, im_name
diff --git a/fibermorph/pipeline/__init__.py b/fibermorph/pipeline/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/fibermorph/pipeline/batch.py b/fibermorph/pipeline/batch.py
new file mode 100644
index 0000000..fcfa4af
--- /dev/null
+++ b/fibermorph/pipeline/batch.py
@@ -0,0 +1,195 @@
+"""Batch processor for section and/or curvature image directories.
+
+Produces one CSV in output_dir:
+ hair_analysis_per_image.csv — one row per source image, carrying the source
+ filename. No grouping/aggregation is performed;
+ that is left to downstream analysis.
+
+Section and curvature pipelines run independently; either can be omitted.
+"""
+
+from __future__ import annotations
+
+import os
+import pathlib
+from typing import Callable
+
+import numpy as np
+import pandas as pd
+from tqdm import tqdm
+
+from ..utils.metadata import collect_images
+
+
+def _process_dir(
+ image_paths: list,
+ process_fn: Callable,
+ image_type: str,
+ process_kwargs: dict,
+) -> list:
+ rows: list = []
+ failed = 0
+ for path in tqdm(image_paths, desc=f" {image_type}", unit="img", ncols=80):
+ fname = os.path.basename(path)
+ try:
+ result = process_fn(path, **process_kwargs)
+ if result is None:
+ failed += 1
+ continue
+ # result may be a dict (extended) or a pd.DataFrame (legacy)
+ if isinstance(result, dict):
+ row = {"image_type": image_type, "source_file": fname}
+ row.update(result)
+ rows.append(row)
+ elif isinstance(result, pd.DataFrame) and not result.empty:
+ row_dict = result.iloc[0].to_dict()
+ row_dict["image_type"] = image_type
+ row_dict["source_file"] = fname
+ rows.append(row_dict)
+ else:
+ failed += 1
+ except Exception as exc:
+ print(f" WARNING [{fname}]: {exc}")
+ failed += 1
+ if failed:
+ print(f" {failed} image(s) failed or yielded no detections.")
+ return rows
+
+
+def run_batch(
+ section_dir: str | None,
+ curv_dir: str | None,
+ output_dir: str,
+ resolution_mu: float = 4.25,
+ resolution_mm: float = 132.0,
+ min_diam: float = 30.0,
+ max_diam: float = 150.0,
+ window_size: int = 50,
+ window_unit: str = "px",
+ use_sam2: bool = False,
+ sam2_checkpoint: str = "",
+ sam2_cfg: str = "",
+ save_img: bool = False,
+ extended_features: bool = True,
+ jobs: int = 1,
+) -> pd.DataFrame:
+ """Run batch processing for section and/or curvature images.
+
+ Parameters
+ ----------
+ section_dir : directory of cross-section images, or None to skip
+ curv_dir : directory of curvature images, or None to skip
+ output_dir : directory where CSVs are written
+ resolution_mu : cross-section resolution in pixels per µm
+ resolution_mm : curvature resolution in pixels/mm
+ min_diam / max_diam : diameter filter in µm (section only)
+ window_size : Taubin sliding window in pixels
+ window_unit : 'px' or 'mm'
+ use_sam2 : attempt SAM2 segmentation (falls back to watershed)
+ sam2_checkpoint : path to SAM2 .pt weights file
+ sam2_cfg : SAM2 model config yaml
+ save_img : save intermediate images
+ extended_features : compute EFD, Hu moments, radial profile, shape class
+ jobs : parallel jobs (passed to section/curvature if supported)
+
+ Returns
+ -------
+ per_image_df : one row per source image (empty DataFrame if nothing processed)
+ """
+ os.makedirs(output_dir, exist_ok=True)
+ all_rows: list = []
+
+ # -------------------------------------------------------------------------
+ # Cross-section pipeline
+ # -------------------------------------------------------------------------
+ if section_dir and os.path.isdir(section_dir):
+ from ..analysis.section_pipeline import section_seq
+
+ print(f"\n[Section] Processing: {section_dir}")
+ paths = collect_images(section_dir)
+ print(f" Found {len(paths)} images")
+
+ def _run_section(img_path: str, **kwargs):
+ out_dir = pathlib.Path(output_dir) / "section_output"
+ out_dir.mkdir(exist_ok=True)
+ df = section_seq(
+ img_path,
+ str(out_dir),
+ resolution=kwargs["resolution_mu"],
+ minsize=kwargs["min_diam"],
+ maxsize=kwargs["max_diam"],
+ save_img=kwargs.get("save_img", False),
+ use_sam2=kwargs.get("use_sam2", False),
+ sam2_checkpoint=kwargs.get("sam2_checkpoint", ""),
+ sam2_cfg=kwargs.get("sam2_cfg", ""),
+ extended_features=kwargs.get("extended_features", True),
+ )
+ return df
+
+ sec_kwargs = {
+ "resolution_mu": resolution_mu,
+ "min_diam": min_diam,
+ "max_diam": max_diam,
+ "save_img": save_img,
+ "use_sam2": use_sam2,
+ "sam2_checkpoint": sam2_checkpoint,
+ "sam2_cfg": sam2_cfg,
+ "extended_features": extended_features,
+ }
+ rows = _process_dir(paths, _run_section, "section", sec_kwargs)
+ all_rows.extend(rows)
+ print(f" Processed {len(rows)} images successfully.")
+
+ # -------------------------------------------------------------------------
+ # Curvature pipeline
+ # -------------------------------------------------------------------------
+ if curv_dir and os.path.isdir(curv_dir):
+ from ..analysis.curvature_pipeline import curvature_seq
+
+ print(f"\n[Curvature] Processing: {curv_dir}")
+ paths = collect_images(curv_dir)
+ print(f" Found {len(paths)} images")
+
+ def _run_curvature(img_path: str, **kwargs):
+ out_dir = pathlib.Path(output_dir) / "curvature_output"
+ out_dir.mkdir(exist_ok=True)
+ df = curvature_seq(
+ img_path,
+ str(out_dir),
+ resolution=kwargs["resolution_mm"],
+ window_size=kwargs["window_size"],
+ window_unit=kwargs.get("window_unit", "px"),
+ save_img=kwargs.get("save_img", False),
+ test=False,
+ within_element=False,
+ )
+ return df
+
+ curv_kwargs = {
+ "resolution_mm": resolution_mm,
+ "window_size": window_size,
+ "window_unit": window_unit,
+ "save_img": save_img,
+ }
+ rows = _process_dir(paths, _run_curvature, "curvature", curv_kwargs)
+ all_rows.extend(rows)
+ print(f" Processed {len(rows)} images successfully.")
+
+ if not all_rows:
+ print("\nNo results produced. Check input paths and image formats.")
+ return pd.DataFrame()
+
+ per_image = pd.DataFrame(all_rows)
+
+ # Identifier columns first. The source filename is recorded as-is; any
+ # grouping (within/between individual) is left to downstream analysis.
+ meta_cols = ["source_file", "image_type", "segmentation_method"]
+ present_meta = [c for c in meta_cols if c in per_image.columns]
+ other_cols = [c for c in per_image.columns if c not in present_meta]
+ per_image = per_image[present_meta + other_cols]
+
+ per_image_path = os.path.join(output_dir, "hair_analysis_per_image.csv")
+ per_image.to_csv(per_image_path, index=False)
+ print(f"\nPer-image CSV → {per_image_path} ({len(per_image)} rows)")
+
+ return per_image
diff --git a/fibermorph/processing/section_sam2.py b/fibermorph/processing/section_sam2.py
new file mode 100644
index 0000000..ca3e4c9
--- /dev/null
+++ b/fibermorph/processing/section_sam2.py
@@ -0,0 +1,324 @@
+"""Cross-section segmentation using SAM2 (GPU) with watershed fallback (CPU).
+
+All SAM2 imports are guarded by try/except so the module loads cleanly without
+the optional sam2 package installed.
+
+Public API
+----------
+segment_section(gray_img, resolution_mu, min_diam, max_diam,
+ use_sam2, checkpoint, model_cfg)
+ -> (mask_uint8, confidence, method_str) or None
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+from pathlib import Path
+
+import cv2
+import numpy as np
+
+logger = logging.getLogger(__name__)
+
+# ---------------------------------------------------------------------------
+# Optional SAM2 import (guarded)
+# ---------------------------------------------------------------------------
+_SAM2_AVAILABLE = False
+_SAM2_IMPORT_ERROR = ""
+_sam2_generator = None
+_sam2_init_logged = False
+
+try:
+ import torch # noqa: F401
+ from sam2.build_sam import build_sam2 # noqa: F401
+ from sam2.automatic_mask_generator import SAM2AutomaticMaskGenerator # noqa: F401
+ _SAM2_AVAILABLE = True
+except Exception as _e:
+ _SAM2_IMPORT_ERROR = f"{type(_e).__name__}: {_e}"
+
+# Default checkpoint paths (resolved relative to the fibermorph package root)
+_PKG_ROOT = Path(__file__).resolve().parents[2]
+_DEFAULT_CKPT = str(_PKG_ROOT / "checkpoints" / "sam2.1_hiera_tiny.pt")
+_DEFAULT_CFG = "configs/sam2.1/sam2.1_hiera_t.yaml"
+
+RESOLUTION_MU = 4.25
+_MIN_DIAM_MU = 30.0
+_MAX_DIAM_MU = 150.0
+
+
+def sam2_runtime_status() -> tuple[bool, str]:
+ """Whether SAM2 can run in this process, and if not, why.
+
+ Returns (True, "") when the sam2 package imports and a CUDA GPU is visible;
+ otherwise (False, reason). The checkpoint file is not checked here.
+ """
+ if not _SAM2_AVAILABLE:
+ return False, "the sam2 package is not installed"
+ try:
+ if not torch.cuda.is_available(): # noqa: F821
+ return False, "no CUDA GPU is available"
+ except Exception as exc:
+ return False, f"the GPU check failed ({type(exc).__name__})"
+ return True, ""
+
+
+# ---------------------------------------------------------------------------
+# SAM2 generator singleton
+# ---------------------------------------------------------------------------
+
+def _get_sam2_generator(checkpoint: str = _DEFAULT_CKPT,
+ model_cfg: str = _DEFAULT_CFG):
+ global _sam2_generator, _sam2_init_logged
+
+ if _sam2_generator is not None:
+ return _sam2_generator
+
+ if not _SAM2_AVAILABLE:
+ if not _sam2_init_logged:
+ logger.warning(
+ f"SAM2 import failed ({_SAM2_IMPORT_ERROR}) — using watershed fallback. "
+ "Install with: pip install git+https://github.com/facebookresearch/segment-anything-2"
+ )
+ _sam2_init_logged = True
+ return None
+
+ try:
+ device = "cuda" if torch.cuda.is_available() else "cpu" # noqa: F821
+ if device == "cpu":
+ if not _sam2_init_logged:
+ logger.warning(
+ "SAM2: No CUDA GPU detected — falling back to watershed. "
+ "Submit to a GPU partition (--partition=spgpu --gpus=1) to enable SAM2."
+ )
+ _sam2_init_logged = True
+ return None
+
+ ckpt = checkpoint or os.environ.get("SAM2_CHECKPOINT", _DEFAULT_CKPT)
+ if not os.path.exists(ckpt):
+ if not _sam2_init_logged:
+ logger.warning(f"SAM2 checkpoint not found: {ckpt} — using watershed fallback.")
+ _sam2_init_logged = True
+ return None
+
+ logger.info(f"SAM2: Loading model on {device} from {ckpt}")
+ sam2 = build_sam2(model_cfg, ckpt, device=device, apply_postprocessing=True) # noqa: F821
+ _sam2_generator = SAM2AutomaticMaskGenerator( # noqa: F821
+ model=sam2,
+ points_per_side=32,
+ points_per_batch=16,
+ stability_score_threshold=0.92,
+ pred_iou_thresh=0.9,
+ )
+ logger.info("SAM2: Model loaded successfully.")
+ return _sam2_generator
+
+ except Exception as exc:
+ if not _sam2_init_logged:
+ logger.warning(f"SAM2 load failed: {exc} — using watershed fallback.")
+ _sam2_init_logged = True
+ return None
+
+
+# ---------------------------------------------------------------------------
+# Watershed segmentation (CPU — always available)
+# ---------------------------------------------------------------------------
+
+def _watershed_segment(
+ img_gray: np.ndarray,
+ resolution_mu: float = RESOLUTION_MU,
+ min_diam: float = _MIN_DIAM_MU,
+ max_diam: float = _MAX_DIAM_MU,
+):
+ """Multi-factor candidate scoring: centre-bias + circularity + solidity + darkness."""
+ from skimage.measure import regionprops, label as sk_label
+ from skimage.morphology import opening, closing, disk
+
+ h, w = img_gray.shape
+ roi_top = int(0.15 * h)
+ roi_bot = int(0.85 * h)
+
+ _, thresh_roi = cv2.threshold(
+ img_gray[roi_top:roi_bot, :], 0, 255,
+ cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU,
+ )
+ binary = np.zeros_like(img_gray, dtype=np.uint8)
+ binary[roi_top:roi_bot, :] = thresh_roi
+
+ k = disk(3)
+ binary = (opening(binary, k) > 0).astype(np.uint8) * 255
+ binary = (closing(binary, k) > 0).astype(np.uint8) * 255
+
+ labels = sk_label(binary > 0)
+
+ min_px = min_diam * resolution_mu
+ max_px = max_diam * resolution_mu
+
+ candidates = []
+ n_regions = 0
+ n_size_rejected = 0
+ for region in regionprops(labels):
+ n_regions += 1
+ maj = region.major_axis_length
+ if not (min_px <= maj <= max_px):
+ n_size_rejected += 1
+ continue
+ circ = min(4 * np.pi * region.area / (region.perimeter ** 2 + 1e-10), 1.0) \
+ if region.perimeter > 0 else 0.0
+ solid = region.solidity
+ cy, cx = region.centroid
+ dist_c = np.sqrt((cy - h / 2) ** 2 + (cx - w / 2) ** 2)
+ intensity = float(img_gray[labels == region.label].mean())
+ candidates.append({
+ "region": region,
+ "circ": circ, "solidity": solid,
+ "dist": dist_c, "intensity": intensity,
+ })
+
+ if not candidates:
+ if n_size_rejected:
+ logger.warning(
+ "No cross-section passed the size filter "
+ f"[{min_diam:.0f}, {max_diam:.0f}] µm = [{min_px:.0f}, {max_px:.0f}] px "
+ f"at resolution_mu={resolution_mu} px/µm "
+ f"({n_size_rejected}/{n_regions} candidate region(s) fell outside it). "
+ "If a cross-section is clearly present, check that resolution_mu is in "
+ "PIXELS PER µm (e.g. 5.556 for 0.18 µm/pixel) and the min/max diameter range."
+ )
+ return None
+
+ max_dist = max(c["dist"] for c in candidates)
+ max_int = max(c["intensity"] for c in candidates)
+ min_int = min(c["intensity"] for c in candidates)
+ int_range = max_int - min_int + 1e-10
+
+ for c in candidates:
+ nd = c["dist"] / max_dist if max_dist > 0 else 0.0
+ dark = (c["intensity"] - min_int) / int_range
+ c["score"] = (0.35 * (1.0 - nd) +
+ 0.25 * c["circ"] +
+ 0.25 * c["solidity"] +
+ 0.15 * (1.0 - dark))
+
+ best = max(candidates, key=lambda x: x["score"])
+ region = best["region"]
+ mask = np.zeros(img_gray.shape, dtype=np.uint8)
+ mask[labels == region.label] = 255
+ conf = 0.7 * best["circ"] + 0.3 * best["solidity"]
+ return mask, float(conf), "watershed"
+
+
+# ---------------------------------------------------------------------------
+# SAM2 segmentation
+# ---------------------------------------------------------------------------
+
+def _sam2_segment(
+ img_gray: np.ndarray,
+ resolution_mu: float = RESOLUTION_MU,
+ min_diam: float = _MIN_DIAM_MU,
+ max_diam: float = _MAX_DIAM_MU,
+ checkpoint: str = _DEFAULT_CKPT,
+ model_cfg: str = _DEFAULT_CFG,
+):
+ from skimage.measure import regionprops
+
+ gen = _get_sam2_generator(checkpoint, model_cfg)
+ if gen is None:
+ return None
+
+ img_rgb = cv2.cvtColor(img_gray, cv2.COLOR_GRAY2RGB)
+ h, w = img_gray.shape
+
+ try:
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16): # noqa: F821
+ masks = gen.generate(img_rgb)
+ except Exception as exc:
+ logger.debug(f"SAM2 generate() failed: {exc}")
+ return None
+
+ min_px = min_diam * resolution_mu
+ max_px = max_diam * resolution_mu
+ candidates = []
+
+ for m in masks:
+ seg = m["segmentation"].astype(np.uint8)
+ rp = regionprops(seg)
+ if not rp:
+ continue
+ props = rp[0]
+ if not (min_px <= props.major_axis_length <= max_px):
+ continue
+ cy, cx = props.centroid
+ dist_c = np.sqrt((cy - h / 2) ** 2 + (cx - w / 2) ** 2)
+ circ = min(4 * np.pi * props.area / (props.perimeter ** 2 + 1e-10), 1.0) \
+ if props.perimeter > 0 else 0.0
+ intensity = float(img_gray[seg > 0].mean())
+ candidates.append({
+ "mask": seg, "iou": m["predicted_iou"],
+ "dist": dist_c, "circ": circ,
+ "solidity": props.solidity, "intensity": intensity,
+ })
+
+ if not candidates:
+ return None
+
+ max_dist = max(c["dist"] for c in candidates)
+ max_iou = max(c["iou"] for c in candidates)
+ max_int = max(c["intensity"] for c in candidates)
+ min_int = min(c["intensity"] for c in candidates)
+ int_range = max_int - min_int + 1e-10
+
+ for c in candidates:
+ nd = c["dist"] / max_dist if max_dist > 0 else 0.0
+ ni = c["iou"] / max_iou if max_iou > 0 else 0.0
+ dark = (c["intensity"] - min_int) / int_range
+ c["score"] = (0.35 * (1.0 - nd) +
+ 0.25 * c["circ"] +
+ 0.25 * c["solidity"] +
+ 0.15 * (1.0 - dark) +
+ 0.10 * ni)
+
+ best = max(candidates, key=lambda x: x["score"])
+ mask = (best["mask"] * 255).astype(np.uint8)
+ return mask, float(best["iou"]), "sam2"
+
+
+# ---------------------------------------------------------------------------
+# Public API
+# ---------------------------------------------------------------------------
+
+def segment_section(
+ gray_img: np.ndarray,
+ resolution_mu: float = RESOLUTION_MU,
+ min_diam: float = _MIN_DIAM_MU,
+ max_diam: float = _MAX_DIAM_MU,
+ use_sam2: bool = False,
+ checkpoint: str = _DEFAULT_CKPT,
+ model_cfg: str = _DEFAULT_CFG,
+):
+ """Segment a hair cross-section from a grayscale microscopy image.
+
+ Tries SAM2 (when use_sam2=True and GPU available), then falls back
+ to watershed automatically.
+
+ Parameters
+ ----------
+ gray_img : 2D uint8 grayscale image
+ resolution_mu : pixels per µm
+ min_diam : minimum diameter threshold in µm
+ max_diam : maximum diameter threshold in µm
+ use_sam2 : attempt SAM2 segmentation first
+ checkpoint : path to SAM2 model checkpoint
+ model_cfg : SAM2 model config file
+
+ Returns
+ -------
+ (mask_uint8, confidence, method_str) or None if no candidate found
+ """
+ if use_sam2:
+ result = _sam2_segment(gray_img, resolution_mu, min_diam, max_diam,
+ checkpoint, model_cfg)
+ if result is not None:
+ return result
+
+ return _watershed_segment(gray_img, resolution_mu, min_diam, max_diam)
diff --git a/fibermorph/test/test_cli.py b/fibermorph/test/test_cli.py
new file mode 100644
index 0000000..d10da69
--- /dev/null
+++ b/fibermorph/test/test_cli.py
@@ -0,0 +1,625 @@
+"""Tests for the fibermorph command line (fibermorph.cli).
+
+Covers how ``--window_size`` is parsed and validated (including the longest
+window accepted), how out-of-range values of ``--jobs``, the resolutions and
+the section size limits are refused, and runs the real entry point (``fibermorph.cli.main`` and ``python -m
+fibermorph``) on a small synthetic curvature image.
+
+Before ``--window_size`` was parsed as a number, its values reached the
+curvature code as strings: ``--window_unit mm`` crashed with a TypeError
+(string times float) and ``--window_size 50.0`` with ``--window_unit px``
+crashed with a ValueError.
+"""
+
+import pathlib
+import subprocess
+import sys
+
+import pandas as pd
+import pytest
+from PIL import Image
+
+import fibermorph
+from fibermorph import cli
+from fibermorph.analysis.curvature_pipeline import curvature_seq
+from fibermorph.cli import MAX_WINDOW_PX, normalize_window_sizes, parse_args
+
+SYNTHETIC_IMAGE = (
+ pathlib.Path(__file__).parent / "test_data" / "curv_golden" / "synthetic_curv.png"
+)
+RESOLUTION = 132 # pixels per mm
+
+# Arguments every parse needs besides the ones under test.
+BASE = ["--curvature", "-i", "in_dir", "-o", "out_dir"]
+
+
+def _window_size(*args):
+ return parse_args(BASE + list(args)).window_size
+
+
+def _parse_error(capsys, *args, base=BASE):
+ """Parse ``args`` expecting argparse to reject them; return the error text."""
+ with pytest.raises(SystemExit) as excinfo:
+ parse_args(list(base) + list(args))
+ assert excinfo.value.code == 2
+ return capsys.readouterr().err
+
+
+# ---------------------------------------------------------------------------
+# Parsing
+# ---------------------------------------------------------------------------
+
+
+class TestWindowSizeParsing:
+ def test_default_is_whole_hair(self):
+ assert _window_size() is None
+
+ def test_px_values_become_ints(self):
+ value = _window_size("--window_size", "50")
+ assert value == [50]
+ assert type(value[0]) is int
+
+ def test_px_accepts_a_float_spelling_of_a_whole_number(self):
+ value = _window_size("--window_size", "50.0")
+ assert value == [50]
+ assert type(value[0]) is int
+
+ def test_px_rejects_fractions(self, capsys):
+ err = _parse_error(capsys, "--window_size", "50.5")
+ assert "--window_size" in err
+ assert "whole number" in err
+ assert "Traceback" not in err
+
+ def test_mm_values_stay_floats(self):
+ value = _window_size("--window_size", "0.5", "--window_unit", "mm")
+ assert value == [0.5]
+ assert type(value[0]) is float
+
+ def test_mm_whole_number_is_a_float(self):
+ value = _window_size("--window_size", "1", "--window_unit", "mm")
+ assert value == [1.0]
+ assert type(value[0]) is float
+
+ def test_unit_flag_may_come_before_the_sizes(self):
+ assert _window_size("--window_unit", "mm", "--window_size", "0.25") == [0.25]
+
+ def test_several_values_are_a_sweep(self):
+ value = _window_size("--window_size", "25", "50", "100.0")
+ assert value == [25, 50, 100]
+ assert all(type(v) is int for v in value)
+
+ def test_several_mm_values(self):
+ assert _window_size("--window_size", "0.25", "0.5", "1", "--window_unit", "mm") == [
+ 0.25,
+ 0.5,
+ 1.0,
+ ]
+
+ @pytest.mark.parametrize("text", ["none", "None", "NONE"])
+ def test_none_means_whole_hair(self, text):
+ assert _window_size("--window_size", text) is None
+ assert _window_size("--window_size", text, "--window_unit", "mm") is None
+
+ @pytest.mark.parametrize("values", [["50", "none"], ["none", "50"], ["none", "none"]])
+ def test_none_must_be_given_on_its_own(self, capsys, values):
+ err = _parse_error(capsys, "--window_size", *values)
+ assert "--window_size" in err and "on its own" in err
+
+ @pytest.mark.parametrize("text", ["0", "0.0", "-5", "-50.0"])
+ def test_px_rejects_zero_and_negative(self, capsys, text):
+ err = _parse_error(capsys, "--window_size", text)
+ assert "greater than 0" in err
+
+ @pytest.mark.parametrize("text", ["0", "-0.5"])
+ def test_mm_rejects_zero_and_negative(self, capsys, text):
+ err = _parse_error(capsys, "--window_size", text, "--window_unit", "mm")
+ assert "greater than 0" in err
+
+ def test_one_bad_value_in_a_sweep_rejects_the_lot(self, capsys):
+ err = _parse_error(capsys, "--window_size", "25", "0", "100")
+ assert "greater than 0" in err
+
+ @pytest.mark.parametrize("text", ["nan", "inf"])
+ def test_rejects_non_finite_numbers(self, capsys, text):
+ err = _parse_error(capsys, "--window_size", text, "--window_unit", "mm")
+ assert "finite" in err
+
+ def test_rejects_text_that_is_not_a_number(self, capsys):
+ err = _parse_error(capsys, "--window_size", "wide")
+ assert "invalid window size 'wide'" in err
+
+ def test_option_without_a_value_is_rejected(self, capsys):
+ err = _parse_error(capsys, "--window_size")
+ assert "expected at least one argument" in err
+
+ def test_remote_script_arguments_parse(self):
+ # The "Run Remote" view of the GUI writes e.g. "--window_size 50"
+ # (a whole number of pixels) among the other curvature flags.
+ args = parse_args(
+ ["--curvature", "-i", "/data/in", "-o", "/data/out"]
+ + "--resolution_mm 132 --window_size 50 --jobs 4".split()
+ )
+ assert args.window_size == [50]
+ assert args.window_unit == "px"
+
+
+class TestNumericOptions:
+ """--jobs, the resolutions and the section size limits."""
+
+ def test_defaults_are_accepted(self):
+ args = parse_args(BASE)
+ assert (args.jobs, args.resolution_mm, args.resolution_mu) == (1, 132.0, 4.25)
+ assert (args.minsize, args.maxsize) == (20, 150)
+
+ @pytest.mark.parametrize("jobs", ["1", "4", "-1", "-2"])
+ def test_jobs_accepts_positive_numbers_and_joblibs_negative_ones(self, jobs, monkeypatch):
+ monkeypatch.setattr(cli.os, "cpu_count", lambda: 8)
+ assert parse_args(BASE + ["--jobs", jobs]).jobs == int(jobs)
+
+ @pytest.mark.parametrize("jobs", ["9", "100000", "2147483648", "99999999999999999999"])
+ def test_jobs_above_cpu_count_is_reduced_to_cpu_count(self, jobs, monkeypatch, capsys):
+ monkeypatch.setattr(cli.os, "cpu_count", lambda: 8)
+ assert parse_args(BASE + ["--jobs", jobs]).jobs == 8
+ assert f"--jobs {jobs} is more than the 8 CPUs" in capsys.readouterr().err
+
+ def test_jobs_cap_handles_unknown_cpu_count(self, monkeypatch, capsys):
+ monkeypatch.setattr(cli.os, "cpu_count", lambda: None)
+ assert parse_args(BASE + ["--jobs", "4"]).jobs == 1
+
+ def test_jobs_rejects_zero(self, capsys):
+ err = _parse_error(capsys, "--jobs", "0")
+ assert "--jobs" in err and "must not be 0" in err
+ assert "Traceback" not in err
+
+ @pytest.mark.parametrize("option", ["--resolution_mm", "--resolution_mu"])
+ @pytest.mark.parametrize("text", ["0", "0.0", "-5", "-0.25", "nan", "inf"])
+ def test_resolution_rejects_zero_negative_and_non_finite(self, capsys, option, text):
+ err = _parse_error(capsys, option, text)
+ assert option in err and "finite number greater than 0" in err
+ assert "Traceback" not in err
+
+ @pytest.mark.parametrize(
+ "option, units_option, units",
+ [
+ ("--resolution_mm", "--resolution_mm_units", "mm_per_px"),
+ ("--resolution_mu", "--resolution_mu_units", "um_per_px"),
+ ],
+ )
+ def test_resolution_in_reciprocal_units(self, capsys, option, units_option, units):
+ args = parse_args(BASE + [option, "0.0076", units_option, units])
+ assert getattr(args, option[2:]) == 0.0076
+ # a value so small that 1 / value overflows cannot become pixels per unit
+ err = _parse_error(capsys, option, "1e-320", units_option, units)
+ assert option in err and "too small" in err
+
+ @pytest.mark.parametrize("module", ["--section", "--raw2gray"])
+ @pytest.mark.parametrize(
+ "bad",
+ [["--resolution_mm", "0"], ["--resolution_mu", "nan"], ["--jobs", "0"]],
+ )
+ def test_checked_whichever_module_is_chosen(self, capsys, module, bad):
+ err = _parse_error(capsys, *bad, base=[module, "-i", "in_dir", "-o", "out_dir"])
+ assert bad[0] in err
+
+ @pytest.mark.parametrize(
+ "extra",
+ [
+ ["--minsize", "0"],
+ ["--minsize", "50", "--maxsize", "50"],
+ ["--minsize", "0", "--maxsize", "1"],
+ ],
+ )
+ def test_section_size_limits_accepted(self, extra):
+ args = parse_args(["--section", "-i", "in_dir", "-o", "out_dir"] + extra)
+ assert args.minsize <= args.maxsize
+
+ @pytest.mark.parametrize(
+ "extra, message",
+ [
+ (["--minsize", "-1"], "--minsize: must not be negative"),
+ (["--maxsize", "0"], "--maxsize: must be greater than 0"),
+ (["--maxsize", "-5"], "--maxsize: must be greater than 0"),
+ (["--minsize", "200", "--maxsize", "100"], "--minsize: 200 is larger than --maxsize"),
+ ],
+ )
+ def test_section_size_limits_rejected(self, capsys, extra, message):
+ err = _parse_error(capsys, *extra, base=["--section", "-i", "in_dir", "-o", "out_dir"])
+ assert message in err
+ assert "Traceback" not in err
+
+
+class TestWindowSizeLimit:
+ """A window longer than MAX_WINDOW_PX pixels is refused, in px or after mm conversion.
+
+ Before the limit, ``--window_size 1e307`` with mm (or a huge resolution)
+ ended in ``OverflowError: cannot convert float infinity to integer``, and
+ ``--window_size 1e300`` with px in ``OSError: File name too long``.
+ """
+
+ def test_the_limit(self):
+ assert MAX_WINDOW_PX == 1_000_000_000
+
+ def test_largest_px_window_is_accepted(self):
+ assert _window_size("--window_size", "1000000000") == [10**9]
+
+ def test_px_window_just_over_the_limit_is_rejected(self, capsys):
+ err = _parse_error(capsys, "--window_size", "1000000001")
+ assert "argument --window_size:" in err
+ assert "largest window allowed (1,000,000,000 pixels)" in err
+
+ @pytest.mark.parametrize("text", ["1e10", "1e20", "1e200", "1e300", "1.7976931348623157e308"])
+ def test_huge_px_windows_are_rejected(self, capsys, text):
+ err = _parse_error(capsys, "--window_size", text)
+ assert "argument --window_size:" in err and "largest window allowed" in err
+ assert "Traceback" not in err
+
+ def test_largest_mm_window_is_accepted(self):
+ value = _window_size(
+ "--window_size", "1000000000", "--window_unit", "mm", "--resolution_mm", "1"
+ )
+ assert value == [1e9]
+
+ def test_mm_window_just_over_the_limit_is_rejected(self, capsys):
+ err = _parse_error(
+ capsys, "--window_size", "1000000001", "--window_unit", "mm", "--resolution_mm", "1"
+ )
+ assert "argument --window_size:" in err and "largest window allowed" in err
+
+ @pytest.mark.parametrize(
+ "extra",
+ [
+ # 1e307 mm * 132 px/mm is infinite
+ ["--window_size", "1e307", "--window_unit", "mm"],
+ # a finite resolution that still overflows the product
+ ["--window_size", "10", "--window_unit", "mm", "--resolution_mm", "1e308"],
+ # finite but over the limit: 1e8 mm * 132 px/mm = 1.32e10 px
+ ["--window_size", "1e8", "--window_unit", "mm"],
+ ],
+ )
+ def test_huge_mm_windows_are_rejected(self, capsys, extra):
+ err = _parse_error(capsys, *extra)
+ assert "argument --window_size:" in err and "largest window allowed" in err
+ assert "Traceback" not in err
+
+ def test_mm_message_gives_the_length_in_pixels_and_the_resolution(self, capsys):
+ err = _parse_error(
+ capsys, "--window_size", "1e8", "--window_unit", "mm", "--resolution_mm", "132"
+ )
+ assert "100000000 mm is 13,200,000,000 pixels at --resolution_mm 132 px_per_mm" in err
+
+ def test_px_message_shows_the_exact_window_just_over_the_limit(self, capsys):
+ err = _parse_error(capsys, "--window_size", str(MAX_WINDOW_PX + 1))
+ assert f"{MAX_WINDOW_PX + 1:,} px is longer than the largest window allowed" in err
+
+ def test_mm_window_is_converted_with_a_mm_per_px_resolution(self, capsys):
+ # 0.0076 mm per pixel is 131.58 pixels per mm: 100 mm is about 13 158 px
+ args = parse_args(
+ BASE
+ + "--window_size 100 --window_unit mm --resolution_mm 0.0076".split()
+ + ["--resolution_mm_units", "mm_per_px"]
+ )
+ assert args.window_size == [100.0]
+ # 1e8 mm at 0.001 mm per pixel is 1e11 px
+ err = _parse_error(
+ capsys, "--window_size", "1e8", "--window_unit", "mm", "--resolution_mm", "0.001",
+ "--resolution_mm_units", "mm_per_px",
+ )
+ assert "mm_per_px" in err and "largest window allowed" in err
+
+ def test_one_long_window_in_a_sweep_rejects_the_lot(self, capsys):
+ err = _parse_error(capsys, "--window_size", "25", "1e300")
+ assert "largest window allowed" in err
+
+ def test_checked_whichever_module_is_chosen(self, capsys):
+ err = _parse_error(
+ capsys, "--window_size", "1e300", base=["--section", "-i", "in_dir", "-o", "out_dir"]
+ )
+ assert "largest window allowed" in err
+
+ def test_a_bad_resolution_is_reported_first(self, capsys):
+ err = _parse_error(
+ capsys, "--window_size", "1e307", "--window_unit", "mm", "--resolution_mm", "0"
+ )
+ assert "argument --resolution_mm:" in err
+
+ def test_no_window_size_is_not_affected_by_a_huge_resolution(self):
+ args = parse_args(["--section", "-i", "in_dir", "-o", "out_dir", "--resolution_mm", "1e308"])
+ assert args.window_size is None
+
+
+class TestNormalizeWindowSizes:
+ def test_none_input(self):
+ assert normalize_window_sizes(None, "px") is None
+ assert normalize_window_sizes(None, "mm") is None
+
+ def test_single_none_value(self):
+ assert normalize_window_sizes([None], "px") is None
+
+ def test_px_ints(self):
+ assert normalize_window_sizes([50.0, 100.0], "px") == [50, 100]
+
+ def test_mm_floats(self):
+ assert normalize_window_sizes([0.5, 2.0], "mm") == [0.5, 2.0]
+
+ @pytest.mark.parametrize(
+ "values, unit",
+ [([50.5], "px"), ([0.0], "px"), ([-1.0], "mm"), ([float("nan")], "mm"),
+ ([float("inf")], "mm"), ([None, 50.0], "px"), ([None, None], "px")],
+ )
+ def test_invalid_values_raise_value_error(self, values, unit):
+ with pytest.raises(ValueError):
+ normalize_window_sizes(values, unit)
+
+
+# ---------------------------------------------------------------------------
+# What main() hands to the curvature workflow
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ "extra, expected",
+ [
+ ([], None),
+ (["--window_size", "none"], None),
+ (["--window_size", "50"], [50]),
+ (["--window_size", "50.0"], [50]),
+ (["--window_size", "25", "50"], [25, 50]),
+ (["--window_size", "0.5", "--window_unit", "mm"], [0.5]),
+ ],
+)
+def test_main_passes_parsed_window_sizes_to_the_workflow(monkeypatch, tmp_path, extra, expected):
+ import fibermorph.workflows as workflows
+
+ calls = []
+ monkeypatch.setattr(workflows, "curvature", lambda *a, **k: calls.append(a) or True)
+
+ with pytest.raises(SystemExit) as excinfo:
+ cli.main(["--curvature", "-i", str(tmp_path), "-o", str(tmp_path / "out")] + extra)
+ assert excinfo.value.code == 0
+
+ (call,) = calls
+ window_size, window_unit = call[4], call[5]
+ assert window_size == expected
+ if expected is not None:
+ assert [type(v) for v in window_size] == [type(v) for v in expected]
+ assert window_unit == ("mm" if "mm" in extra else "px")
+
+
+# ---------------------------------------------------------------------------
+# End to end: the real entry point on a synthetic image
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture(scope="module")
+def image_dir(tmp_path_factory):
+ """A directory holding the synthetic curvature image as a TIFF."""
+ directory = tmp_path_factory.mktemp("cli_images")
+ Image.open(SYNTHETIC_IMAGE).save(directory / "synthetic_curv.tiff")
+ return directory
+
+
+def _run_cli(image_dir, out_dir, *extra):
+ """Run ``fibermorph.cli.main`` for curvature; return (run directory, summary table)."""
+ with pytest.raises(SystemExit) as excinfo:
+ cli.main(
+ ["--curvature", "-i", str(image_dir), "-o", str(out_dir),
+ "--resolution_mm", str(RESOLUTION), "--jobs", "1", *extra]
+ )
+ assert excinfo.value.code == 0
+
+ (run_dir,) = out_dir.glob("*_fibermorph_curvature")
+ (summary,) = run_dir.glob("curvature_summary_data_*.csv")
+ return run_dir, pd.read_csv(summary, index_col=0, float_precision="round_trip")
+
+
+def _direct(image_dir, workdir, window_size, window_unit):
+ """The summary ``curvature_seq`` gives for the same settings, called directly."""
+ return curvature_seq(
+ str(image_dir / "synthetic_curv.tiff"),
+ str(workdir),
+ resolution=RESOLUTION,
+ window_size=window_size,
+ window_unit=window_unit,
+ save_img=False,
+ test=False,
+ within_element=False,
+ )
+
+
+def _assert_same_table(from_cli, direct):
+ pd.testing.assert_frame_equal(
+ from_cli.reset_index(drop=True),
+ direct.reset_index(drop=True),
+ check_exact=False,
+ rtol=1e-12,
+ atol=1e-12,
+ )
+
+
+@pytest.mark.parametrize("size_text", ["50", "50.0"])
+def test_cli_px_window(image_dir, tmp_path, size_text):
+ run_dir, table = _run_cli(image_dir, tmp_path / "out", "--window_size", size_text)
+
+ assert list(table["ID"]) == ["synthetic_curv_WindowSize-50px"]
+ assert (run_dir / "analysis" / "ImageSum_synthetic_curv_WindowSize-50px.csv").is_file()
+ _assert_same_table(table, _direct(image_dir, tmp_path / "direct", 50, "px"))
+
+
+def test_cli_mm_window_matches_a_direct_call(image_dir, tmp_path):
+ run_dir, table = _run_cli(
+ image_dir, tmp_path / "out", "--window_size", "0.5", "--window_unit", "mm"
+ )
+
+ assert list(table["ID"]) == ["synthetic_curv_WindowSize-0.5mm"]
+ assert (run_dir / "analysis" / "ImageSum_synthetic_curv_WindowSize-0.5mm.csv").is_file()
+ assert table["hair_count"].iloc[0] > 0
+ _assert_same_table(table, _direct(image_dir, tmp_path / "direct", 0.5, "mm"))
+
+
+@pytest.mark.parametrize("extra", [[], ["--window_size", "none"]])
+def test_cli_whole_hair_when_no_window_size(image_dir, tmp_path, extra):
+ run_dir, table = _run_cli(image_dir, tmp_path / "out", *extra)
+
+ assert list(table["ID"]) == ["synthetic_curv"]
+ assert "curv_mean" in table.columns and "curv_mean_mean" not in table.columns
+ assert (run_dir / "analysis" / "ImageSum_synthetic_curv.csv").is_file()
+ _assert_same_table(table, _direct(image_dir, tmp_path / "direct", None, "px"))
+
+
+def test_cli_window_size_sweep(image_dir, tmp_path):
+ run_dir, table = _run_cli(
+ image_dir, tmp_path / "out", "--window_size", "25", "50", "100"
+ )
+
+ assert list(table["ID"]) == [
+ "synthetic_curv_WindowSize-25px",
+ "synthetic_curv_WindowSize-50px",
+ "synthetic_curv_WindowSize-100px",
+ ]
+ for window in (25, 50, 100):
+ assert (run_dir / "analysis" / f"ImageSum_synthetic_curv_WindowSize-{window}px.csv").is_file()
+ _assert_same_table(table, _direct(image_dir, tmp_path / "direct", [25, 50, 100], "px"))
+
+
+def test_cli_window_under_ten_pixels_measures_each_hair_whole(image_dir, tmp_path):
+ # A window that comes to fewer than 10 pixels is not used: every hair is
+ # measured over its whole length, whatever the unit. The output still
+ # carries the window that was asked for.
+ _, mm_table = _run_cli(
+ image_dir, tmp_path / "mm", "--window_size", "0.001", "--window_unit", "mm"
+ )
+ _, px_table = _run_cli(image_dir, tmp_path / "px", "--window_size", "5")
+
+ assert list(mm_table["ID"]) == ["synthetic_curv_WindowSize-0.001mm"]
+ assert list(px_table["ID"]) == ["synthetic_curv_WindowSize-5px"]
+ _assert_same_table(mm_table.drop(columns="ID"), px_table.drop(columns="ID"))
+
+
+# ---------------------------------------------------------------------------
+# End to end as a subprocess: python -m fibermorph
+# ---------------------------------------------------------------------------
+
+# Run from the folder that holds the package under test, so the subprocess
+# imports the same fibermorph as this test run.
+PACKAGE_PARENT = pathlib.Path(fibermorph.__file__).resolve().parent.parent
+
+
+def _module_run(*args):
+ return subprocess.run(
+ [sys.executable, "-m", "fibermorph", *args],
+ cwd=PACKAGE_PARENT,
+ capture_output=True,
+ text=True,
+ )
+
+
+def test_module_run_with_a_mm_window(image_dir, tmp_path):
+ out_dir = tmp_path / "out"
+ result = _module_run(
+ "--curvature", "-i", str(image_dir), "-o", str(out_dir),
+ "--resolution_mm", str(RESOLUTION), "--window_size", "0.5", "--window_unit", "mm",
+ )
+
+ assert result.returncode == 0, result.stderr
+ (summary,) = out_dir.glob("*_fibermorph_curvature/curvature_summary_data_*.csv")
+ assert list(pd.read_csv(summary)["ID"]) == ["synthetic_curv_WindowSize-0.5mm"]
+
+
+@pytest.mark.parametrize(
+ "bad_args, message",
+ [
+ (["--window_size", "50.5"], "whole number"),
+ (["--window_size", "-1"], "greater than 0"),
+ (["--window_size", "wide"], "invalid window size"),
+ # both used to end in a traceback after the output directory was made
+ (["--window_unit", "mm", "--window_size", "1e307"], "largest window allowed"),
+ (["--window_size", "1e300"], "largest window allowed"),
+ (
+ ["--window_unit", "mm", "--window_size", "10", "--resolution_mm", "1e308"],
+ "largest window allowed",
+ ),
+ ],
+)
+def test_module_run_reports_bad_window_size_without_a_traceback(
+ image_dir, tmp_path, bad_args, message
+):
+ out_dir = tmp_path / "out"
+ result = _module_run(
+ "--curvature", "-i", str(image_dir), "-o", str(out_dir), *bad_args
+ )
+
+ assert result.returncode == 2
+ assert "Traceback" not in result.stderr
+ assert "--window_size" in result.stderr and message in result.stderr
+ assert not out_dir.exists()
+
+
+def test_module_run_accepts_the_largest_window(image_dir, tmp_path):
+ # no hair is that long, so nothing is measured; the run itself still works
+ out_dir = tmp_path / "out"
+ result = _module_run(
+ "--curvature", "-i", str(image_dir), "-o", str(out_dir),
+ "--resolution_mm", str(RESOLUTION), "--window_size", str(MAX_WINDOW_PX),
+ )
+
+ assert result.returncode == 0, result.stderr
+ (summary,) = out_dir.glob("*_fibermorph_curvature/curvature_summary_data_*.csv")
+ table = pd.read_csv(summary)
+ assert list(table["ID"]) == [f"synthetic_curv_WindowSize-{MAX_WINDOW_PX}px"]
+ assert list(table["hair_count"]) == [0]
+
+
+@pytest.mark.parametrize(
+ "module, bad_args, option",
+ [
+ ("--curvature", ["--jobs", "0"], "--jobs"),
+ ("--curvature", ["--resolution_mm", "0"], "--resolution_mm"),
+ ("--curvature", ["--resolution_mm", "-5"], "--resolution_mm"),
+ ("--curvature", ["--resolution_mm", "nan"], "--resolution_mm"),
+ ("--curvature", ["--resolution_mm", "inf"], "--resolution_mm"),
+ ("--section", ["--resolution_mu", "0"], "--resolution_mu"),
+ ("--section", ["--resolution_mu", "nan"], "--resolution_mu"),
+ ("--section", ["--jobs", "0"], "--jobs"),
+ ("--section", ["--minsize", "200", "--maxsize", "100"], "--minsize"),
+ # an option the chosen module does not use is checked all the same
+ ("--section", ["--resolution_mm", "0"], "--resolution_mm"),
+ ("--raw2gray", ["--resolution_mm", "0"], "--resolution_mm"),
+ ],
+)
+def test_module_run_reports_bad_numeric_options_without_a_traceback(
+ image_dir, tmp_path, module, bad_args, option
+):
+ out_dir = tmp_path / "out"
+ result = _module_run(module, "-i", str(image_dir), "-o", str(out_dir), *bad_args)
+
+ assert result.returncode == 2
+ assert "Traceback" not in result.stderr
+ assert f"argument {option}:" in result.stderr
+ assert not out_dir.exists()
+
+
+def test_module_run_accepts_jobs_minus_one(image_dir, tmp_path):
+ out_dir = tmp_path / "out"
+ result = _module_run(
+ "--curvature", "-i", str(image_dir), "-o", str(out_dir),
+ "--resolution_mm", str(RESOLUTION), "--window_size", "50", "--jobs", "-1",
+ )
+
+ assert result.returncode == 0, result.stderr
+ (summary,) = out_dir.glob("*_fibermorph_curvature/curvature_summary_data_*.csv")
+ assert list(pd.read_csv(summary)["ID"]) == ["synthetic_curv_WindowSize-50px"]
+
+
+def test_module_run_with_huge_jobs_runs_on_available_cpus(image_dir, tmp_path):
+ out_dir = tmp_path / "out"
+ result = _module_run(
+ "--curvature", "-i", str(image_dir), "-o", str(out_dir),
+ "--resolution_mm", str(RESOLUTION), "--window_size", "50",
+ "--jobs", "2147483648",
+ )
+
+ assert result.returncode == 0, result.stderr
+ assert "Traceback" not in result.stderr
+ assert "--jobs 2147483648 is more than the" in result.stderr
+ (summary,) = out_dir.glob("*_fibermorph_curvature/curvature_summary_data_*.csv")
+ assert list(pd.read_csv(summary)["ID"]) == ["synthetic_curv_WindowSize-50px"]
diff --git a/fibermorph/test/test_cli_removed_options.py b/fibermorph/test/test_cli_removed_options.py
new file mode 100644
index 0000000..0a2970c
--- /dev/null
+++ b/fibermorph/test/test_cli_removed_options.py
@@ -0,0 +1,69 @@
+"""Removed curvature options: a clear error (exit code 2) and no --help entry.
+
+Scripts written for earlier versions (for example SBATCH scripts generated by
+the web app) may still pass these flags. They must stop with a message that
+says why, rather than silently running a different analysis or failing with
+argparse's generic "unrecognized arguments".
+"""
+
+import pytest
+
+from fibermorph import cli
+
+# flag -> the feature the error message names
+REMOVED_OPTIONS = {
+ "--use-clahe": "CLAHE preprocessing",
+ "--extended-curvature": "extended curvature",
+}
+
+
+def _parse(monkeypatch, *argv):
+ monkeypatch.setattr("sys.argv", ["fibermorph", *argv])
+ return cli.parse_args()
+
+
+@pytest.mark.parametrize("flag", REMOVED_OPTIONS)
+def test_removed_option_exits_with_code_2_and_explains_why(monkeypatch, capsys, flag):
+ with pytest.raises(SystemExit) as excinfo:
+ _parse(monkeypatch, "--curvature", "-i", "in", "-o", "out", flag)
+ assert excinfo.value.code == 2
+
+ err = capsys.readouterr().err
+ assert f"argument {flag}" in err
+ assert "was removed" in err
+ assert REMOVED_OPTIONS[flag] in err
+ assert "not part of the published fibermorph curvature method" in err
+ assert "unrecognized arguments" not in err
+
+
+@pytest.mark.parametrize("flag", REMOVED_OPTIONS)
+def test_removed_option_is_rejected_wherever_it_appears(monkeypatch, capsys, flag):
+ with pytest.raises(SystemExit) as excinfo:
+ _parse(monkeypatch, flag, "--curvature", "--resolution_mm", "132",
+ "-i", "in", "-o", "out")
+ assert excinfo.value.code == 2
+ assert "was removed" in capsys.readouterr().err
+
+
+@pytest.mark.parametrize("flag", REMOVED_OPTIONS)
+def test_removed_option_is_hidden_from_help(monkeypatch, capsys, flag):
+ with pytest.raises(SystemExit) as excinfo:
+ _parse(monkeypatch, "--help")
+ assert excinfo.value.code == 0
+
+ out = capsys.readouterr().out
+ assert "--within_element" in out # the curvature options are still listed
+ assert flag not in out
+
+
+def test_curvature_command_without_removed_options_still_parses(monkeypatch):
+ args = _parse(monkeypatch, "--curvature", "-i", "in", "-o", "out")
+ assert args.curvature is True
+ for flag in REMOVED_OPTIONS:
+ assert not hasattr(args, flag.lstrip("-").replace("-", "_"))
+
+
+def test_cross_section_extended_features_flag_is_unaffected(monkeypatch):
+ args = _parse(monkeypatch, "--section", "-i", "in", "-o", "out", "--extended-features")
+ assert args.section is True
+ assert args.extended_features is True
diff --git a/fibermorph/test/test_core_curvature.py b/fibermorph/test/test_core_curvature.py
index 103c8e8..e7bf584 100644
--- a/fibermorph/test/test_core_curvature.py
+++ b/fibermorph/test/test_core_curvature.py
@@ -211,7 +211,7 @@ def test_analyze_all_curv_straight_line(self, tmp_path):
resolution=1.0,
window_size=30,
window_unit="px",
- test=True,
+ test=False,
within_element=False,
)
@@ -231,7 +231,7 @@ def test_analyze_all_curv_quarter_arc(self, tmp_path):
resolution=1.0,
window_size=30,
window_unit="px",
- test=True,
+ test=False,
within_element=False,
)
@@ -307,3 +307,126 @@ def test_analyze_all_curv_with_short_element(self, tmp_path):
)
assert isinstance(result, pd.DataFrame)
+
+
+def _create_two_line_skeleton(long_len: int = 120, short_len: int = 30) -> np.ndarray:
+ """Two separate horizontal lines of different lengths."""
+ canvas = np.zeros((200, 200), dtype=np.uint8)
+ rr, cc = sk_draw.line(60, 20, 60, 20 + long_len - 1)
+ canvas[rr, cc] = 1
+ rr, cc = sk_draw.line(150, 20, 150, 20 + short_len - 1)
+ canvas[rr, cc] = 1
+ return canvas
+
+
+class TestPerHairOutputWhenTestTrue:
+ """test=True returns one row per hair (used by the simulated-data validation)."""
+
+ def test_window_mode_returns_per_hair_rows(self, tmp_path):
+ result = analyze_all_curv(
+ _create_two_line_skeleton(), "two_lines", tmp_path,
+ resolution=1.0, window_size=15, window_unit="px",
+ test=True, within_element=False,
+ )
+ assert list(result.columns) == ["curv_mean", "curv_median", "length"]
+ assert len(result) == 2
+ assert sorted(result["length"].round(0)) == [30.0, 120.0]
+
+ def test_window_mode_summary_when_test_false(self, tmp_path):
+ result = analyze_all_curv(
+ _create_two_line_skeleton(), "two_lines", tmp_path,
+ resolution=1.0, window_size=15, window_unit="px",
+ test=False, within_element=False,
+ )
+ assert list(result.columns) == [
+ "ID", "curv_mean_mean", "curv_mean_median", "curv_median_mean",
+ "curv_median_median", "length_mean", "length_median", "hair_count",
+ ]
+ assert result["hair_count"].iloc[0] == 2
+
+ def test_per_hair_arc_curvature_matches_radius(self, tmp_path):
+ """What validation_curv reads: curv_median per hair, close to 1/radius."""
+ radius = 60
+ result = analyze_all_curv(
+ _create_arc_skeleton(radius=radius), "arc", tmp_path,
+ resolution=1.0, window_size=30, window_unit="px",
+ test=True, within_element=False,
+ )
+ assert len(result) == 1
+ assert result["curv_median"].iloc[0] == pytest.approx(1.0 / radius, rel=0.35)
+
+
+class TestWholeHairMode:
+ """window_size=None fits one Taubin circle to each hair (v0.3.1 behavior)."""
+
+ def test_summary_columns_and_values(self, tmp_path):
+ radius = 60
+ result = analyze_all_curv(
+ _create_arc_skeleton(radius=radius), "whole_arc", tmp_path,
+ resolution=1.0, window_size=None, window_unit="px",
+ test=False, within_element=False,
+ )
+ assert list(result.columns) == [
+ "ID", "curv_mean", "curv_median", "length_mean", "length_median",
+ "hair_count",
+ ]
+ assert result["ID"].iloc[0] == "whole_arc"
+ assert result["hair_count"].iloc[0] == 1
+ assert result["curv_mean"].iloc[0] == pytest.approx(1.0 / radius, rel=0.1)
+ assert result["curv_mean"].iloc[0] == result["curv_median"].iloc[0]
+
+ def test_straight_line_has_zero_curvature(self, tmp_path):
+ result = analyze_all_curv(
+ _create_line_skeleton(length=140), "whole_line", tmp_path,
+ resolution=1.0, window_size=None, window_unit="px",
+ test=False, within_element=False,
+ )
+ assert result["curv_mean"].iloc[0] == pytest.approx(0.0, abs=1e-3)
+
+ def test_test_true_returns_per_hair_curv_and_length(self, tmp_path):
+ result = analyze_all_curv(
+ _create_two_line_skeleton(), "whole_two", tmp_path,
+ resolution=1.0, window_size=None, window_unit="px",
+ test=True, within_element=False,
+ )
+ assert list(result.columns) == ["curv", "length"]
+ assert len(result) == 2
+
+ def test_hairs_shorter_than_half_resolution_are_skipped(self, tmp_path):
+ """With resolution 100 px/mm the minimum is 50 px: the 30 px hair is dropped."""
+ result = analyze_all_curv(
+ _create_two_line_skeleton(long_len=120, short_len=30), "whole_min", tmp_path,
+ resolution=100.0, window_size=None, window_unit="px",
+ test=False, within_element=False,
+ )
+ assert result["hair_count"].iloc[0] == 1
+ assert result["length_mean"].iloc[0] == pytest.approx(1.2)
+
+ def test_writes_per_hair_csv(self, tmp_path):
+ analyze_all_curv(
+ _create_line_skeleton(length=140), "whole_csv", tmp_path,
+ resolution=1.0, window_size=None, window_unit="px",
+ test=False, within_element=False,
+ )
+ saved = pd.read_csv(tmp_path / "analysis" / "ImageSum_whole_csv.csv", index_col=0)
+ assert list(saved.columns) == ["curv", "length"]
+ assert len(saved) == 1
+
+ def test_no_qualifying_hair_returns_empty_summary(self, tmp_path):
+ """An image with no hair above the minimum length must not raise."""
+ result = analyze_all_curv(
+ np.zeros((50, 50), dtype=np.uint8), "whole_none", tmp_path,
+ resolution=100.0, window_size=None, window_unit="px",
+ test=False, within_element=False,
+ )
+ assert result["hair_count"].iloc[0] == 0
+ assert np.isnan(result["curv_mean"].iloc[0])
+
+ def test_window_list_may_mix_none_and_sizes(self, tmp_path):
+ """A list of window sizes can include None (whole hair)."""
+ result = analyze_all_curv(
+ _create_line_skeleton(length=140), "mixed", tmp_path,
+ resolution=1.0, window_size=[None, 30], window_unit="px",
+ test=False, within_element=False,
+ )
+ assert len(result) == 2
diff --git a/fibermorph/test/test_core_filters.py b/fibermorph/test/test_core_filters.py
index dea82c9..49e4254 100644
--- a/fibermorph/test/test_core_filters.py
+++ b/fibermorph/test/test_core_filters.py
@@ -89,5 +89,5 @@ def test_filter_curv_extracts_correct_name(self, tmp_path):
test_img.save(img_path)
filter_img, im_name = filter_curv(img_path, tmp_path, save_img=False)
-
+
assert im_name == "my_special_image"
diff --git a/fibermorph/test/test_core_shape_analysis.py b/fibermorph/test/test_core_shape_analysis.py
new file mode 100644
index 0000000..06b2267
--- /dev/null
+++ b/fibermorph/test/test_core_shape_analysis.py
@@ -0,0 +1,207 @@
+"""Unit tests for core.shape_analysis module."""
+
+import numpy as np
+import pytest
+from skimage import draw as sk_draw
+
+from fibermorph.core.shape_analysis import (
+ compute_efd,
+ compute_radial_profile,
+ extract_features_from_array,
+ classify_shape,
+)
+
+
+def _circle_contour(radius: int = 40, n_pts: int = 200) -> np.ndarray:
+ """Return (n_pts, 2) contour points for a circle."""
+ theta = np.linspace(0, 2 * np.pi, n_pts, endpoint=False)
+ cx = radius * np.cos(theta)
+ cy = radius * np.sin(theta)
+ return np.column_stack([cx, cy])
+
+
+def _circle_mask(size: int = 120, radius: int = 40) -> np.ndarray:
+ """Return a uint8 binary mask with a filled circle."""
+ mask = np.zeros((size, size), dtype=np.uint8)
+ rr, cc = sk_draw.disk((size // 2, size // 2), radius, shape=mask.shape)
+ mask[rr, cc] = 255
+ return mask
+
+
+def _ellipse_mask(size: int = 120, r_row: int = 50, r_col: int = 25) -> np.ndarray:
+ """Return a uint8 binary mask with a filled ellipse."""
+ mask = np.zeros((size, size), dtype=np.uint8)
+ rr, cc = sk_draw.ellipse(size // 2, size // 2, r_row, r_col, shape=mask.shape)
+ mask[rr, cc] = 255
+ return mask
+
+
+# ─────────────────────────────────────────────
+# compute_efd
+# ─────────────────────────────────────────────
+class TestComputeEFD:
+ def test_returns_array_of_correct_shape(self):
+ contour = _circle_contour()
+ efd = compute_efd(contour, n_harmonics=10)
+ assert isinstance(efd["efd_coeffs"], np.ndarray)
+ assert efd["efd_coeffs"].shape == (10, 4)
+
+ def test_different_harmonics(self):
+ contour = _circle_contour()
+ for n in [5, 10, 20]:
+ efd = compute_efd(contour, n_harmonics=n)
+ assert efd["efd_coeffs"].shape[0] == n
+
+ def test_circle_has_dominant_first_harmonic(self):
+ contour = _circle_contour(radius=40, n_pts=360)
+ efd = compute_efd(contour, n_harmonics=10)
+ coeffs = efd["efd_coeffs"]
+ first_mag = np.sqrt(coeffs[0, 0] ** 2 + coeffs[0, 1] ** 2 +
+ coeffs[0, 2] ** 2 + coeffs[0, 3] ** 2)
+ higher_mags = [
+ np.sqrt(coeffs[k, 0] ** 2 + coeffs[k, 1] ** 2 +
+ coeffs[k, 2] ** 2 + coeffs[k, 3] ** 2)
+ for k in range(1, 10)
+ ]
+ assert first_mag > max(higher_mags)
+
+ def test_returns_finite_values(self):
+ contour = _circle_contour()
+ efd = compute_efd(contour, n_harmonics=10)
+ assert np.all(np.isfinite(efd["efd_coeffs"]))
+
+
+# ─────────────────────────────────────────────
+# compute_radial_profile
+# ─────────────────────────────────────────────
+class TestComputeRadialProfile:
+ def test_returns_dict_with_expected_keys(self):
+ mask = _circle_mask()
+ from skimage.measure import regionprops, label
+ labeled = label(mask)
+ props = regionprops(labeled)[0]
+ result = compute_radial_profile(mask, props, n_angles=36, resolution_mu=4.25)
+ expected = {
+ "radial_mean_mu", "radial_std_mu", "radial_min_mu",
+ "radial_max_mu", "radial_cv", "n_radial_peaks", "asymmetry_index",
+ }
+ assert expected.issubset(set(result.keys()))
+
+ def test_circle_has_low_asymmetry(self):
+ mask = _circle_mask(radius=40)
+ from skimage.measure import regionprops, label
+ props = regionprops(label(mask))[0]
+ result = compute_radial_profile(mask, props, n_angles=36, resolution_mu=1.0)
+ assert result["asymmetry_index"] < 0.2
+
+ def test_ellipse_has_higher_radius_range(self):
+ circle_mask = _circle_mask(radius=30)
+ ellipse_m = _ellipse_mask(r_row=50, r_col=20)
+ from skimage.measure import regionprops, label
+ c_props = regionprops(label(circle_mask))[0]
+ e_props = regionprops(label(ellipse_m))[0]
+ c_res = compute_radial_profile(circle_mask, c_props, n_angles=36, resolution_mu=1.0)
+ e_res = compute_radial_profile(ellipse_m, e_props, n_angles=36, resolution_mu=1.0)
+ c_range = c_res["radial_max_mu"] - c_res["radial_min_mu"]
+ e_range = e_res["radial_max_mu"] - e_res["radial_min_mu"]
+ assert e_range > c_range
+
+
+# ─────────────────────────────────────────────
+# extract_features_from_array
+# ─────────────────────────────────────────────
+class TestExtractFeaturesFromArray:
+ def test_returns_dataframe_with_one_row(self):
+ mask = _circle_mask()
+ result = extract_features_from_array(mask, source_name="test_img", resolution_mu=4.25, n_harmonics=10)
+ assert result is not None
+ assert isinstance(result, dict)
+
+ def test_contains_geometric_columns(self):
+ mask = _circle_mask()
+ result = extract_features_from_array(mask, source_name="test_img", resolution_mu=4.25, n_harmonics=10)
+ for col in ["area_mu2", "circularity", "eccentricity", "solidity"]:
+ assert col in result, f"Missing key: {col}"
+
+ def test_contains_efd_columns(self):
+ mask = _circle_mask()
+ result = extract_features_from_array(mask, source_name="test_img", resolution_mu=4.25, n_harmonics=10)
+ efd_keys = [k for k in result if k.startswith("efd_")]
+ # 10 harmonics × 4 coefficients + 10 power values + 1 deviation score
+ assert len(efd_keys) == 51
+
+ def test_contains_hu_moment_columns(self):
+ mask = _circle_mask()
+ result = extract_features_from_array(mask, source_name="test_img", resolution_mu=4.25, n_harmonics=10)
+ hu_keys = [k for k in result if k.startswith("hu")]
+ assert len(hu_keys) == 7
+
+ def test_empty_mask_returns_nan_df(self):
+ mask = np.zeros((50, 50), dtype=np.uint8)
+ result = extract_features_from_array(mask, source_name="empty", resolution_mu=4.25, n_harmonics=10)
+ assert result is None
+
+ def test_circle_has_high_circularity(self):
+ mask = _circle_mask(radius=45)
+ result = extract_features_from_array(mask, source_name="circle", resolution_mu=1.0, n_harmonics=10)
+ assert result["circularity"] > 0.85
+
+
+# ─────────────────────────────────────────────
+# classify_shape
+# ─────────────────────────────────────────────
+class TestClassifyShape:
+ VALID_CLASSES = {
+ "Circular", "Elliptical", "Flattened",
+ "Triangular", "Tear-drop", "Multi-polar", "Irregular",
+ }
+
+ def _circle_features(self):
+ return {
+ "circularity": 0.97,
+ "eccentricity": 0.1,
+ "solidity": 0.98,
+ "aspect_ratio": 1.02,
+ "convexity": 0.99,
+ "n_radial_peaks": 0,
+ "asymmetry_index": 0.02,
+ }
+
+ def _ellipse_features(self):
+ return {
+ "circularity": 0.75,
+ "eccentricity": 0.65,
+ "solidity": 0.97,
+ "aspect_ratio": 1.8,
+ "convexity": 0.97,
+ "n_radial_peaks": 0,
+ "asymmetry_index": 0.05,
+ }
+
+ def _flattened_features(self):
+ return {
+ "circularity": 0.50,
+ "eccentricity": 0.90,
+ "solidity": 0.96,
+ "aspect_ratio": 3.5,
+ "convexity": 0.95,
+ "n_radial_peaks": 0,
+ "asymmetry_index": 0.08,
+ }
+
+ def test_returns_string(self):
+ result = classify_shape(self._circle_features())
+ assert isinstance(result, str)
+
+ def test_valid_class_returned(self):
+ for feat in [self._circle_features(), self._ellipse_features(), self._flattened_features()]:
+ result = classify_shape(feat)
+ assert result in self.VALID_CLASSES, f"Unexpected class: {result}"
+
+ def test_circle_classified_as_circular_or_elliptical(self):
+ result = classify_shape(self._circle_features())
+ assert result in {"Circular", "Elliptical"}
+
+ def test_flattened_classified_correctly(self):
+ result = classify_shape(self._flattened_features())
+ assert result in {"Flattened", "Elliptical"}
diff --git a/fibermorph/test/test_curvature_golden.py b/fibermorph/test/test_curvature_golden.py
new file mode 100644
index 0000000..dd30cf5
--- /dev/null
+++ b/fibermorph/test/test_curvature_golden.py
@@ -0,0 +1,238 @@
+"""Golden tests: curvature results must equal those of the published method.
+
+The reference CSV files in test_data/curv_golden/ were produced by the
+original fibermorph v0.3.1 code (the version behind the published results)
+running under scikit-image 0.16.2; see test_data/curv_golden/README.md for
+exactly how. These tests run the current ``curvature_seq`` and require the
+same numbers, for a window in pixels, a window in mm, and the whole-hair
+mode (``window_size=None``), both per hair (``test=True``) and as image
+summary (``test=False``). ``curvature_seq`` has a single path, the v0.3.1
+method, so there are no analysis options to vary.
+
+* TestSyntheticImageGolden: offline, uses a small synthetic PNG kept in the
+ repository.
+* TestDemoImagesGolden: downloads the two lab demo curvature images (about
+ 6 MB in total, each fetched once per test run) and is skipped when they
+ cannot be downloaded. Set FIBERMORPH_DEMO_CURV_DIR to a folder that already
+ holds the two TIFF files to use them without downloading.
+"""
+
+import os
+import pathlib
+
+import numpy as np
+import pandas as pd
+import pytest
+
+from fibermorph.analysis.curvature_pipeline import curvature_seq
+
+GOLDEN = pathlib.Path(__file__).parent / "test_data" / "curv_golden"
+SYNTHETIC_IMAGE = GOLDEN / "synthetic_curv.png"
+SYNTHETIC_REFERENCE = GOLDEN / "reference_v031"
+DEMO_REFERENCE = GOLDEN / "reference_demo_v031"
+
+RESOLUTION = 132 # pixels per mm, as used for the references
+
+# window text used in the reference file names -> (window_size, window_unit)
+WINDOWS = {
+ "50px": (50, "px"),
+ "none": (None, "px"),
+ "0.5mm": (0.5, "mm"),
+}
+
+RTOL = 1e-9
+ATOL = 1e-12
+
+
+def _read_reference(directory, stem, window_text, kind):
+ path = directory / f"{stem}__window{window_text}__{kind}.csv"
+ return pd.read_csv(path, float_precision="round_trip")
+
+
+def _run(image_path, workdir, window_text, test):
+ window_size, window_unit = WINDOWS[window_text]
+ return curvature_seq(
+ str(image_path),
+ str(workdir),
+ resolution=RESOLUTION,
+ window_size=window_size,
+ window_unit=window_unit,
+ save_img=False,
+ test=test,
+ within_element=False,
+ )
+
+
+def _assert_per_hair_equal(actual, expected):
+ """Same columns, same number of hairs, same numbers, rows in the same order.
+
+ The row order is the order of the labelled hairs (skimage regionprops), which
+ is deterministic and is the order v0.3.1 wrote them in.
+ """
+ actual = pd.DataFrame(actual).reset_index(drop=True)
+ assert list(actual.columns) == list(expected.columns)
+ assert len(actual) == len(expected)
+ np.testing.assert_allclose(
+ actual.to_numpy(float), expected.to_numpy(float), rtol=RTOL, atol=ATOL
+ )
+
+
+def _assert_summary_equal(actual, expected):
+ actual = pd.DataFrame(actual).reset_index(drop=True)
+ assert list(actual.columns) == list(expected.columns)
+ assert len(actual) == len(expected) == 1
+ assert actual["ID"].iloc[0] == expected["ID"].iloc[0]
+ assert actual["hair_count"].iloc[0] == expected["hair_count"].iloc[0]
+ numeric = [c for c in expected.columns if c != "ID"]
+ np.testing.assert_allclose(
+ actual[numeric].to_numpy(float),
+ expected[numeric].to_numpy(float),
+ rtol=RTOL,
+ atol=ATOL,
+ )
+
+
+@pytest.mark.parametrize("window_text", ["50px", "none", "0.5mm"])
+class TestSyntheticImageGolden:
+ """Synthetic image: dark arcs of several radii plus near-straight lines."""
+
+ def test_per_hair_rows_match_v031(self, tmp_path, window_text):
+ expected = _read_reference(SYNTHETIC_REFERENCE, "synthetic_curv", window_text, "perhair")
+ actual = _run(SYNTHETIC_IMAGE, tmp_path, window_text, test=True)
+ _assert_per_hair_equal(actual, expected)
+
+ def test_image_summary_matches_v031(self, tmp_path, window_text):
+ expected = _read_reference(SYNTHETIC_REFERENCE, "synthetic_curv", window_text, "summary")
+ actual = _run(SYNTHETIC_IMAGE, tmp_path, window_text, test=False)
+ _assert_summary_equal(actual, expected)
+
+
+class TestValidationCurvCallPath:
+ """fibermorph.demo.demo.validation_curv calls curvature_seq(test=True) and
+ reads the per-hair columns ``length`` and ``curv_median``."""
+
+ def test_test_true_returns_per_hair_rows_with_columns_validation_uses(self, tmp_path):
+ result = _run(SYNTHETIC_IMAGE, tmp_path, "50px", test=True)
+
+ assert isinstance(result, pd.DataFrame)
+ assert {"length", "curv_median"} <= set(result.columns)
+ assert "hair_count" not in result.columns # not the image summary
+ assert len(result) > 1 # one row per hair
+
+ # the same operations validation_curv performs on the result
+ per_hair = pd.DataFrame(result).sort_values(by=["length"], ignore_index=True)
+ per_hair["radius"] = 1 / per_hair["curv_median"]
+ assert per_hair["length"].is_monotonic_increasing
+
+ def test_test_false_returns_image_summary(self, tmp_path):
+ result = _run(SYNTHETIC_IMAGE, tmp_path, "50px", test=False)
+ assert len(result) == 1
+ assert "hair_count" in result.columns
+
+
+# ---------------------------------------------------------------------------
+# Online golden test on the lab's demo images
+# ---------------------------------------------------------------------------
+
+DEMO_BASE_URL = (
+ "https://github.com/tinalasisi/fibermorph_DemoData/raw/master/test_input/curv/"
+)
+DEMO_IMAGES = {
+ "004_demo_curv": "004_demo_curv.tiff",
+ "027_demo_nocurv": "027_demo_nocurv.tiff",
+}
+_TIFF_MAGIC = (b"II*\x00", b"MM\x00*")
+
+
+def _fetch_demo_image(file_name, dest_dir, base_url=DEMO_BASE_URL):
+ """Return a local path to a demo image, downloading it if necessary.
+
+ Uses FIBERMORPH_DEMO_CURV_DIR when that folder already holds the file, and
+ a copy already downloaded into dest_dir by an earlier call (so each image
+ is fetched once per test run). Skips the calling test if the file cannot
+ be downloaded or is not a TIFF.
+ """
+ local_dir = os.environ.get("FIBERMORPH_DEMO_CURV_DIR")
+ if local_dir and (pathlib.Path(local_dir) / file_name).is_file():
+ return pathlib.Path(local_dir) / file_name
+
+ path = pathlib.Path(dest_dir) / file_name
+ if path.is_file():
+ return path
+
+ import requests
+
+ try:
+ response = requests.get(base_url + file_name, timeout=30)
+ response.raise_for_status()
+ except requests.RequestException as exc:
+ pytest.skip(f"could not download {file_name}: {exc}")
+ if not response.content.startswith(_TIFF_MAGIC):
+ pytest.skip(f"downloaded {file_name} is not a TIFF file")
+
+ path.write_bytes(response.content)
+ return path
+
+
+@pytest.fixture(scope="module")
+def demo_dir(tmp_path_factory):
+ return tmp_path_factory.mktemp("demo_curv")
+
+
+class TestDownloadHelper:
+ def test_downloaded_file_is_reused_not_fetched_again(self, tmp_path, monkeypatch):
+ import requests
+
+ monkeypatch.delenv("FIBERMORPH_DEMO_CURV_DIR", raising=False)
+ calls = []
+
+ class FakeResponse:
+ content = b"II*\x00 fake tiff bytes"
+
+ def raise_for_status(self):
+ pass
+
+ def fake_get(url, timeout):
+ calls.append(url)
+ return FakeResponse()
+
+ monkeypatch.setattr(requests, "get", fake_get)
+
+ first = _fetch_demo_image("y.tiff", tmp_path, base_url="http://example.invalid/")
+ second = _fetch_demo_image("y.tiff", tmp_path, base_url="http://example.invalid/")
+
+ assert first == second == tmp_path / "y.tiff"
+ assert calls == ["http://example.invalid/y.tiff"] # one download, not two
+
+ def test_unreachable_server_skips(self, tmp_path):
+ with pytest.raises(pytest.skip.Exception):
+ _fetch_demo_image("none.tiff", tmp_path, base_url="http://127.0.0.1:9/")
+
+ def test_local_folder_is_used_without_download(self, tmp_path, monkeypatch):
+ local_dir = tmp_path / "local"
+ dest_dir = tmp_path / "dest"
+ local_dir.mkdir()
+ dest_dir.mkdir()
+ (local_dir / "x.tiff").write_bytes(b"II*\x00")
+ monkeypatch.setenv("FIBERMORPH_DEMO_CURV_DIR", str(local_dir))
+ assert _fetch_demo_image("x.tiff", dest_dir, base_url="http://127.0.0.1:9/") == (
+ local_dir / "x.tiff"
+ )
+
+
+@pytest.mark.parametrize("stem", sorted(DEMO_IMAGES))
+@pytest.mark.parametrize("window_text", ["50px", "none"])
+class TestDemoImagesGolden:
+ """Curly (004) and straight (027) demo hair images at 132 px/mm."""
+
+ def test_per_hair_rows_match_v031(self, tmp_path, demo_dir, stem, window_text):
+ image = _fetch_demo_image(DEMO_IMAGES[stem], demo_dir)
+ expected = _read_reference(DEMO_REFERENCE, stem, window_text, "perhair")
+ actual = _run(image, tmp_path, window_text, test=True)
+ _assert_per_hair_equal(actual, expected)
+
+ def test_image_summary_matches_v031(self, tmp_path, demo_dir, stem, window_text):
+ image = _fetch_demo_image(DEMO_IMAGES[stem], demo_dir)
+ expected = _read_reference(DEMO_REFERENCE, stem, window_text, "summary")
+ actual = _run(image, tmp_path, window_text, test=False)
+ _assert_summary_equal(actual, expected)
diff --git a/fibermorph/test/test_curvature_pipeline.py b/fibermorph/test/test_curvature_pipeline.py
new file mode 100644
index 0000000..17e2518
--- /dev/null
+++ b/fibermorph/test/test_curvature_pipeline.py
@@ -0,0 +1,108 @@
+"""Unit tests for analysis.curvature_pipeline."""
+
+import os
+
+import numpy as np
+import pytest
+from PIL import Image
+from skimage import draw as sk_draw
+
+
+def _make_curv_tiff(tmp_path, fname: str = "test_curv.tiff",
+ size: int = 200, amplitude: int = 30) -> str:
+ """Create a synthetic curvature TIFF with a wavy dark line on bright background."""
+ img = np.ones((size, size), dtype=np.uint8) * 230
+ for x in range(size):
+ y = int(size // 2 + amplitude * np.sin(2 * np.pi * 3 * x / size))
+ y = np.clip(y, 2, size - 3)
+ img[y - 2:y + 2, x] = 20
+ path = os.path.join(str(tmp_path), fname)
+ Image.fromarray(img, mode="L").save(path)
+ return path
+
+
+class TestCurvatureSeq:
+ """Tests for curvature_seq."""
+
+ def test_returns_dataframe(self, tmp_path):
+ from fibermorph.analysis.curvature_pipeline import curvature_seq
+ img_path = _make_curv_tiff(tmp_path)
+ result = curvature_seq(
+ img_path, str(tmp_path),
+ resolution=132, window_size=None, window_unit="px",
+ save_img=False, test=False, within_element=False,
+ )
+ import pandas as pd
+ assert result is not None
+ assert isinstance(result, pd.DataFrame)
+
+ def test_dataframe_has_curvature_columns(self, tmp_path):
+ from fibermorph.analysis.curvature_pipeline import curvature_seq
+ img_path = _make_curv_tiff(tmp_path)
+ df = curvature_seq(
+ img_path, str(tmp_path),
+ resolution=132, window_size=None, window_unit="px",
+ save_img=False, test=False, within_element=False,
+ )
+ if df is not None and not df.empty:
+ assert any(c in df.columns for c in ["curv_mean", "mean", "median"])
+
+ def test_no_crash_on_blank_image(self, tmp_path):
+ from fibermorph.analysis.curvature_pipeline import curvature_seq
+ blank = np.ones((100, 100), dtype=np.uint8) * 200
+ img_path = os.path.join(str(tmp_path), "blank.tiff")
+ Image.fromarray(blank, mode="L").save(img_path)
+ import pandas as pd
+ result = curvature_seq(
+ img_path, str(tmp_path),
+ resolution=132, window_size=None, window_unit="px",
+ save_img=False, test=False, within_element=False,
+ )
+ assert result is None or isinstance(result, pd.DataFrame)
+
+
+REMOVED_PARAMETERS = ("use_clahe", "extended_curvature")
+
+
+class TestRemovedOptions:
+ """CLAHE preprocessing and extended curvature are no longer options."""
+
+ @pytest.mark.parametrize("dotted", [
+ "fibermorph.analysis.curvature_pipeline.curvature_seq",
+ "fibermorph.workflows.curvature",
+ "fibermorph.workflows.batch",
+ "fibermorph.pipeline.batch.run_batch",
+ ])
+ def test_functions_have_no_removed_parameters(self, dotted):
+ import importlib
+ import inspect
+ module_name, func_name = dotted.rsplit(".", 1)
+ func = getattr(importlib.import_module(module_name), func_name)
+ parameters = inspect.signature(func).parameters
+ for name in REMOVED_PARAMETERS:
+ assert name not in parameters
+
+ @pytest.mark.parametrize("name", REMOVED_PARAMETERS)
+ def test_curvature_seq_rejects_removed_parameters(self, tmp_path, name):
+ from fibermorph.analysis.curvature_pipeline import curvature_seq
+ img_path = _make_curv_tiff(tmp_path)
+ with pytest.raises(TypeError):
+ curvature_seq(
+ img_path, str(tmp_path),
+ resolution=132, window_size=None, window_unit="px",
+ save_img=False, test=False, within_element=False,
+ **{name: True},
+ )
+
+ @pytest.mark.parametrize("module_name, func_name", [
+ ("fibermorph.core.filters", "filter_curv_clahe"),
+ ("fibermorph.core.curvature", "curl_index_from_skeleton"),
+ ("fibermorph.core.curvature", "wave_count"),
+ ("fibermorph.core.curvature", "pixel_length_correction_coords"),
+ ])
+ def test_removed_functions_are_gone(self, module_name, func_name):
+ import importlib
+ import fibermorph
+ assert not hasattr(importlib.import_module(module_name), func_name)
+ assert not hasattr(fibermorph, func_name)
+ assert func_name not in fibermorph.__all__
diff --git a/fibermorph/test/test_data/curv_golden/README.md b/fibermorph/test/test_data/curv_golden/README.md
new file mode 100644
index 0000000..356aad9
--- /dev/null
+++ b/fibermorph/test/test_data/curv_golden/README.md
@@ -0,0 +1,99 @@
+# Golden curvature references (original fibermorph v0.3.1)
+
+The CSV files here hold the curvature output of the **original published
+fibermorph method** (git tag `v0.3.1`, commit `c5aa520`, 21 Nov 2020). The tests
+in `fibermorph/test/test_curvature_golden.py` run the current code on the same
+images and require the same numbers (relative tolerance 1e-9). They guard against
+the curvature results drifting again, for example because a newer scikit-image
+changes `frangi()`.
+
+## Contents
+
+| Path | What it is |
+|---|---|
+| `synthetic_curv.png` | 1000 x 1000 8-bit synthetic image: dark arcs of several radii, a near-straight bowed line, a straight line, and one short arc. About 400 KB. |
+| `make_synthetic_curv_image.py` | Draws `synthetic_curv.png` (numpy + Pillow only). |
+| `reference_v031/` | v0.3.1 output for `synthetic_curv.png`: windows `50px`, `none` (whole hair) and `0.5mm`. |
+| `reference_demo_v031/` | v0.3.1 output for the two lab demo images `004_demo_curv.tiff` (curly) and `027_demo_nocurv.tiff` (straight): windows `50px` and `none`. The TIFFs are **not** committed. |
+| `make_curv_reference.py` | Produces the CSV files by running the unmodified v0.3.1 code. |
+
+File names: `__window__perhair.csv` (one row per hair, from
+`curvature_seq(..., test=True)`) and `__window__summary.csv`
+(one row per image, from `test=False`). `` is `50px`, `none` or `0.5mm`.
+Resolution is 132 px/mm in all cases. Columns:
+
+* window sizes, per hair: `curv_mean, curv_median, length`
+* window sizes, summary: `ID, curv_mean_mean, curv_mean_median, curv_median_mean, curv_median_median, length_mean, length_median, hair_count`
+* whole hair (`none`), per hair: `curv, length`
+* whole hair (`none`), summary: `ID, curv_mean, curv_median, length_mean, length_median, hair_count`
+
+## How the references were made
+
+Environment (scikit-image and scipy are the versions pinned by v0.3.1's
+`environment.yml`; the rest are the closest builds that install on current
+hardware, run as x86_64 under Rosetta):
+
+* Python 3.8.20
+* scikit-image 0.16.2
+* numpy 1.18.5
+* scipy 1.4.1
+* pandas 1.0.5
+* Pillow 7.2.0
+
+(`environment.yml` of v0.3.1 pins Python 3.6.10, numpy 1.18.1, pandas 1.0.1 and
+Pillow 7.0.0. The current code, on Python 3.12, numpy 2.5, scipy 1.18,
+pandas 2.3 and scikit-image 0.26, reproduces these files to about 1e-14
+relative, so the small differences in numpy, pandas and Pillow versions do not
+matter.)
+
+1. Export the two v0.3.1 source files (the tag must exist locally; run
+ `git fetch --tags` if it does not):
+
+ ```
+ mkdir -p /tmp/fibermorph_v031
+ git show v0.3.1:fibermorph/fibermorph.py > /tmp/fibermorph_v031/fibermorph.py
+ git show v0.3.1:fibermorph/_version.py > /tmp/fibermorph_v031/_version.py
+ ```
+
+2. Run the script with the paper-era Python, from this directory:
+
+ ```
+ # synthetic image
+ python make_curv_reference.py --v031-dir /tmp/fibermorph_v031 \
+ --image synthetic_curv.png --out-dir reference_v031 \
+ --windows 50px,none,0.5mm
+
+ # lab demo images (download them first, see below)
+ python make_curv_reference.py --v031-dir /tmp/fibermorph_v031 \
+ --image 004_demo_curv.tiff --out-dir reference_demo_v031 --windows 50px,none
+ python make_curv_reference.py --v031-dir /tmp/fibermorph_v031 \
+ --image 027_demo_nocurv.tiff --out-dir reference_demo_v031 --windows 50px,none
+ ```
+
+ On Apple silicon prefix the Python command with `arch -x86_64`. The script
+ loads v0.3.1's `fibermorph.py` unmodified (stubbing only the unused `rawpy`
+ and `demo` imports) and calls its `curvature_seq` with `test=True` and
+ `test=False`. It refuses to run unless scikit-image is 0.16.2.
+
+The PNG was checked to decode to identical pixels under the paper-era
+environment (Pillow 7.2.0) and a current one (Pillow 12.3.0); sha256 of the
+decoded array: `2d1a7e9a6d452654f1b439dd281f0c8ad9b763923146e85e89fed81c09992cec`.
+
+## Demo images
+
+`004_demo_curv.tiff` and `027_demo_nocurv.tiff` come from the lab's public demo
+data: `https://github.com/tinalasisi/fibermorph_DemoData/raw/master/test_input/curv/`.
+The online tests download them when they run (about 6 MB in total, each image
+once per test run) and skip if the download fails. To run without downloading,
+put the two files in a folder and set `FIBERMORPH_DEMO_CURV_DIR` to that folder.
+
+## Notes
+
+* `curvature_seq` has a single path, the v0.3.1 method, so the references
+ cover everything it can produce; there are no analysis options to vary.
+* A hair counts for a window setting only if its skeleton is longer than the
+ window (px) or 0.5 (mm), and for the whole-hair mode only if longer than
+ 0.5 x resolution pixels; this is why the number of rows differs between
+ settings for the same image.
+* If the references must be regenerated (they should not change), review the
+ diff: any change means the analysis, not the references, moved.
diff --git a/fibermorph/test/test_data/curv_golden/make_curv_reference.py b/fibermorph/test/test_data/curv_golden/make_curv_reference.py
new file mode 100644
index 0000000..e67d1ef
--- /dev/null
+++ b/fibermorph/test/test_data/curv_golden/make_curv_reference.py
@@ -0,0 +1,111 @@
+"""Make golden curvature references with the ORIGINAL fibermorph v0.3.1 code.
+
+This script runs the curvature analysis of the published method (git tag
+v0.3.1, November 2020) and saves its output as CSV files. The tests in
+fibermorph/test/test_curvature_golden.py compare the current fibermorph code
+against these files. Run it inside the paper-era Python environment, NOT the
+environment fibermorph is developed in:
+
+ Python 3.8.20, scikit-image 0.16.2, numpy 1.18.5, scipy 1.4.1,
+ pandas 1.0.5, Pillow 7.2.0
+ (scikit-image and scipy are the versions pinned by v0.3.1's
+ environment.yml; the rest are the closest builds that install today,
+ see README.md)
+
+The script loads fibermorph.py and _version.py of the v0.3.1 tag unmodified
+and calls v0.3.1's ``curvature_seq`` twice per window setting: once with
+test=True (one row per hair) and once with test=False (image summary), at
+132 px/mm. Export the two files from the repository first (the tag must
+exist locally; run ``git fetch --tags`` if it does not):
+
+ mkdir -p /tmp/fibermorph_v031
+ git show v0.3.1:fibermorph/fibermorph.py > /tmp/fibermorph_v031/fibermorph.py
+ git show v0.3.1:fibermorph/_version.py > /tmp/fibermorph_v031/_version.py
+
+Then, with the paper-era Python (on Apple silicon: ``arch -x86_64 /bin/python``):
+
+ python make_curv_reference.py --v031-dir /tmp/fibermorph_v031 \
+ --image synthetic_curv.png --out-dir reference_v031 \
+ --windows 50px,none,0.5mm
+
+``--windows`` is a comma-separated list; ``50px`` is a 50 pixel window,
+``0.5mm`` a 0.5 mm window (converted with the resolution), ``none`` the
+whole-hair mode. Output files are named
+``__window__perhair.csv`` and ``...__summary.csv``;
+```` is the window text as given (``50px``, ``none``, ``0.5mm``).
+"""
+import argparse
+import importlib.util
+import os
+import sys
+import tempfile
+import types
+
+import numpy as np
+import pandas as pd
+import skimage
+
+RESOLUTION = 132 # pixels per mm
+
+
+def load_v031(src_dir):
+ """Import fibermorph.py from src_dir (with _version.py beside it). rawpy
+ (RAW images) and demo (demo-data download code) are not used by
+ curvature_seq, so they are stubbed."""
+ sys.modules.setdefault("rawpy", types.ModuleType("rawpy"))
+ sys.modules.setdefault("demo", types.ModuleType("demo"))
+ spec = importlib.util.spec_from_file_location(
+ "fibermorph_v031", os.path.join(src_dir, "fibermorph.py")
+ )
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def parse_window(text):
+ """'50px' -> (50, 'px'); '0.5mm' -> (0.5, 'mm'); 'none' -> (None, 'px')."""
+ if text == "none":
+ return None, "px"
+ if text.endswith("px"):
+ return int(text[:-2]), "px"
+ if text.endswith("mm"):
+ return float(text[:-2]), "mm"
+ raise ValueError("window must look like 50px, 0.5mm or none, got " + repr(text))
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
+ parser.add_argument("--v031-dir", required=True,
+ help="folder holding fibermorph.py and _version.py from tag v0.3.1")
+ parser.add_argument("--image", required=True)
+ parser.add_argument("--out-dir", required=True)
+ parser.add_argument("--windows", default="50px,none,0.5mm")
+ args = parser.parse_args()
+
+ if skimage.__version__ != "0.16.2":
+ raise SystemExit("Needs scikit-image 0.16.2, found " + skimage.__version__)
+
+ os.makedirs(args.out_dir, exist_ok=True)
+ stem = os.path.splitext(os.path.basename(args.image))[0]
+ fm = load_v031(args.v031_dir)
+
+ for text in args.windows.split(","):
+ window, unit = parse_window(text)
+ for test, kind in ((True, "perhair"), (False, "summary")):
+ with tempfile.TemporaryDirectory() as work_dir:
+ df = fm.curvature_seq(
+ args.image, work_dir, RESOLUTION, window, unit,
+ False, test, False,
+ )
+ out = os.path.join(
+ args.out_dir, "{}__window{}__{}.csv".format(stem, text, kind)
+ )
+ pd.DataFrame(df).to_csv(out, index=False)
+ print("wrote", out, "(%d rows)" % len(df))
+
+ print("python", sys.version.split()[0], "| scikit-image", skimage.__version__,
+ "| numpy", np.__version__, "| pandas", pd.__version__)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/fibermorph/test/test_data/curv_golden/make_synthetic_curv_image.py b/fibermorph/test/test_data/curv_golden/make_synthetic_curv_image.py
new file mode 100644
index 0000000..e2e8462
--- /dev/null
+++ b/fibermorph/test/test_data/curv_golden/make_synthetic_curv_image.py
@@ -0,0 +1,71 @@
+"""Draw the synthetic curvature image used by the golden tests.
+
+Writes synthetic_curv.png next to this script: a 1000 x 1000 8-bit grayscale
+image with dark hair-like ridges on a light, slightly noisy background. It has
+dark arcs of several radii, a near-straight line, and one short arc that ends
+up shorter than the whole-hair minimum length. The PNG itself is the committed
+fixture; this script documents how it was drawn and needs only numpy and
+Pillow:
+
+ python make_synthetic_curv_image.py
+"""
+import os
+
+import numpy as np
+from PIL import Image
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+SIZE = 1000
+
+
+def arc_darkness(rows, cols, center_r, center_c, radius, start_deg, end_deg, sigma, peak):
+ """Gaussian-profile dark ridge along a circular arc (angles in degrees)."""
+ dr = rows - center_r
+ dc = cols - center_c
+ dist = np.abs(np.hypot(dr, dc) - radius)
+ ang = np.degrees(np.arctan2(dr, dc)) % 360.0
+ start, end = start_deg % 360.0, end_deg % 360.0
+ if start <= end:
+ inside = (ang >= start) & (ang <= end)
+ else:
+ inside = (ang >= start) | (ang <= end)
+ return np.where(inside, peak * np.exp(-(dist ** 2) / (2 * sigma ** 2)), 0.0)
+
+
+def line_darkness(rows, cols, r0, c0, r1, c1, sigma, peak):
+ """Gaussian-profile dark ridge along a straight segment."""
+ vr, vc = r1 - r0, c1 - c0
+ length_sq = vr * vr + vc * vc
+ t = np.clip(((rows - r0) * vr + (cols - c0) * vc) / length_sq, 0.0, 1.0)
+ dist = np.hypot(rows - (r0 + t * vr), cols - (c0 + t * vc))
+ return peak * np.exp(-(dist ** 2) / (2 * sigma ** 2))
+
+
+def make_image():
+ rows, cols = np.mgrid[0:SIZE, 0:SIZE].astype(float)
+ dark = np.zeros((SIZE, SIZE))
+
+ # (center_row, center_col, radius_px, start_deg, end_deg)
+ arcs = [
+ (250, 250, 100, 20, 300), # tight curl, radius 100 px
+ (250, 700, 160, 200, 340), # radius 160 px
+ (780, 300, 260, 250, 330), # gentle arc, radius 260 px
+ (900, 800, 60, 100, 170), # short arc (about 63 px of skeleton)
+ ]
+ for cr, cc, rad, a0, a1 in arcs:
+ dark = np.maximum(dark, arc_darkness(rows, cols, cr, cc, rad, a0, a1, 2.2, 170.0))
+
+ # near-straight line with a very slight bow (very large radius)
+ dark = np.maximum(dark, arc_darkness(rows, cols, 5600, 500, 4800, 266, 274, 2.2, 170.0))
+ # straight line
+ dark = np.maximum(dark, line_darkness(rows, cols, 560, 600, 600, 960, 2.2, 170.0))
+
+ rng = np.random.RandomState(31)
+ img = 228.0 - dark + rng.normal(0.0, 1.5, size=dark.shape)
+ return np.clip(np.round(img), 0, 255).astype(np.uint8)
+
+
+if __name__ == "__main__":
+ Image.fromarray(make_image(), mode="L").save(
+ os.path.join(HERE, "synthetic_curv.png"), optimize=True
+ )
diff --git a/fibermorph/test/test_data/curv_golden/reference_demo_v031/004_demo_curv__window50px__perhair.csv b/fibermorph/test/test_data/curv_golden/reference_demo_v031/004_demo_curv__window50px__perhair.csv
new file mode 100644
index 0000000..1c3e62d
--- /dev/null
+++ b/fibermorph/test/test_data/curv_golden/reference_demo_v031/004_demo_curv__window50px__perhair.csv
@@ -0,0 +1,10 @@
+curv_mean,curv_median,length
+3.474367605225834,2.742696098488582,3.0736583486533853
+0.8631765674379729,0.8642645116999796,4.613840279501597
+0.9335871550235584,1.0077010166728444,2.7396870362965333
+0.6955765745705457,0.698569542042326,2.283476572324225
+0.7342444640948589,0.7349099533809959,1.4234002954614713
+0.8285836928679337,0.8539085171562336,1.560020149686058
+0.7926286577200705,0.7942501109157596,1.4673299165955136
+0.9052990196789388,0.8815490705251272,3.23230327741159
+0.7435320342115467,0.7246217635780016,3.650741752972217
diff --git a/fibermorph/test/test_data/curv_golden/reference_demo_v031/004_demo_curv__window50px__summary.csv b/fibermorph/test/test_data/curv_golden/reference_demo_v031/004_demo_curv__window50px__summary.csv
new file mode 100644
index 0000000..de05b7d
--- /dev/null
+++ b/fibermorph/test/test_data/curv_golden/reference_demo_v031/004_demo_curv__window50px__summary.csv
@@ -0,0 +1,2 @@
+ID,curv_mean_mean,curv_mean_median,curv_median_mean,curv_median_median,length_mean,length_median,hair_count
+004_demo_curv_WindowSize-50px,1.1078884189812512,0.8285836928679337,1.033607842717761,0.8539085171562336,2.6716064032113986,2.7396870362965333,9
diff --git a/fibermorph/test/test_data/curv_golden/reference_demo_v031/004_demo_curv__windownone__perhair.csv b/fibermorph/test/test_data/curv_golden/reference_demo_v031/004_demo_curv__windownone__perhair.csv
new file mode 100644
index 0000000..d493745
--- /dev/null
+++ b/fibermorph/test/test_data/curv_golden/reference_demo_v031/004_demo_curv__windownone__perhair.csv
@@ -0,0 +1,10 @@
+curv,length
+0.22239042775096624,3.0736583486533853
+0.863232837217565,4.613840279501597
+0.9085852494811749,2.7396870362965333
+0.7803103797925268,2.283476572324225
+0.7169783249381673,1.4234002954614713
+0.824088572858989,1.560020149686058
+0.8037168932727701,1.4673299165955136
+0.8609160948741408,3.23230327741159
+1.1368275138364636,3.650741752972217
diff --git a/fibermorph/test/test_data/curv_golden/reference_demo_v031/004_demo_curv__windownone__summary.csv b/fibermorph/test/test_data/curv_golden/reference_demo_v031/004_demo_curv__windownone__summary.csv
new file mode 100644
index 0000000..6ba2fee
--- /dev/null
+++ b/fibermorph/test/test_data/curv_golden/reference_demo_v031/004_demo_curv__windownone__summary.csv
@@ -0,0 +1,2 @@
+ID,curv_mean,curv_median,length_mean,length_median,hair_count
+004_demo_curv,0.7907829215580848,0.824088572858989,2.6716064032113986,2.7396870362965333,9
diff --git a/fibermorph/test/test_data/curv_golden/reference_demo_v031/027_demo_nocurv__window50px__perhair.csv b/fibermorph/test/test_data/curv_golden/reference_demo_v031/027_demo_nocurv__window50px__perhair.csv
new file mode 100644
index 0000000..4af2f89
--- /dev/null
+++ b/fibermorph/test/test_data/curv_golden/reference_demo_v031/027_demo_nocurv__window50px__perhair.csv
@@ -0,0 +1,9 @@
+curv_mean,curv_median,length
+0.040517227129386116,0.03233838092165113,2.4929557696485207
+0.060369250464781546,0.049620301276213655,4.154655910084023
+0.05847471330398745,0.04081926564052053,3.0672206616076116
+0.0400099917736424,0.03601144280231727,2.010623756295334
+0.03687162561671792,0.03225003270747157,4.224904086123743
+0.04091402328701903,0.03794579347685433,4.053333805404923
+0.06354846920243315,0.039695350524595954,2.0079744126514067
+0.07975082456456545,0.0757254475937459,1.4455300877621895
diff --git a/fibermorph/test/test_data/curv_golden/reference_demo_v031/027_demo_nocurv__window50px__summary.csv b/fibermorph/test/test_data/curv_golden/reference_demo_v031/027_demo_nocurv__window50px__summary.csv
new file mode 100644
index 0000000..8b0d0cf
--- /dev/null
+++ b/fibermorph/test/test_data/curv_golden/reference_demo_v031/027_demo_nocurv__window50px__summary.csv
@@ -0,0 +1,2 @@
+ID,curv_mean_mean,curv_mean_median,curv_median_mean,curv_median_median,length_mean,length_median,hair_count
+027_demo_nocurv_WindowSize-50px,0.052557015667816634,0.049694368295503236,0.04305075186792129,0.03882057200072514,2.932149811197219,2.780088215628066,8
diff --git a/fibermorph/test/test_data/curv_golden/reference_demo_v031/027_demo_nocurv__windownone__perhair.csv b/fibermorph/test/test_data/curv_golden/reference_demo_v031/027_demo_nocurv__windownone__perhair.csv
new file mode 100644
index 0000000..4951809
--- /dev/null
+++ b/fibermorph/test/test_data/curv_golden/reference_demo_v031/027_demo_nocurv__windownone__perhair.csv
@@ -0,0 +1,9 @@
+curv,length
+0.012915375758998572,2.4929557696485207
+0.036392549438354176,4.154655910084023
+0.04711700573548241,3.0672206616076116
+0.010541227242094778,2.010623756295334
+0.013525949222477292,4.224904086123743
+0.03124190486260756,4.053333805404923
+0.02765329534949601,2.0079744126514067
+0.003403030263383712,1.4455300877621895
diff --git a/fibermorph/test/test_data/curv_golden/reference_demo_v031/027_demo_nocurv__windownone__summary.csv b/fibermorph/test/test_data/curv_golden/reference_demo_v031/027_demo_nocurv__windownone__summary.csv
new file mode 100644
index 0000000..ecaa8cd
--- /dev/null
+++ b/fibermorph/test/test_data/curv_golden/reference_demo_v031/027_demo_nocurv__windownone__summary.csv
@@ -0,0 +1,2 @@
+ID,curv_mean,curv_median,length_mean,length_median,hair_count
+027_demo_nocurv,0.022848792234111813,0.020589622285986653,2.932149811197219,2.780088215628066,8
diff --git a/fibermorph/test/test_data/curv_golden/reference_v031/synthetic_curv__window0.5mm__perhair.csv b/fibermorph/test/test_data/curv_golden/reference_v031/synthetic_curv__window0.5mm__perhair.csv
new file mode 100644
index 0000000..8e196cf
--- /dev/null
+++ b/fibermorph/test/test_data/curv_golden/reference_v031/synthetic_curv__window0.5mm__perhair.csv
@@ -0,0 +1,6 @@
+curv_mean,curv_median,length
+0.8205840819969016,0.825664650860454,2.9179544259988344
+1.3289072283171477,1.3234017031041438,3.656363152139981
+0.49814004629731373,0.4991541284546681,2.7202139164867525
+0.01544165059162725,0.010121968504697359,2.8063842356059965
+0.014184004436611324,0.018009816651656445,5.0732244807575375
diff --git a/fibermorph/test/test_data/curv_golden/reference_v031/synthetic_curv__window0.5mm__summary.csv b/fibermorph/test/test_data/curv_golden/reference_v031/synthetic_curv__window0.5mm__summary.csv
new file mode 100644
index 0000000..7045e49
--- /dev/null
+++ b/fibermorph/test/test_data/curv_golden/reference_v031/synthetic_curv__window0.5mm__summary.csv
@@ -0,0 +1,2 @@
+ID,curv_mean_mean,curv_mean_median,curv_median_mean,curv_median_median,length_mean,length_median,hair_count
+synthetic_curv_WindowSize-0.5mm,0.5354514023279203,0.49814004629731373,0.5352704535151239,0.4991541284546681,3.4348280421978203,2.9179544259988344,5
diff --git a/fibermorph/test/test_data/curv_golden/reference_v031/synthetic_curv__window50px__perhair.csv b/fibermorph/test/test_data/curv_golden/reference_v031/synthetic_curv__window50px__perhair.csv
new file mode 100644
index 0000000..a0fdbb8
--- /dev/null
+++ b/fibermorph/test/test_data/curv_golden/reference_v031/synthetic_curv__window50px__perhair.csv
@@ -0,0 +1,7 @@
+curv_mean,curv_median,length
+0.8131582546214139,0.8246796045965996,2.9179544259988344
+1.3205272966923989,1.3211923576332658,3.656363152139981
+0.4913775046043126,0.4947618259753923,2.7202139164867525
+0.03453196413534772,0.0344745979596473,2.8063842356059965
+0.010373399428981259,0.01261424959801756,5.0732244807575375
+2.144426954580048,2.150481201672405,0.49856023627344026
diff --git a/fibermorph/test/test_data/curv_golden/reference_v031/synthetic_curv__window50px__summary.csv b/fibermorph/test/test_data/curv_golden/reference_v031/synthetic_curv__window50px__summary.csv
new file mode 100644
index 0000000..03ea8f4
--- /dev/null
+++ b/fibermorph/test/test_data/curv_golden/reference_v031/synthetic_curv__window50px__summary.csv
@@ -0,0 +1,2 @@
+ID,curv_mean_mean,curv_mean_median,curv_median_mean,curv_median_median,length_mean,length_median,hair_count
+synthetic_curv_WindowSize-50px,0.8023992290104172,0.6522678796128633,0.8063673062392214,0.659720715285996,2.945450074543757,2.8621693308024154,6
diff --git a/fibermorph/test/test_data/curv_golden/reference_v031/synthetic_curv__windownone__perhair.csv b/fibermorph/test/test_data/curv_golden/reference_v031/synthetic_curv__windownone__perhair.csv
new file mode 100644
index 0000000..ecf3792
--- /dev/null
+++ b/fibermorph/test/test_data/curv_golden/reference_v031/synthetic_curv__windownone__perhair.csv
@@ -0,0 +1,6 @@
+curv,length
+0.8268879144356351,2.9179544259988344
+1.3252738492605634,3.656363152139981
+0.5075467875054087,2.7202139164867525
+0.0005014709908752712,2.8063842356059965
+0.02769414970286297,5.0732244807575375
diff --git a/fibermorph/test/test_data/curv_golden/reference_v031/synthetic_curv__windownone__summary.csv b/fibermorph/test/test_data/curv_golden/reference_v031/synthetic_curv__windownone__summary.csv
new file mode 100644
index 0000000..2f25a79
--- /dev/null
+++ b/fibermorph/test/test_data/curv_golden/reference_v031/synthetic_curv__windownone__summary.csv
@@ -0,0 +1,2 @@
+ID,curv_mean,curv_median,length_mean,length_median,hair_count
+synthetic_curv,0.537580834379069,0.5075467875054087,3.4348280421978203,2.9179544259988344,5
diff --git a/fibermorph/test/test_data/curv_golden/synthetic_curv.png b/fibermorph/test/test_data/curv_golden/synthetic_curv.png
new file mode 100644
index 0000000..6f3c697
Binary files /dev/null and b/fibermorph/test/test_data/curv_golden/synthetic_curv.png differ
diff --git a/fibermorph/test/test_data/frangi_v016/README.md b/fibermorph/test/test_data/frangi_v016/README.md
new file mode 100644
index 0000000..1fc3c9b
--- /dev/null
+++ b/fibermorph/test/test_data/frangi_v016/README.md
@@ -0,0 +1,28 @@
+# Reference data for the frozen Frangi filter
+
+`fibermorph/core/frangi_v016.py` reproduces `skimage.filters.frangi` as it was in
+scikit-image 0.16.2 (the version behind the published fibermorph results).
+`fibermorph/test/test_frangi_v016.py` checks it against the arrays in this folder.
+
+| File | Content |
+|---|---|
+| `input_uint8.npy` | 64 x 96 8-bit test image: a dark arc cut by the border, a diagonal line, a short thick segment, mild noise (seeded). |
+| `ref_default.npy` | `skimage.filters.frangi(input_uint8)` |
+| `ref_float64.npy` | `skimage.filters.frangi(input_uint8 / 255.0)` |
+| `ref_white_sigmas.npy` | `skimage.filters.frangi(input_uint8, sigmas=[1, 2, 3.5], black_ridges=False)` |
+| `make_frangi_reference.py` | Writes all of the above. |
+
+## How the references were made
+
+With scikit-image **0.16.2** (Python 3.8.20, numpy 1.18.5, scipy 1.4.1), run as
+x86_64 on Apple silicon:
+
+```
+cd fibermorph/test/test_data/frangi_v016
+arch -x86_64 /bin/python make_frangi_reference.py
+```
+
+The script refuses to run with any other scikit-image version. The installed
+scikit-image of a current environment gives a different result for the same
+input (largest difference about 0.85 against a reference maximum of 1.9e-4),
+which is what the test detects.
diff --git a/fibermorph/test/test_data/frangi_v016/input_uint8.npy b/fibermorph/test/test_data/frangi_v016/input_uint8.npy
new file mode 100644
index 0000000..38cea29
Binary files /dev/null and b/fibermorph/test/test_data/frangi_v016/input_uint8.npy differ
diff --git a/fibermorph/test/test_data/frangi_v016/make_frangi_reference.py b/fibermorph/test/test_data/frangi_v016/make_frangi_reference.py
new file mode 100644
index 0000000..f80ddfb
--- /dev/null
+++ b/fibermorph/test/test_data/frangi_v016/make_frangi_reference.py
@@ -0,0 +1,68 @@
+"""Generate the reference data for fibermorph/test/test_frangi_v016.py.
+
+Run this with scikit-image 0.16.2 (the version used by the published fibermorph
+v0.3.1 method), NOT with the version installed for fibermorph itself:
+
+ arch -x86_64 /bin/python make_frangi_reference.py
+
+The paper-era environment used for the committed files was Python 3.8.20,
+scikit-image 0.16.2, numpy 1.18.5, scipy 1.4.1. The script refuses to run with
+any other scikit-image version.
+
+It writes, next to this script:
+ input_uint8.npy deterministic 8-bit test image (64 x 96)
+ ref_default.npy skimage.filters.frangi(input_uint8)
+ ref_float64.npy skimage.filters.frangi(input_uint8 / 255.0)
+ ref_white_sigmas.npy skimage.filters.frangi(input_uint8, sigmas=[1, 2, 3.5],
+ black_ridges=False)
+"""
+import os
+
+import numpy as np
+import skimage
+import skimage.filters
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+
+
+def make_input():
+ """Light background with dark ridges: a curved arc cut by the border, a
+ straight diagonal line, a short thick segment, and mild noise."""
+ rows, cols = np.mgrid[0:64, 0:96].astype(float)
+ darkness = np.zeros((64, 96))
+
+ # arc of a circle centered near the left border, radius 30
+ dist_circle = np.abs(np.hypot(rows - 30.0, cols - 8.0) - 30.0)
+ darkness += 170.0 * np.exp(-(dist_circle ** 2) / (2 * 1.5 ** 2))
+
+ # near-straight diagonal line (distance from the line x - 2y + 10 = 0)
+ dist_line = np.abs(cols - 2.0 * rows + 10.0) / np.sqrt(5.0)
+ darkness += 150.0 * np.exp(-(dist_line ** 2) / (2 * 1.2 ** 2))
+
+ # short, thicker segment touching the bottom border
+ dist_seg = np.abs(cols - 80.0)
+ darkness += np.where(rows > 40, 120.0 * np.exp(-(dist_seg ** 2) / (2 * 2.5 ** 2)), 0.0)
+
+ rng = np.random.RandomState(20201117)
+ img = 215.0 - darkness + rng.normal(0.0, 3.0, size=darkness.shape)
+ return np.clip(np.round(img), 0, 255).astype(np.uint8)
+
+
+def main():
+ if skimage.__version__ != "0.16.2":
+ raise SystemExit(f"Needs scikit-image 0.16.2, found {skimage.__version__}")
+
+ img = make_input()
+ np.save(os.path.join(HERE, "input_uint8.npy"), img)
+ np.save(os.path.join(HERE, "ref_default.npy"), skimage.filters.frangi(img))
+ np.save(os.path.join(HERE, "ref_float64.npy"), skimage.filters.frangi(img / 255.0))
+ np.save(
+ os.path.join(HERE, "ref_white_sigmas.npy"),
+ skimage.filters.frangi(img, sigmas=[1, 2, 3.5], black_ridges=False),
+ )
+ print("scikit-image", skimage.__version__, "numpy", np.__version__)
+ print("input", img.shape, img.dtype, "min/max", img.min(), img.max())
+
+
+if __name__ == "__main__":
+ main()
diff --git a/fibermorph/test/test_data/frangi_v016/ref_default.npy b/fibermorph/test/test_data/frangi_v016/ref_default.npy
new file mode 100644
index 0000000..6a0c70a
Binary files /dev/null and b/fibermorph/test/test_data/frangi_v016/ref_default.npy differ
diff --git a/fibermorph/test/test_data/frangi_v016/ref_float64.npy b/fibermorph/test/test_data/frangi_v016/ref_float64.npy
new file mode 100644
index 0000000..13455ba
Binary files /dev/null and b/fibermorph/test/test_data/frangi_v016/ref_float64.npy differ
diff --git a/fibermorph/test/test_data/frangi_v016/ref_white_sigmas.npy b/fibermorph/test/test_data/frangi_v016/ref_white_sigmas.npy
new file mode 100644
index 0000000..3597789
Binary files /dev/null and b/fibermorph/test/test_data/frangi_v016/ref_white_sigmas.npy differ
diff --git a/fibermorph/test/test_data/sections_real_20251022/.gitignore b/fibermorph/test/test_data/sections_real_20251022/.gitignore
new file mode 100644
index 0000000..c78d56b
--- /dev/null
+++ b/fibermorph/test/test_data/sections_real_20251022/.gitignore
@@ -0,0 +1,5 @@
+# Keep the ~10 MB scans local by default — do not bloat the repo/CI with them.
+# Force-add if you decide they should be committed: git add -f *.tif
+*.tif
+*.tiff
+!README.md
diff --git a/fibermorph/test/test_data/sections_real_20251022/README.md b/fibermorph/test/test_data/sections_real_20251022/README.md
new file mode 100644
index 0000000..99f232b
--- /dev/null
+++ b/fibermorph/test/test_data/sections_real_20251022/README.md
@@ -0,0 +1,44 @@
+# Real fiber cross-section test scans (2025-10-22 batch)
+
+Two representative scans from the lab's cross-section imaging batch, kept here to
+verify segmentation and measurement on real data (not synthetic fixtures).
+
+## Calibration
+
+- **Resolution: 0.18 µm/pixel.**
+- In fibermorph, `resolution_mu` is **pixels per µm**, so use
+ **`resolution_mu = 1 / 0.18 ≈ 5.556`** (not the 4.25 default, which is
+ Abhiraj's AFREU value and does not apply to these `12.5mag` scans).
+- Scale-free metrics (eccentricity, solidity, circularity, aspect ratio, shape
+ class) are calibration-independent; only the µm/µm² columns depend on it.
+
+## What these files are
+
+- Source: `.../YemkoPryor_Hub/Hair Weights/Microscope_Images/20251022/`
+- Originals: 3648 × 5472, **RGB, uint8, ~60 MB each** (uncompressed 20 MP TIFF).
+- Here: converted to **grayscale + zlib-compressed TIFF, ~10 MB each**.
+ - Pixel dimensions are **unchanged**, so the 0.18 µm/pixel calibration is
+ preserved exactly — these are shrunk in file size only, not resampled.
+ - fibermorph reads grayscale anyway (`cv2.imread(..., IMREAD_GRAYSCALE)`), so
+ dropping the redundant RGB channels is lossless for this pipeline.
+- `.tif` extension is required — `utils.filesystem.list_images` only accepts
+ `.tif`/`.tiff`.
+
+## Why shrink at all
+
+60 MB × a full batch is slow to upload to the Streamlit app and wasteful to
+process. Grayscale + compression removes ~6× the bytes with **no** effect on
+pixels or calibration. (If even smaller files are needed, the images can be
+downsampled — but that scales the resolution: a 2× downsample makes it
+0.36 µm/pixel, i.e. `resolution_mu ≈ 2.78`.)
+
+## How to run
+
+Point the standalone runner at this folder (currently in the session scratchpad):
+
+```
+python run_sections.py --resolution-mu 5.556
+```
+
+Outputs a `section_measurements.csv`, a QC overlay per image, and (with
+`--compress`) grayscale copies.
diff --git a/fibermorph/test/test_frangi_v016.py b/fibermorph/test/test_frangi_v016.py
new file mode 100644
index 0000000..ff3d70d
--- /dev/null
+++ b/fibermorph/test/test_frangi_v016.py
@@ -0,0 +1,109 @@
+"""Tests for the frozen scikit-image 0.16.2 Frangi filter (core.frangi_v016).
+
+The reference arrays were produced by the real scikit-image 0.16.2 (see
+test_data/frangi_v016/make_frangi_reference.py), so these tests fail if the
+installed scikit-image's frangi (whose output differs) is used instead.
+"""
+
+import pathlib
+
+import numpy as np
+import pytest
+from PIL import Image
+
+from fibermorph.core.filters import filter_curv
+from fibermorph.core.frangi_v016 import frangi_v016
+
+DATA = pathlib.Path(__file__).parent / "test_data" / "frangi_v016"
+
+
+@pytest.fixture(scope="module")
+def input_uint8():
+ return np.load(DATA / "input_uint8.npy")
+
+
+def _assert_matches(out, ref):
+ """Tight match: rtol 1e-9, atol scaled to the reference maximum."""
+ assert out.shape == ref.shape
+ np.testing.assert_allclose(out, ref, rtol=1e-9, atol=1e-9 * ref.max())
+
+
+class TestFrangiV016Reference:
+ def test_default_settings_uint8(self, input_uint8):
+ ref = np.load(DATA / "ref_default.npy")
+ _assert_matches(frangi_v016(input_uint8), ref)
+
+ def test_float_input(self, input_uint8):
+ ref = np.load(DATA / "ref_float64.npy")
+ _assert_matches(frangi_v016(input_uint8 / 255.0), ref)
+
+ def test_white_ridges_and_custom_sigmas(self, input_uint8):
+ ref = np.load(DATA / "ref_white_sigmas.npy")
+ out = frangi_v016(input_uint8, sigmas=[1, 2, 3.5], black_ridges=False)
+ _assert_matches(out, ref)
+
+ def test_reference_is_not_trivial(self):
+ ref = np.load(DATA / "ref_default.npy")
+ assert ref.max() > 0
+ assert np.count_nonzero(ref) > 100
+
+ def test_output_is_float64_same_shape(self, input_uint8):
+ out = frangi_v016(input_uint8)
+ assert out.dtype == np.float64
+ assert out.shape == input_uint8.shape
+
+ def test_rejects_non_2d_input(self):
+ with pytest.raises(ValueError):
+ frangi_v016(np.zeros((4, 4, 4), dtype=np.uint8))
+
+ def test_rejects_negative_sigma(self, input_uint8):
+ with pytest.raises(ValueError):
+ frangi_v016(input_uint8, sigmas=[-1, 2])
+
+
+class TestInstalledHelpers:
+ """frangi_v016 uses skimage.util.img_as_float and invert from the installed
+ scikit-image. These two helpers must keep the behavior they had in 0.16.2
+ (identical arrays in 0.16.2 and 0.26 were checked when this test was written)."""
+
+ def test_img_as_float_of_uint8_is_x_times_one_over_255_float64(self):
+ from skimage.util import img_as_float
+
+ x = np.arange(256, dtype=np.uint8)
+ out = img_as_float(x)
+ assert out.dtype == np.float64
+ np.testing.assert_array_equal(out, x * (1.0 / 255))
+
+ def test_img_as_float_leaves_float64_unchanged(self):
+ from skimage.util import img_as_float
+
+ x = np.linspace(0.0, 1.0, 101)
+ out = img_as_float(x)
+ assert out.dtype == np.float64
+ np.testing.assert_array_equal(out, x)
+
+ def test_invert_of_uint8_is_255_minus_x(self):
+ from skimage.util import invert
+
+ x = np.arange(256, dtype=np.uint8)
+ out = invert(x)
+ assert out.dtype == np.uint8
+ np.testing.assert_array_equal(out, 255 - x)
+
+ def test_invert_of_float64_is_one_minus_x(self):
+ from skimage.util import invert
+
+ x = np.linspace(0.0, 1.0, 101)
+ np.testing.assert_array_equal(invert(x), 1.0 - x)
+
+
+class TestFilterCurvUsesFrozenFilter:
+ def test_filter_curv_output_equals_frozen_filter(self, input_uint8, tmp_path):
+ """filter_curv must return the frozen filter's output for the image it reads."""
+ img_path = tmp_path / "ridge_input.png"
+ Image.fromarray(input_uint8, mode="L").save(img_path)
+
+ filter_img, im_name = filter_curv(img_path, tmp_path, save_img=False)
+
+ assert im_name == "ridge_input"
+ _assert_matches(filter_img, np.load(DATA / "ref_default.npy"))
diff --git a/fibermorph/test/test_gui_app.py b/fibermorph/test/test_gui_app.py
new file mode 100644
index 0000000..4230c9d
--- /dev/null
+++ b/fibermorph/test/test_gui_app.py
@@ -0,0 +1,552 @@
+"""Streamlit AppTest checks for hosted vs local mode in fibermorph/gui/app.py.
+
+Hosted mode (FIBERMORPH_LOCAL unset) must not offer anything that touches the
+server's disk: no "Folder on disk" input, no SAM2 checkpoint path box, and the
+server's SAM2 checkpoint path is not shown anywhere (the Run Remote view's
+checkpoint box, which only fills in an SBATCH script, starts empty). Uploads
+must be measured separately even when file names repeat, and must report the
+uploaded file names, not the names of the temporary files. The Run Local view
+must tell people to start local mode with `fibermorph-gui --local`.
+"""
+
+import io
+from pathlib import Path
+
+import numpy as np
+import pytest
+from PIL import Image
+from skimage import draw as sk_draw
+
+pytest.importorskip("streamlit")
+from streamlit.testing.v1 import AppTest # noqa: E402
+
+import fibermorph.gui # noqa: E402
+
+APP_PATH = str(Path(fibermorph.gui.__file__).parent / "app.py")
+CKPT_LABEL = "SAM2 checkpoint path"
+
+needs_upload_support = pytest.mark.skipif(
+ not hasattr(AppTest, "file_uploader"),
+ reason="this Streamlit version's AppTest cannot simulate file uploads",
+)
+
+
+def _run_app(monkeypatch, local, view="section", env=None, sam2_ok=None):
+ """Run the app once in hosted or local mode and return the AppTest.
+
+ sam2_ok=True/False pretends SAM2 can/cannot run (package + CUDA GPU);
+ None leaves the real check in place.
+ """
+ if sam2_ok is not None:
+ from fibermorph.processing import section_sam2
+ status = (True, "") if sam2_ok else (False, "no CUDA GPU is available")
+ monkeypatch.setattr(section_sam2, "sam2_runtime_status", lambda: status)
+ if local:
+ monkeypatch.setenv("FIBERMORPH_LOCAL", "1")
+ else:
+ monkeypatch.delenv("FIBERMORPH_LOCAL", raising=False)
+ for name, value in (env or {}).items():
+ if value is None:
+ monkeypatch.delenv(name, raising=False)
+ else:
+ monkeypatch.setenv(name, value)
+ at = AppTest.from_file(APP_PATH, default_timeout=120)
+ at.session_state["active_view"] = view
+ at.run()
+ assert not at.exception, [e.value for e in at.exception]
+ return at
+
+
+def _labels(elements):
+ return [e.label for e in elements]
+
+
+def _click(at, key):
+ [b for b in at.button if b.key == key][0].click()
+ at.run()
+ assert not at.exception, [e.value for e in at.exception]
+
+
+def _rendered_text(at):
+ """Every string the app put on the page, as a list.
+
+ Walks all elements, including widget labels, help, placeholders and the
+ initial value of text boxes (via each element's protobuf), plus the current
+ value of each widget. Used to check that something appears nowhere.
+ """
+ out = []
+
+ def walk(node):
+ for child in getattr(node, "children", {}).values():
+ proto = getattr(child, "proto", None)
+ if proto is not None:
+ out.append(str(proto))
+ value = getattr(child, "value", None)
+ if value is not None:
+ out.append(str(value))
+ walk(child)
+
+ walk(at.main)
+ walk(at.sidebar)
+ return out
+
+
+# ---------------------------------------------------------------------------
+# Hosted vs local: folder input
+# ---------------------------------------------------------------------------
+
+@pytest.mark.parametrize("view", ["section", "curvature"])
+def test_hosted_has_no_folder_on_disk_option(monkeypatch, view):
+ at = _run_app(monkeypatch, local=False, view=view)
+ assert "Input source" not in _labels(at.radio)
+ assert not [t for t in at.text_input if t.label.startswith("Folder containing")]
+ assert len(at.file_uploader) == 1
+
+
+@pytest.mark.parametrize("view", ["section", "curvature"])
+def test_local_offers_folder_on_disk_option(monkeypatch, view):
+ at = _run_app(monkeypatch, local=True, view=view)
+ radios = [r for r in at.radio if r.label == "Input source"]
+ assert len(radios) == 1
+ assert "Folder on disk" in radios[0].options
+ radios[0].set_value("Folder on disk")
+ at.run()
+ assert [t for t in at.text_input if t.label.startswith("Folder containing")]
+
+
+# ---------------------------------------------------------------------------
+# Hosted vs local: SAM2 checkpoint path
+# ---------------------------------------------------------------------------
+
+def test_hosted_has_no_sam2_checkpoint_text_input(monkeypatch):
+ at = _run_app(monkeypatch, local=False)
+ assert CKPT_LABEL not in _labels(at.text_input)
+
+
+def test_local_has_sam2_checkpoint_text_input(monkeypatch, tmp_path):
+ ckpt = tmp_path / "model.pt"
+ ckpt.write_bytes(b"x")
+ at = _run_app(monkeypatch, local=True, env={"SAM2_CHECKPOINT": str(ckpt)},
+ sam2_ok=True)
+ boxes = [t for t in at.text_input if t.label == CKPT_LABEL]
+ assert len(boxes) == 1
+ assert boxes[0].value == str(ckpt)
+
+
+def test_hosted_caption_says_checkpoint_is_found_without_showing_path(monkeypatch, tmp_path):
+ ckpt = tmp_path / "private_model_dir" / "model.pt"
+ ckpt.parent.mkdir()
+ ckpt.write_bytes(b"x")
+ at = _run_app(monkeypatch, local=False, env={"SAM2_CHECKPOINT": str(ckpt)},
+ sam2_ok=True)
+ captions = [c.value for c in at.caption if c.value.startswith("SAM2 checkpoint")]
+ assert len(captions) == 1
+ assert "file found on this server" in captions[0]
+ assert "no file found" not in captions[0]
+ assert not [t for t in _rendered_text(at) if "private_model_dir" in t]
+
+
+def test_hosted_caption_says_when_no_checkpoint_file_is_found(monkeypatch, tmp_path):
+ missing = tmp_path / "private_model_dir" / "absent.pt"
+ at = _run_app(monkeypatch, local=False, env={"SAM2_CHECKPOINT": str(missing)},
+ sam2_ok=True)
+ captions = [c.value for c in at.caption if c.value.startswith("SAM2 checkpoint")]
+ assert len(captions) == 1
+ assert "no file found" in captions[0]
+ assert "watershed" in captions[0]
+ assert "private_model_dir" not in captions[0]
+
+
+# ---------------------------------------------------------------------------
+# SAM2 unavailable (e.g. the Streamlit-hosted app has no GPU): the toggle is
+# locked off, a one-line reason is shown, and Run Local / Run Remote are named
+# as the ways to run SAM2.
+# ---------------------------------------------------------------------------
+
+SAM2_TOGGLE = "Use SAM2 segmentation (GPU required)"
+
+
+def _sam2_toggle(at):
+ [t] = [t for t in at.toggle if t.label == SAM2_TOGGLE]
+ return t
+
+
+def _sam2_warnings(at):
+ return [w.value for w in at.warning if "SAM2 is unavailable" in w.value]
+
+
+@pytest.mark.parametrize("local, where", [(False, "this hosted app"), (True, "this machine")],
+ ids=["hosted", "local"])
+def test_sam2_unavailable_locks_toggle_and_says_why(monkeypatch, local, where):
+ at = _run_app(monkeypatch, local=local, sam2_ok=False)
+ assert _sam2_toggle(at).proto.disabled
+ assert _sam2_toggle(at).value is False
+ [msg] = _sam2_warnings(at)
+ assert f"SAM2 is unavailable on {where}" in msg
+ assert "no CUDA GPU is available" in msg
+ assert "Run Local" in msg and "fibermorph-gui --local" in msg and "Run Remote" in msg
+ # The hosted checkpoint caption is pointless when SAM2 can't run at all.
+ assert not [c for c in at.caption if c.value.startswith("SAM2 checkpoint")]
+ assert CKPT_LABEL not in _labels(at.text_input)
+
+
+def test_sam2_available_leaves_toggle_enabled_without_warning(monkeypatch):
+ at = _run_app(monkeypatch, local=False, sam2_ok=True)
+ assert not _sam2_toggle(at).proto.disabled
+ assert not _sam2_warnings(at)
+
+
+def test_real_sam2_check_without_gpu_marks_sam2_unavailable(monkeypatch):
+ # Test machines have no CUDA GPU (and usually no sam2 package), so the real
+ # check must report SAM2 as unavailable on the hosted app.
+ from fibermorph.processing import section_sam2
+ ok, reason = section_sam2.sam2_runtime_status()
+ if ok:
+ pytest.skip("this machine can run SAM2")
+ at = _run_app(monkeypatch, local=False)
+ assert _sam2_toggle(at).proto.disabled
+ [msg] = _sam2_warnings(at)
+ assert reason in msg
+
+
+def test_run_remote_keeps_sam2_toggle_when_app_cannot_run_sam2(monkeypatch):
+ # SAM2 in Run Remote only goes into the cluster script, so it stays usable.
+ at = _run_app(monkeypatch, local=False, view="remote", sam2_ok=False)
+ [t] = [t for t in at.toggle if t.label.startswith("Enable SAM2")]
+ assert not t.proto.disabled
+ assert [c for c in at.caption if "SAM2 runs on the cluster's GPU node" in c.value]
+
+
+def test_run_local_says_where_sam2_runs(monkeypatch):
+ at = _run_app(monkeypatch, local=False, view="local")
+ text = " ".join(m.value for m in at.markdown)
+ assert "SAM2 segmentation** does not run on the hosted app" in text
+ assert "SAM2_CHECKPOINT=" in text and "fibermorph-gui --local" in text
+
+
+@pytest.mark.parametrize("view", ["section", "curvature", "local", "remote"])
+@pytest.mark.parametrize("exists", [True, False], ids=["checkpoint-present", "checkpoint-missing"])
+def test_hosted_views_never_show_the_server_checkpoint_path(monkeypatch, tmp_path, view, exists):
+ ckpt = tmp_path / "private_model_dir" / "model.pt"
+ if exists:
+ ckpt.parent.mkdir()
+ ckpt.write_bytes(b"x")
+ at = _run_app(monkeypatch, local=False, view=view, env={"SAM2_CHECKPOINT": str(ckpt)})
+ shown = _rendered_text(at)
+ assert shown
+ assert not [t for t in shown if "private_model_dir" in t]
+
+
+def test_rendered_text_helper_does_see_the_path_in_local_mode(monkeypatch, tmp_path):
+ # Guards the test above against passing only because the helper sees nothing.
+ ckpt = tmp_path / "private_model_dir" / "model.pt"
+ for view in ("section", "remote"):
+ at = _run_app(monkeypatch, local=True, view=view, env={"SAM2_CHECKPOINT": str(ckpt)},
+ sam2_ok=True)
+ assert [t for t in _rendered_text(at) if "private_model_dir" in t]
+
+
+def _remote_checkpoint_box(at):
+ boxes = [t for t in at.text_input if t.label == CKPT_LABEL]
+ assert len(boxes) == 1
+ return boxes[0]
+
+
+def _generate_script(at, section_dir="/data/sections"):
+ at.text_input(key="section_path").set_value(section_dir)
+ [t for t in at.toggle if t.label.startswith("Enable SAM2")][0].set_value(True)
+ at.run()
+ _click(at, "gen_sbatch")
+ return at.session_state["sbatch_script"]
+
+
+def test_hosted_remote_checkpoint_box_starts_empty_with_a_placeholder(monkeypatch, tmp_path):
+ ckpt = tmp_path / "private_model_dir" / "model.pt"
+ at = _run_app(monkeypatch, local=False, view="remote", env={"SAM2_CHECKPOINT": str(ckpt)})
+ box = _remote_checkpoint_box(at)
+ assert box.value == ""
+ assert box.placeholder.startswith("/path/to/")
+
+
+def test_local_remote_checkpoint_box_is_prefilled(monkeypatch, tmp_path):
+ ckpt = tmp_path / "model.pt"
+ at = _run_app(monkeypatch, local=True, view="remote", env={"SAM2_CHECKPOINT": str(ckpt)})
+ assert _remote_checkpoint_box(at).value == str(ckpt)
+
+
+def test_hosted_script_uses_placeholder_for_blank_checkpoint(monkeypatch, tmp_path):
+ ckpt = tmp_path / "private_model_dir" / "model.pt"
+ at = _run_app(monkeypatch, local=False, view="remote", env={"SAM2_CHECKPOINT": str(ckpt)})
+ script = _generate_script(at)
+ assert "--use-sam2" in script
+ assert "--sam2-checkpoint 'YOUR_SAM2_CHECKPOINT'" in script
+ assert "private_model_dir" not in script
+ assert not [t for t in _rendered_text(at) if "private_model_dir" in t]
+
+
+@pytest.mark.parametrize("local", [False, True], ids=["hosted", "local"])
+def test_script_uses_the_checkpoint_typed_for_the_cluster(monkeypatch, tmp_path, local):
+ at = _run_app(monkeypatch, local=local, view="remote",
+ env={"SAM2_CHECKPOINT": str(tmp_path / "private_model_dir" / "model.pt")})
+ _remote_checkpoint_box(at).set_value("/cluster/models/sam2.pt")
+ script = _generate_script(at)
+ assert "--sam2-checkpoint '/cluster/models/sam2.pt'" in script
+ assert "private_model_dir" not in script
+
+
+# ---------------------------------------------------------------------------
+# Curvature: only the published method is offered
+# ---------------------------------------------------------------------------
+
+@pytest.mark.parametrize("view", ["curvature", "remote"])
+def test_curvature_settings_offer_only_the_published_method(monkeypatch, view):
+ at = _run_app(monkeypatch, local=False, view=view)
+ labels = _labels(at.toggle)
+ for removed in ("CLAHE", "xtended curvature", "xperimental"):
+ assert not [label for label in labels if removed in label]
+ # Cross-section extended features are a separate option and stay.
+ if view == "remote":
+ assert [label for label in labels if label.startswith("Extended features")]
+
+
+@pytest.mark.parametrize("local", [False, True], ids=["hosted", "local"])
+def test_generated_curvature_script_has_no_removed_flags(monkeypatch, local):
+ at = _run_app(monkeypatch, local=local, view="remote")
+ at.text_input(key="curv_path").set_value("/data/curvature")
+ at.run()
+ _click(at, "gen_sbatch")
+ script = at.session_state["sbatch_script"]
+ assert "fibermorph --curvature" in script
+ assert "--window_size 50" in script
+ assert "--use-clahe" not in script
+ assert "--extended-curvature" not in script
+
+
+# ---------------------------------------------------------------------------
+# Which checkpoint reaches segment_section
+# ---------------------------------------------------------------------------
+
+def _section_png(radius, size=500):
+ img = np.full((size, size), 220, dtype=np.uint8)
+ rr, cc = sk_draw.disk((size // 2, size // 2), radius, shape=img.shape)
+ img[rr, cc] = 30
+ buf = io.BytesIO()
+ Image.fromarray(img, mode="L").save(buf, format="PNG")
+ return buf.getvalue()
+
+
+@pytest.fixture
+def seen_checkpoints(monkeypatch):
+ """Record the checkpoint= argument of every segment_section call."""
+ from fibermorph.processing import section_sam2
+
+ real = section_sam2.segment_section
+ seen = []
+
+ def spy(*args, **kwargs):
+ seen.append(kwargs.get("checkpoint"))
+ return real(*args, **kwargs)
+
+ monkeypatch.setattr(section_sam2, "segment_section", spy)
+ return seen
+
+
+@needs_upload_support
+def test_hosted_ignores_a_client_supplied_checkpoint(monkeypatch, tmp_path, seen_checkpoints):
+ server_ckpt = str(tmp_path / "server_model.pt")
+ at = _run_app(monkeypatch, local=False, env={"SAM2_CHECKPOINT": server_ckpt})
+ # A client that sends widget state for the (non-existent) checkpoint box
+ # must not be able to choose what the server loads.
+ at.session_state["sec_ckpt"] = "/attacker/chosen/model.pt"
+ at.file_uploader[0].set_value(("one.png", _section_png(100), "image/png"))
+ at.run()
+ _click(at, "sec_run")
+ assert seen_checkpoints == [server_ckpt]
+
+
+@needs_upload_support
+def test_local_uses_the_checkpoint_typed_in_the_box(monkeypatch, tmp_path, seen_checkpoints):
+ at = _run_app(monkeypatch, local=True, env={"SAM2_CHECKPOINT": str(tmp_path / "default.pt")},
+ sam2_ok=True)
+ chosen = str(tmp_path / "my_model.pt")
+ [t for t in at.text_input if t.label == CKPT_LABEL][0].set_value(chosen)
+ at.file_uploader[0].set_value(("one.png", _section_png(100), "image/png"))
+ at.run()
+ _click(at, "sec_run")
+ assert seen_checkpoints == [chosen]
+
+
+# ---------------------------------------------------------------------------
+# Uploads: hostile names, duplicate names, and reported file names
+# ---------------------------------------------------------------------------
+
+@needs_upload_support
+def test_section_duplicate_uploads_are_measured_separately_and_named_as_uploaded(
+ monkeypatch, tmp_path):
+ escape = tmp_path / "escaped.png"
+ at = _run_app(monkeypatch, local=False)
+ at.file_uploader[0].set_value([
+ ("sample.png", _section_png(100), "image/png"),
+ ("sample.png", _section_png(130), "image/png"),
+ (str(escape), _section_png(110), "image/png"),
+ ("..\\..\\win\\path.png", _section_png(120), "image/png"),
+ ])
+ at.run()
+ _click(at, "sec_run")
+
+ df = at.session_state["section_results"]
+ assert list(df["source_file"]) == ["sample.png", "sample.png", "escaped.png", "path.png"]
+ # Every column that carries a file name shows the uploaded name, never
+ # the generated temp name (upload_0001.png ...).
+ assert list(df["ID"]) == list(df["source_file"])
+ assert list(df["mask_filename"]) == list(df["source_file"])
+ assert "upload_" not in df.to_csv(index=False)
+ # The two "sample.png" uploads are different images and give different areas.
+ assert df["area_mu2"].iloc[0] != pytest.approx(df["area_mu2"].iloc[1], rel=0.05)
+ assert [name for name, _, _ in at.session_state["seg_store"]] == list(df["source_file"])
+ assert not escape.exists()
+ assert any("Duplicate filenames" in w.value and "sample.png" in w.value for w in at.warning)
+
+
+@needs_upload_support
+def test_curvature_duplicate_uploads_are_measured_separately_and_named_as_uploaded(
+ monkeypatch):
+ def fiber_png(amplitude, size=800):
+ img = np.full((size, size), 230, dtype=np.uint8)
+ xs = np.arange(40, size - 40)
+ ys = (size // 2 + amplitude * np.sin(2 * np.pi * 2 * xs / size)).astype(int)
+ for x, y in zip(xs, ys):
+ img[y - 2:y + 3, x] = 20
+ buf = io.BytesIO()
+ Image.fromarray(img, mode="L").save(buf, format="PNG")
+ return buf.getvalue()
+
+ at = _run_app(monkeypatch, local=False, view="curvature")
+ at.file_uploader[0].set_value([
+ ("fiber.png", fiber_png(20), "image/png"),
+ ("../../fiber.png", fiber_png(80), "image/png"),
+ ])
+ at.run()
+ _click(at, "curv_run")
+
+ frags = at.session_state["curvature_fragments"]
+ summ = at.session_state["curvature_summary"]
+ assert list(frags["source_file"]) == ["fiber.png", "fiber.png"]
+ assert list(summ["source_file"]) == ["fiber.png", "fiber.png"]
+ # Guards the shape of the summary: only the published measurements, no
+ # curl index / wave count columns. That the extended toggle is gone is
+ # checked by test_curvature_settings_offer_only_the_published_method.
+ assert list(summ.columns) == ["source_file", "n_fragments", "curv_mean",
+ "curv_median", "length_mean", "length_median",
+ "length_total"]
+ assert "upload_" not in frags.to_csv(index=False)
+ assert "upload_" not in summ.to_csv(index=False)
+ # Two different images, so two different measurements (not the second one twice).
+ assert frags["length"].iloc[0] != pytest.approx(frags["length"].iloc[1], rel=0.01)
+ assert summ["length_mean"].iloc[0] != pytest.approx(summ["length_mean"].iloc[1], rel=0.01)
+ assert any("Duplicate filenames" in w.value for w in at.warning)
+
+
+@needs_upload_support
+def test_unreadable_upload_error_names_the_uploaded_file(monkeypatch):
+ at = _run_app(monkeypatch, local=False, view="curvature")
+ at.file_uploader[0].set_value([("my scan.tif", b"not an image", "image/tiff")])
+ at.run()
+ _click(at, "curv_run")
+ messages = [w.value for w in at.warning]
+ assert any(m.startswith("my scan.tif: ") for m in messages), messages
+ assert not any("upload_" in m for m in messages), messages
+
+
+# ---------------------------------------------------------------------------
+# User-facing text
+# ---------------------------------------------------------------------------
+
+@pytest.mark.parametrize("local", [False, True])
+def test_run_local_view_says_to_use_local_flag(monkeypatch, local):
+ at = _run_app(monkeypatch, local=local, view="local")
+ text = "\n".join(m.value for m in at.markdown)
+ assert "fibermorph-gui --local" in text
+ # No instruction still tells people to run bare `fibermorph-gui` for local mode.
+ for line in text.splitlines():
+ if line.strip() == "fibermorph-gui":
+ pytest.fail("Run Local view still shows bare `fibermorph-gui`")
+
+
+def test_run_local_view_status_hosted_vs_local(monkeypatch):
+ hosted = _run_app(monkeypatch, local=False, view="local")
+ assert not hosted.success
+ assert any("--local" in i.value for i in hosted.info)
+ local = _run_app(monkeypatch, local=True, view="local")
+ assert any("running locally" in s.value for s in local.success)
+
+
+# ---------------------------------------------------------------------------
+# The upload cap shown in the app is the cap Streamlit enforces
+# ---------------------------------------------------------------------------
+
+@pytest.fixture
+def set_upload_cap():
+ """Set Streamlit's server.maxUploadSize (in MB) for one test, then restore it."""
+ from streamlit import config
+ original = config.get_option("server.maxUploadSize")
+
+ def setter(megabytes):
+ config.set_option("server.maxUploadSize", megabytes)
+
+ yield setter
+ config.set_option("server.maxUploadSize", original)
+
+
+@pytest.mark.parametrize("megabytes, shown", [
+ (500, "500 MB"), (2000, "2 GB"), (750, "750 MB"),
+])
+def test_hosted_text_follows_the_configured_upload_cap(monkeypatch, set_upload_cap, megabytes, shown):
+ set_upload_cap(megabytes)
+ at = _run_app(monkeypatch, local=False, view="local")
+ footer = [m.value for m in at.sidebar.markdown if "fm-foot__status" in m.value]
+ assert len(footer) == 1 and f"Hosted · {shown} upload cap" in footer[0]
+ body = "\n".join(m.value for m in at.markdown)
+ assert f"caps uploads ({shown} here)" in body
+ assert any(f"upload-only, {shown}" in i.value for i in at.info)
+ others = {"500 MB", "2 GB", "750 MB", "5 GB"} - {shown}
+ page = "\n".join(_rendered_text(at))
+ assert not [o for o in others if o in page], [o for o in others if o in page]
+
+
+@pytest.mark.parametrize("megabytes, shown", [
+ (5000, "5 GB"), (2000, "2 GB"), (300, "300 MB"),
+])
+def test_local_text_follows_the_configured_upload_cap(monkeypatch, set_upload_cap, megabytes, shown):
+ set_upload_cap(megabytes)
+ at = _run_app(monkeypatch, local=True, view="local")
+ footer = [m.value for m in at.sidebar.markdown if "fm-foot__status" in m.value]
+ assert len(footer) == 1 and f"Local · {shown} cap · folder input" in footer[0]
+ assert any(f"capped at {shown}" in s.value for s in at.success)
+ others = {"500 MB", "2 GB", "300 MB", "5 GB"} - {shown}
+ page = "\n".join(_rendered_text(at))
+ assert not [o for o in others if o in page], [o for o in others if o in page]
+
+
+# --- Run Remote: inputs the fibermorph CLI would refuse ---------------------
+
+def _remote_number_input(at, label):
+ (box,) = [n for n in at.number_input if n.label == label]
+ return box
+
+
+def test_run_remote_inputs_have_the_cli_lower_limits(monkeypatch):
+ at = _run_app(monkeypatch, local=False, view="remote")
+ assert _remote_number_input(at, "Min diameter (µm)").min == 0.0
+ assert _remote_number_input(at, "Max diameter (µm)").min == 1.0
+ assert _remote_number_input(at, "Taubin window (px)").min == 1
+
+
+def test_run_remote_warns_when_min_diameter_exceeds_max(monkeypatch):
+ at = _run_app(monkeypatch, local=False, view="remote")
+ assert not [w for w in at.warning if "Min diameter is larger than Max" in w.value]
+ _remote_number_input(at, "Min diameter (µm)").set_value(200.0)
+ _remote_number_input(at, "Max diameter (µm)").set_value(150.0)
+ at.run()
+ assert not at.exception, [e.value for e in at.exception]
+ assert [w for w in at.warning if "Min diameter is larger than Max" in w.value]
diff --git a/fibermorph/test/test_gui_inputs.py b/fibermorph/test/test_gui_inputs.py
new file mode 100644
index 0000000..eaa57cf
--- /dev/null
+++ b/fibermorph/test/test_gui_inputs.py
@@ -0,0 +1,224 @@
+"""Tests for fibermorph.gui.inputs: safe handling of uploaded files."""
+
+import io
+import os
+
+import pytest
+
+from fibermorph.gui import inputs
+
+
+class FakeUpload:
+ """Minimal stand-in for Streamlit's UploadedFile (name + read())."""
+
+ def __init__(self, name, data=b"data"):
+ self.name = name
+ self._buf = io.BytesIO(data)
+
+ def read(self):
+ return self._buf.read()
+
+
+def _tree(root):
+ """Every file under root, as paths relative to root."""
+ return sorted(
+ os.path.relpath(os.path.join(d, f), root)
+ for d, _, files in os.walk(root) for f in files
+ )
+
+
+# ---------------------------------------------------------------------------
+# format_upload_cap
+# ---------------------------------------------------------------------------
+
+@pytest.mark.parametrize("megabytes, expected", [
+ (200, "200 MB"),
+ (500, "500 MB"),
+ (999, "999 MB"),
+ (1000, "1 GB"),
+ (2000, "2 GB"),
+ (2500, "2500 MB"),
+ (5000, "5 GB"),
+ ("5000", "5 GB"),
+])
+def test_format_upload_cap(megabytes, expected):
+ assert inputs.format_upload_cap(megabytes) == expected
+
+
+# ---------------------------------------------------------------------------
+# display_name
+# ---------------------------------------------------------------------------
+
+@pytest.mark.parametrize("client_name, expected", [
+ ("sample.tif", "sample.tif"),
+ ("my scan (1).PNG", "my scan (1).PNG"),
+ ("/etc/passwd", "passwd"),
+ ("/abs/dir/hair.tiff", "hair.tiff"),
+ ("../../escape.tif", "escape.tif"),
+ ("a/../../b.jpg", "b.jpg"),
+ ("C:\\Users\\me\\scan.tif", "scan.tif"),
+ ("..\\..\\scan.tif", "scan.tif"),
+ ("mixed/dir\\scan.png", "scan.png"),
+ ("trailing.tif ", "trailing.tif"),
+ ("bad\x00name\n.tif", "badname.tif"),
+])
+def test_display_name_keeps_last_component(client_name, expected):
+ assert inputs.display_name(client_name) == expected
+
+
+@pytest.mark.parametrize("client_name", ["", None, ".", "..", "/", "\\", "a/", "a\\..", " ", "\x00"])
+def test_display_name_falls_back_when_nothing_usable(client_name):
+ assert inputs.display_name(client_name, fallback="upload_0007.tif") == "upload_0007.tif"
+
+
+# ---------------------------------------------------------------------------
+# safe_extension
+# ---------------------------------------------------------------------------
+
+@pytest.mark.parametrize("client_name, expected", [
+ ("a.tif", ".tif"),
+ ("a.TIFF", ".tiff"),
+ ("a.Png", ".png"),
+ ("a.jpg", ".jpg"),
+ ("a.jpeg", ".jpeg"),
+ ("archive.tar.tif", ".tif"),
+ ("dir.tif/noext", ""), # extension of a parent component doesn't count
+ ("a.exe", ""),
+ ("a.tif.exe", ""),
+ ("a.py", ""),
+ ("a.", ""),
+ (".tif", ""), # a dotfile has no extension
+ ("noext", ""),
+ ("", ""),
+ (None, ""),
+ ("a.t if", ""),
+ ("a.tif/../b.sh", ""),
+])
+def test_safe_extension_only_for_accepted_types(client_name, expected):
+ assert inputs.safe_extension(client_name) == expected
+
+
+def test_upload_types_match_what_the_app_accepts():
+ assert inputs.UPLOAD_TYPES == ["tif", "tiff", "png", "jpg", "jpeg"]
+
+
+# ---------------------------------------------------------------------------
+# save_uploads
+# ---------------------------------------------------------------------------
+
+def test_save_uploads_uses_generated_names_and_keeps_content(tmp_path):
+ ups = [FakeUpload("first.tif", b"AAA"), FakeUpload("second.PNG", b"BBB")]
+ out = inputs.save_uploads(ups, str(tmp_path))
+ assert [n for n, _ in out] == ["first.tif", "second.PNG"]
+ assert [os.path.basename(p) for _, p in out] == ["upload_0001.tif", "upload_0002.png"]
+ assert [open(p, "rb").read() for _, p in out] == [b"AAA", b"BBB"]
+
+
+@pytest.mark.parametrize("hostile", [
+ "/tmp/fibermorph_should_not_exist.tif",
+ "../../fibermorph_should_not_exist.tif",
+ "../fibermorph_should_not_exist.tif",
+ "a/../../fibermorph_should_not_exist.tif",
+ "..\\..\\fibermorph_should_not_exist.tif",
+ "C:\\Windows\\fibermorph_should_not_exist.tif",
+ "\\\\server\\share\\fibermorph_should_not_exist.tif",
+ "sub/dir/file.tif",
+ "..",
+ ".",
+ "",
+ "/",
+])
+def test_hostile_names_never_escape_tmpdir(tmp_path, hostile):
+ outer = tmp_path / "outer"
+ tmpdir = outer / "work"
+ tmpdir.mkdir(parents=True)
+ out = inputs.save_uploads([FakeUpload(hostile, b"x")], str(tmpdir))
+
+ assert len(out) == 1
+ _, path = out[0]
+ assert os.path.dirname(os.path.abspath(path)) == str(tmpdir)
+ assert os.path.basename(path).startswith("upload_0001")
+ # Nothing was created anywhere except directly inside tmpdir.
+ assert _tree(str(outer)) == [os.path.join("work", os.path.basename(path))]
+ assert not os.path.exists("/tmp/fibermorph_should_not_exist.tif")
+
+
+def test_duplicate_names_become_distinct_files(tmp_path):
+ ups = [FakeUpload("same.tif", b"one"), FakeUpload("same.tif", b"two"),
+ FakeUpload("same.tif", b"three")]
+ out = inputs.save_uploads(ups, str(tmp_path))
+ paths = [p for _, p in out]
+ assert len(set(paths)) == 3
+ assert [n for n, _ in out] == ["same.tif"] * 3 # display names are the original
+ assert [open(p, "rb").read() for p in paths] == [b"one", b"two", b"three"]
+
+
+def test_names_differing_only_by_directory_do_not_collide(tmp_path):
+ ups = [FakeUpload("a/x.tif", b"1"), FakeUpload("b/x.tif", b"2")]
+ out = inputs.save_uploads(ups, str(tmp_path))
+ assert [n for n, _ in out] == ["x.tif", "x.tif"]
+ assert [open(p, "rb").read() for _, p in out] == [b"1", b"2"]
+
+
+def test_empty_name_gets_generated_display_name(tmp_path):
+ out = inputs.save_uploads([FakeUpload("", b"x"), FakeUpload("..", b"y")], str(tmp_path))
+ assert [n for n, _ in out] == ["upload_0001", "upload_0002"]
+ assert [os.path.basename(p) for _, p in out] == ["upload_0001", "upload_0002"]
+
+
+def test_unaccepted_extension_is_not_kept_on_disk(tmp_path):
+ out = inputs.save_uploads([FakeUpload("evil.sh"), FakeUpload("x.tif.exe")], str(tmp_path))
+ assert [os.path.basename(p) for _, p in out] == ["upload_0001", "upload_0002"]
+ assert [n for n, _ in out] == ["evil.sh", "x.tif.exe"] # still shown as uploaded
+
+
+def test_save_uploads_handles_none_and_empty(tmp_path):
+ assert inputs.save_uploads(None, str(tmp_path)) == []
+ assert inputs.save_uploads([], str(tmp_path)) == []
+ assert _tree(str(tmp_path)) == []
+
+
+def test_pre_existing_files_are_not_overwritten_by_other_names(tmp_path):
+ # A file the app already has in tmpdir can't be clobbered by choosing its name.
+ (tmp_path / "important.tif").write_bytes(b"keep")
+ inputs.save_uploads([FakeUpload("important.tif", b"new")], str(tmp_path))
+ assert (tmp_path / "important.tif").read_bytes() == b"keep"
+
+
+# ---------------------------------------------------------------------------
+# restore_names
+# ---------------------------------------------------------------------------
+
+def test_restore_names_replaces_generated_name_and_path(tmp_path):
+ (name, path), = inputs.save_uploads([FakeUpload("../x/scan.tif")], str(tmp_path))
+ assert name == "scan.tif"
+ msg = f"Cannot identify image file 'upload_0001.tif' (read {path})"
+ assert inputs.restore_names(msg, path, name) == (
+ "Cannot identify image file 'scan.tif' (read scan.tif)")
+
+
+def test_restore_names_leaves_other_text_alone(tmp_path):
+ path = str(tmp_path / "upload_0001.tif")
+ assert inputs.restore_names("something else", path, "scan.tif") == "something else"
+ assert inputs.restore_names(ValueError("bad upload_0001.tif"), path, "scan.tif") == "bad scan.tif"
+
+
+def test_restore_names_only_replaces_whole_names(tmp_path):
+ # An upload saved without an extension ("upload_0001") must not be matched
+ # inside another upload's name ("upload_00010.tif").
+ path = str(tmp_path / "upload_0001")
+ msg = f"bad upload_0001 and upload_00010.tif and {path} and {path}0"
+ assert inputs.restore_names(msg, path, "N") == "bad N and upload_00010.tif and N and " + path + "0"
+
+
+def test_restore_names_handles_punctuation_around_the_name(tmp_path):
+ path = str(tmp_path / "upload_0001.tif")
+ assert inputs.restore_names("Cannot read 'upload_0001.tif'.", path, "my scan.tif") == (
+ "Cannot read 'my scan.tif'.")
+ assert inputs.restore_names("upload_0001.tif: bad", path, r"a\1.tif") == r"a\1.tif: bad"
+
+
+def test_restore_names_is_a_no_op_for_files_read_from_a_folder(tmp_path):
+ path = str(tmp_path / "scan.tif")
+ msg = f"Cannot read {path}"
+ assert inputs.restore_names(msg, path, "scan.tif") == msg
diff --git a/fibermorph/test/test_gui_launcher.py b/fibermorph/test/test_gui_launcher.py
new file mode 100644
index 0000000..9b2ae76
--- /dev/null
+++ b/fibermorph/test/test_gui_launcher.py
@@ -0,0 +1,373 @@
+"""Tests for the GUI launcher: first-run email-prompt skip, and hosted vs local
+mode (`fibermorph-gui` vs `fibermorph-gui --local`)."""
+
+import json
+import os
+import subprocess
+import sys
+import textwrap
+from pathlib import Path
+
+import pytest
+
+from fibermorph.gui import launcher
+
+
+def test_skip_email_prompt_creates_when_absent(tmp_path, monkeypatch):
+ monkeypatch.setenv("HOME", str(tmp_path)) # Path.home() honors HOME on POSIX
+ launcher._skip_streamlit_email_prompt()
+ cred = tmp_path / ".streamlit" / "credentials.toml"
+ assert cred.exists()
+ assert 'email = ""' in cred.read_text()
+
+
+def test_skip_email_prompt_preserves_existing(tmp_path, monkeypatch):
+ monkeypatch.setenv("HOME", str(tmp_path))
+ cred = tmp_path / ".streamlit" / "credentials.toml"
+ cred.parent.mkdir(parents=True)
+ cred.write_text('[general]\nemail = "real@example.com"\n')
+ launcher._skip_streamlit_email_prompt()
+ assert "real@example.com" in cred.read_text(), "existing credentials must not be clobbered"
+
+
+# ---------------------------------------------------------------------------
+# Hosted vs local mode: `fibermorph-gui` is hosted unless --local (or
+# FIBERMORPH_LOCAL=1) is given.
+# ---------------------------------------------------------------------------
+
+@pytest.fixture
+def run_launcher(tmp_path, monkeypatch):
+ """Run launcher.main(argv) with streamlit's CLI replaced by a recorder.
+
+ Returns a function that runs the launcher and returns a dict with the
+ sys.argv streamlit would have received and the FIBERMORPH_LOCAL value seen
+ at that moment. HOME and FIBERMORPH_LOCAL are sandboxed so nothing leaks
+ into (or out of) the real environment.
+ """
+ pytest.importorskip("streamlit")
+ from streamlit.web import cli as stcli
+
+ monkeypatch.setenv("HOME", str(tmp_path))
+ monkeypatch.delenv("FIBERMORPH_LOCAL", raising=False)
+ monkeypatch.delenv(launcher.UPLOAD_CAP_ENV, raising=False)
+ monkeypatch.setattr(sys, "argv", list(sys.argv)) # main() replaces sys.argv; undone on teardown
+ seen = {}
+
+ def fake_streamlit_main():
+ seen["argv"] = list(sys.argv)
+ seen["local_env"] = os.environ.get("FIBERMORPH_LOCAL")
+ return 0
+
+ monkeypatch.setattr(stcli, "main", fake_streamlit_main)
+
+ def run(argv):
+ with pytest.raises(SystemExit) as exc:
+ launcher.main(argv)
+ assert exc.value.code == 0
+ return seen
+
+ yield run
+ # main() sets FIBERMORPH_LOCAL directly in os.environ in local mode, which
+ # monkeypatch doesn't know about; remove it before monkeypatch restores
+ # whatever the environment had originally.
+ os.environ.pop("FIBERMORPH_LOCAL", None)
+
+
+def _option(argv, name):
+ """Last value given for a --name option in a streamlit argv."""
+ return [argv[i + 1] for i, a in enumerate(argv[:-1]) if a == name][-1]
+
+
+def test_default_launch_is_hosted(run_launcher):
+ seen = run_launcher([])
+ argv = seen["argv"]
+ assert argv[:2] == ["streamlit", "run"]
+ assert Path(argv[2]) == Path(launcher.__file__).parent / "app.py"
+ assert _option(argv, "--server.maxUploadSize") == "500"
+ assert "--server.address" not in argv
+ assert seen["local_env"] is None
+
+
+def test_hosted_mode_never_sets_local_env(run_launcher):
+ run_launcher([])
+ assert "FIBERMORPH_LOCAL" not in os.environ
+
+
+def test_local_flag_enables_local_mode(run_launcher):
+ seen = run_launcher(["--local"])
+ argv = seen["argv"]
+ assert seen["local_env"] == "1"
+ assert _option(argv, "--server.maxUploadSize") == "5000"
+ assert _option(argv, "--server.address") == "localhost"
+ assert "--local" not in argv, "--local is fibermorph's flag, not streamlit's"
+
+
+def test_preset_env_var_enables_local_mode(run_launcher, monkeypatch):
+ monkeypatch.setenv("FIBERMORPH_LOCAL", "1")
+ seen = run_launcher([])
+ argv = seen["argv"]
+ assert seen["local_env"] == "1"
+ assert _option(argv, "--server.maxUploadSize") == "5000"
+ assert _option(argv, "--server.address") == "localhost"
+
+
+@pytest.mark.parametrize("value", ["0", "", "true"])
+def test_other_env_values_stay_hosted(run_launcher, monkeypatch, value):
+ # The app only treats FIBERMORPH_LOCAL == "1" as local mode.
+ monkeypatch.setenv("FIBERMORPH_LOCAL", value)
+ seen = run_launcher([])
+ assert _option(seen["argv"], "--server.maxUploadSize") == "500"
+ assert "--server.address" not in seen["argv"]
+ assert seen["local_env"] == value # left untouched
+
+
+def test_extra_args_pass_through_after_defaults(run_launcher):
+ seen = run_launcher(["--local", "--server.port", "8600", "--server.headless=true"])
+ argv = seen["argv"]
+ assert argv[-3:] == ["--server.port", "8600", "--server.headless=true"]
+ # Pass-through comes after the defaults so it can override them.
+ assert argv.index("--server.port") > argv.index("--server.maxUploadSize")
+ assert argv.index("--server.port") > argv.index("--server.address")
+
+
+def test_extra_args_can_override_defaults(run_launcher):
+ seen = run_launcher(["--server.maxUploadSize", "1000"])
+ assert _option(seen["argv"], "--server.maxUploadSize") == "1000"
+ seen = run_launcher(["--local", "--server.address", "192.168.0.5"])
+ assert _option(seen["argv"], "--server.address") == "192.168.0.5"
+
+
+def test_extra_args_pass_through_in_hosted_mode(run_launcher):
+ seen = run_launcher(["--server.port", "9000"])
+ assert seen["argv"][-2:] == ["--server.port", "9000"]
+ assert seen["local_env"] is None
+
+
+def test_local_flag_position_does_not_matter(run_launcher):
+ seen = run_launcher(["--server.port", "9000", "--local"])
+ assert seen["local_env"] == "1"
+ assert seen["argv"][-2:] == ["--server.port", "9000"]
+ assert "--local" not in seen["argv"]
+
+
+def test_main_reads_sys_argv_by_default(run_launcher, monkeypatch):
+ monkeypatch.setattr(sys, "argv", ["fibermorph-gui", "--local"])
+ seen = run_launcher(None)
+ assert seen["local_env"] == "1"
+
+
+def test_help_documents_local_flag(capsys):
+ with pytest.raises(SystemExit) as exc:
+ launcher.main(["--help"])
+ assert exc.value.code == 0
+ out = capsys.readouterr().out
+ assert "--local" in out
+ assert "5 GB" in out and "localhost" in out
+ assert "500 MB" in out
+
+
+def test_launch_notice_names_the_mode(run_launcher, capsys):
+ run_launcher([])
+ assert "hosted mode" in capsys.readouterr().err
+ run_launcher(["--local"])
+ assert "local mode" in capsys.readouterr().err
+
+
+# ---------------------------------------------------------------------------
+# Precedence: the launcher's --server.* defaults vs STREAMLIT_* environment
+# variables and the user's own command-line options.
+# ---------------------------------------------------------------------------
+
+@pytest.mark.parametrize("argv", [[], ["--local"]])
+def test_env_upload_cap_is_not_overridden(run_launcher, monkeypatch, argv):
+ # A command-line option outranks STREAMLIT_SERVER_MAX_UPLOAD_SIZE, so the
+ # launcher must not pass one when the host has set the variable.
+ monkeypatch.setenv(launcher.UPLOAD_CAP_ENV, "200")
+ seen = run_launcher(argv)
+ assert "--server.maxUploadSize" not in seen["argv"]
+
+
+def test_empty_env_upload_cap_counts_as_unset(run_launcher, monkeypatch):
+ monkeypatch.setenv(launcher.UPLOAD_CAP_ENV, "")
+ seen = run_launcher([])
+ assert _option(seen["argv"], "--server.maxUploadSize") == "500"
+
+
+def test_command_line_upload_cap_wins_over_env(run_launcher, monkeypatch):
+ monkeypatch.setenv(launcher.UPLOAD_CAP_ENV, "200")
+ seen = run_launcher(["--server.maxUploadSize", "300"])
+ assert _option(seen["argv"], "--server.maxUploadSize") == "300"
+
+
+def test_env_address_does_not_widen_local_mode(run_launcher, monkeypatch):
+ monkeypatch.setenv("STREAMLIT_SERVER_ADDRESS", "0.0.0.0")
+ seen = run_launcher(["--local"])
+ assert _option(seen["argv"], "--server.address") == "localhost"
+
+
+def test_hosted_mode_leaves_address_and_port_to_streamlit(run_launcher, monkeypatch):
+ # The Dockerfile sets STREAMLIT_SERVER_ADDRESS=0.0.0.0 and
+ # STREAMLIT_SERVER_PORT=7860; hosted mode passes neither option, so both
+ # keep applying.
+ monkeypatch.setenv("STREAMLIT_SERVER_ADDRESS", "0.0.0.0")
+ monkeypatch.setenv("STREAMLIT_SERVER_PORT", "7860")
+ seen = run_launcher([])
+ assert "--server.address" not in seen["argv"]
+ assert "--server.port" not in seen["argv"]
+
+
+@pytest.mark.parametrize("args, expected", [
+ (["--server.port", "8600"], "8600"),
+ (["--server.port=8600"], "8600"),
+ (["--server.port", "1", "--server.port=2"], "2"),
+ (["--server.port"], None),
+ (["--server.portal", "9"], None),
+ ([], None),
+])
+def test_option_value(args, expected):
+ assert launcher._option_value(args, "--server.port") == expected
+
+
+def test_help_says_how_the_defaults_interact_with_streamlit_settings(capsys):
+ with pytest.raises(SystemExit):
+ launcher.main(["--help"])
+ out = " ".join(capsys.readouterr().out.split())
+ assert "STREAMLIT_SERVER_MAX_UPLOAD_SIZE" in out
+ assert "STREAMLIT_SERVER_ADDRESS" in out
+ assert "config.toml" in out
+
+
+# --- startup notice ---------------------------------------------------------
+
+def test_local_notice_says_localhost_only_by_default(run_launcher, capsys):
+ run_launcher(["--local"])
+ err = capsys.readouterr().err
+ assert "localhost only" in err
+ assert "5000 MB" in err
+
+
+@pytest.mark.parametrize("flag", [
+ ["--server.address", "0.0.0.0"],
+ ["--server.address=0.0.0.0"],
+ ["--server.address", "192.168.0.5"],
+ # Streamlit treats an empty address as 0.0.0.0 (every interface).
+ ["--server.address="],
+ ["--server.address", ""],
+])
+def test_local_notice_warns_when_address_is_widened(run_launcher, capsys, flag):
+ run_launcher(["--local", *flag])
+ err = capsys.readouterr().err
+ assert "localhost only" not in err
+ assert "WARNING" in err
+ assert "read folders on it" in err
+
+
+@pytest.mark.parametrize("flag", [["--server.address="], ["--server.address", ""]])
+def test_local_notice_says_every_interface_for_an_empty_address(run_launcher, capsys, flag):
+ run_launcher(["--local", *flag])
+ assert "all network interfaces" in capsys.readouterr().err
+
+
+@pytest.mark.parametrize("address", ["localhost", "127.0.0.1", "::1"])
+def test_local_notice_is_unchanged_for_loopback_address(run_launcher, capsys, address):
+ run_launcher(["--local", "--server.address", address])
+ err = capsys.readouterr().err
+ assert "localhost only" in err
+ assert "WARNING" not in err
+
+
+def test_notice_reports_the_effective_upload_cap(run_launcher, monkeypatch, capsys):
+ run_launcher([])
+ assert "500 MB" in capsys.readouterr().err
+ monkeypatch.setenv(launcher.UPLOAD_CAP_ENV, "200")
+ run_launcher([])
+ assert "200 MB" in capsys.readouterr().err
+ run_launcher(["--server.maxUploadSize=300"])
+ assert "300 MB" in capsys.readouterr().err
+
+
+# --- what Streamlit itself resolves -----------------------------------------
+
+_RESOLVE_SCRIPT = textwrap.dedent("""
+ import json, sys
+ from streamlit import config
+ from streamlit.web import bootstrap, cli as stcli
+ from fibermorph.gui import launcher
+
+ seen = {}
+
+ def fake_main_run(file, args=None, flag_options=None):
+ # What `streamlit run` does with its options before starting the server.
+ bootstrap.load_config_options(flag_options=flag_options or {})
+ for name in ("server.maxUploadSize", "server.address"):
+ seen[name] = config.get_option(name)
+
+ stcli._main_run = fake_main_run
+ try:
+ launcher.main(sys.argv[1:])
+ except SystemExit:
+ pass
+ print("RESOLVED " + json.dumps(seen))
+""")
+
+
+def _resolved_options(tmp_path, env, args):
+ """Run the launcher in a fresh interpreter with Streamlit's real config
+ loader and return the server options Streamlit ends up with.
+
+ Runs in an empty directory with an empty HOME, so neither the repository's
+ .streamlit/config.toml nor the developer's own settings are read.
+ """
+ pytest.importorskip("streamlit")
+ from streamlit.web import bootstrap, cli as stcli
+ if not (hasattr(stcli, "_main_run") and hasattr(bootstrap, "load_config_options")):
+ pytest.skip("this Streamlit version does not expose the config loader used here")
+ full_env = {k: v for k, v in os.environ.items()
+ if not k.startswith(("STREAMLIT_", "FIBERMORPH_"))}
+ full_env.update(env, HOME=str(tmp_path))
+ full_env["PYTHONPATH"] = os.pathsep.join(
+ [str(Path(launcher.__file__).resolve().parents[2]), full_env.get("PYTHONPATH", "")]
+ )
+ proc = subprocess.run(
+ [sys.executable, "-c", _RESOLVE_SCRIPT, *args],
+ cwd=tmp_path, env=full_env, capture_output=True, text=True, timeout=120,
+ )
+ assert proc.returncode == 0, proc.stderr
+ line = [ln for ln in proc.stdout.splitlines() if ln.startswith("RESOLVED ")][-1]
+ return json.loads(line[len("RESOLVED "):])
+
+
+def test_streamlit_uses_host_upload_cap_from_environment(tmp_path):
+ got = _resolved_options(tmp_path, {launcher.UPLOAD_CAP_ENV: "200"}, [])
+ assert got["server.maxUploadSize"] == 200
+
+
+def test_streamlit_gets_500_mb_cap_when_host_sets_nothing(tmp_path):
+ # Streamlit's own default is 200 MB, so 500 shows the launcher set it.
+ got = _resolved_options(tmp_path, {}, [])
+ assert got["server.maxUploadSize"] == 500
+
+
+def test_streamlit_keeps_host_address_in_hosted_mode(tmp_path):
+ got = _resolved_options(tmp_path, {"STREAMLIT_SERVER_ADDRESS": "0.0.0.0"}, [])
+ assert got["server.address"] == "0.0.0.0"
+
+
+def test_streamlit_ignores_env_address_in_local_mode(tmp_path):
+ got = _resolved_options(tmp_path, {"STREAMLIT_SERVER_ADDRESS": "0.0.0.0"}, ["--local"])
+ assert got["server.address"] == "localhost"
+
+
+def test_streamlit_honours_command_line_address_in_local_mode(tmp_path):
+ got = _resolved_options(
+ tmp_path, {"STREAMLIT_SERVER_ADDRESS": "localhost"},
+ ["--local", "--server.address", "0.0.0.0"],
+ )
+ assert got["server.address"] == "0.0.0.0"
+
+
+def test_streamlit_gets_an_empty_address_when_asked_for_one(tmp_path):
+ # Streamlit's server then falls back to 0.0.0.0 (every interface), which is
+ # why the startup notice warns about it instead of saying "localhost only".
+ got = _resolved_options(tmp_path, {}, ["--local", "--server.address="])
+ assert got["server.address"] == ""
diff --git a/fibermorph/test/test_pipeline_batch.py b/fibermorph/test/test_pipeline_batch.py
new file mode 100644
index 0000000..120b004
--- /dev/null
+++ b/fibermorph/test/test_pipeline_batch.py
@@ -0,0 +1,142 @@
+"""Unit tests for pipeline.batch — per-image output with canonical labels.
+
+The batch pipeline no longer aggregates per sample; it emits one row per source
+image (carrying the source filename) and leaves all grouping to downstream
+analysis.
+"""
+
+import os
+import pathlib
+import shutil
+
+import numpy as np
+import pandas as pd
+import pytest
+from PIL import Image
+from skimage import draw as sk_draw
+
+from fibermorph.pipeline.batch import run_batch
+
+GOLDEN = pathlib.Path(__file__).parent / "test_data" / "curv_golden"
+
+
+def _make_section_tiff(path: str, size: int = 200, radius: int = 40) -> None:
+ """A dark disk on a bright field — segments with the classical watershed."""
+ img = np.ones((size, size), dtype=np.uint8) * 220
+ rr, cc = sk_draw.disk((size // 2, size // 2), radius, shape=img.shape)
+ img[rr, cc] = 30
+ Image.fromarray(img, mode="L").save(path)
+
+
+class TestRunBatchPerImage:
+ def test_per_image_only_no_per_sample(self, tmp_path):
+ sec_dir = tmp_path / "sections"
+ out_dir = tmp_path / "out"
+ sec_dir.mkdir()
+ out_dir.mkdir()
+ # canonical names: Individual_Sample_Side
+ _make_section_tiff(str(sec_dir / "Y_5_A.tiff"))
+ _make_section_tiff(str(sec_dir / "Y_5_B.tiff"))
+
+ result = run_batch(
+ section_dir=str(sec_dir), curv_dir=None, output_dir=str(out_dir),
+ resolution_mu=4.25, min_diam=10, max_diam=300,
+ use_sam2=False, extended_features=False,
+ )
+
+ # returns a single per-image DataFrame (not a tuple)
+ assert isinstance(result, pd.DataFrame)
+ assert not isinstance(result, tuple)
+ assert len(result) == 2
+
+ # per-image CSV written; per-sample CSV must NOT exist
+ assert (out_dir / "hair_analysis_per_image.csv").exists()
+ assert not (out_dir / "hair_analysis_per_sample.csv").exists()
+
+ def test_source_file_recorded_no_grouping_columns(self, tmp_path):
+ sec_dir = tmp_path / "sections"
+ out_dir = tmp_path / "out"
+ sec_dir.mkdir()
+ out_dir.mkdir()
+ _make_section_tiff(str(sec_dir / "Y_5_B.tiff"))
+
+ result = run_batch(
+ section_dir=str(sec_dir), curv_dir=None, output_dir=str(out_dir),
+ resolution_mu=4.25, min_diam=10, max_diam=300,
+ use_sam2=False, extended_features=False,
+ )
+ row = result.iloc[0]
+ assert row["source_file"] == "Y_5_B.tiff"
+ # the app does not parse filenames into grouping columns
+ for col in ("individual", "sample", "side", "sample_id", "region", "replicate"):
+ assert col not in result.columns
+
+ def test_empty_input_returns_empty_dataframe(self, tmp_path):
+ sec_dir = tmp_path / "sections"
+ out_dir = tmp_path / "out"
+ sec_dir.mkdir()
+ out_dir.mkdir()
+ result = run_batch(
+ section_dir=str(sec_dir), curv_dir=None, output_dir=str(out_dir),
+ use_sam2=False, extended_features=False,
+ )
+ assert isinstance(result, pd.DataFrame)
+ assert result.empty
+
+
+class TestBatchCurvatureUsesPublishedMethod:
+ """Batch curvature output equals the published (v0.3.1) numbers.
+
+ The reference summary in test_data/curv_golden/ was produced by the
+ original fibermorph v0.3.1 code (see the README there); batch adds only
+ the image_type and source_file labels.
+ """
+
+ @staticmethod
+ def _curv_dir(tmp_path):
+ curv_dir = tmp_path / "curvature"
+ curv_dir.mkdir()
+ shutil.copy(GOLDEN / "synthetic_curv.png", curv_dir / "synthetic_curv.png")
+ return curv_dir
+
+ @staticmethod
+ def _expected():
+ return pd.read_csv(
+ GOLDEN / "reference_v031" / "synthetic_curv__window50px__summary.csv",
+ float_precision="round_trip",
+ )
+
+ @classmethod
+ def _assert_published_row(cls, row):
+ expected = cls._expected().iloc[0]
+ assert row["image_type"] == "curvature"
+ assert row["source_file"] == "synthetic_curv.png"
+ assert row["ID"] == expected["ID"]
+ for col in expected.index.drop("ID"):
+ assert row[col] == pytest.approx(expected[col], rel=1e-9)
+
+ def test_run_batch_curvature_matches_published_method(self, tmp_path):
+ out_dir = tmp_path / "out"
+ result = run_batch(
+ section_dir=None, curv_dir=str(self._curv_dir(tmp_path)),
+ output_dir=str(out_dir), resolution_mm=132, window_size=50,
+ window_unit="px",
+ )
+ assert len(result) == 1
+ self._assert_published_row(result.iloc[0])
+ # only the published summary columns (plus the two labels)
+ assert set(result.columns) == set(self._expected().columns) | {
+ "image_type", "source_file"}
+
+ def test_workflows_batch_curvature_writes_published_columns(self, tmp_path):
+ from fibermorph.workflows import batch
+
+ assert batch(None, str(self._curv_dir(tmp_path)), str(tmp_path / "out"),
+ resolution_mm=132, window_size=50, window_unit="px")
+ (csv,) = (tmp_path / "out").glob("*fibermorph_batch/hair_analysis_per_image.csv")
+ written = pd.read_csv(csv)
+ assert len(written) == 1
+ self._assert_published_row(written.iloc[0])
+ for removed in ("curl_index", "curl_index_std", "wave_count",
+ "wave_count_per_mm", "length_total"):
+ assert removed not in written.columns
diff --git a/fibermorph/test/test_section_pipeline.py b/fibermorph/test/test_section_pipeline.py
new file mode 100644
index 0000000..68898a9
--- /dev/null
+++ b/fibermorph/test/test_section_pipeline.py
@@ -0,0 +1,106 @@
+"""Unit tests for analysis.section_pipeline (watershed path, extended features)."""
+
+import os
+import tempfile
+
+import numpy as np
+import pytest
+from PIL import Image
+from skimage import draw as sk_draw
+
+
+def _make_section_tiff(tmp_path, fname: str = "test_section.tiff",
+ size: int = 200, radius: int = 40) -> str:
+ """Create a synthetic cross-section TIFF with a dark circle on bright background."""
+ img = np.ones((size, size), dtype=np.uint8) * 220
+ rr, cc = sk_draw.disk((size // 2, size // 2), radius, shape=img.shape)
+ img[rr, cc] = 30
+ path = os.path.join(str(tmp_path), fname)
+ Image.fromarray(img, mode="L").save(path)
+ return path
+
+
+class TestSectionSeq:
+ """Tests for section_seq (watershed path, no SAM2 required)."""
+
+ def test_returns_dataframe(self, tmp_path):
+ from fibermorph.analysis.section_pipeline import section_seq
+ img_path = _make_section_tiff(tmp_path)
+ result = section_seq(
+ img_path, str(tmp_path),
+ resolution=4.25, minsize=10, maxsize=300,
+ save_img=False, use_sam2=False, extended_features=False,
+ )
+ import pandas as pd
+ assert result is not None
+ assert isinstance(result, pd.DataFrame)
+
+ def test_dataframe_has_id_column(self, tmp_path):
+ from fibermorph.analysis.section_pipeline import section_seq
+ img_path = _make_section_tiff(tmp_path)
+ df = section_seq(
+ img_path, str(tmp_path),
+ resolution=4.25, minsize=10, maxsize=300,
+ save_img=False, use_sam2=False, extended_features=False,
+ )
+ assert "ID" in df.columns
+
+ def test_extended_features_adds_efd_columns(self, tmp_path):
+ from fibermorph.analysis.section_pipeline import section_seq
+ img_path = _make_section_tiff(tmp_path)
+ df = section_seq(
+ img_path, str(tmp_path),
+ resolution=4.25, minsize=10, maxsize=300,
+ save_img=False, use_sam2=False, extended_features=True,
+ )
+ if df is not None and not df.empty:
+ efd_cols = [c for c in df.columns if c.startswith("efd_")]
+ assert len(efd_cols) > 0, "Extended features should include EFD columns"
+
+ def test_extended_features_adds_hu_columns(self, tmp_path):
+ from fibermorph.analysis.section_pipeline import section_seq
+ img_path = _make_section_tiff(tmp_path)
+ df = section_seq(
+ img_path, str(tmp_path),
+ resolution=4.25, minsize=10, maxsize=300,
+ save_img=False, use_sam2=False, extended_features=True,
+ )
+ if df is not None and not df.empty:
+ hu_cols = [c for c in df.columns if c.startswith("hu")]
+ assert len(hu_cols) == 7, "Extended features should include 7 Hu moment columns"
+
+ def test_extended_features_adds_shape_class(self, tmp_path):
+ from fibermorph.analysis.section_pipeline import section_seq
+ img_path = _make_section_tiff(tmp_path)
+ df = section_seq(
+ img_path, str(tmp_path),
+ resolution=4.25, minsize=10, maxsize=300,
+ save_img=False, use_sam2=False, extended_features=True,
+ )
+ if df is not None and not df.empty:
+ assert "shape_class" in df.columns
+
+ def test_save_img_creates_output_files(self, tmp_path):
+ from fibermorph.analysis.section_pipeline import section_seq
+ img_path = _make_section_tiff(tmp_path)
+ section_seq(
+ img_path, str(tmp_path),
+ resolution=4.25, minsize=10, maxsize=300,
+ save_img=True, use_sam2=False, extended_features=False,
+ )
+ # At minimum the input tiff should still exist; output may create subdir
+ assert os.path.exists(img_path)
+
+ def test_no_crash_on_blank_image(self, tmp_path):
+ from fibermorph.analysis.section_pipeline import section_seq
+ blank = np.ones((100, 100), dtype=np.uint8) * 200
+ img_path = os.path.join(str(tmp_path), "blank.tiff")
+ Image.fromarray(blank, mode="L").save(img_path)
+ # Should not raise; may return empty / NaN DataFrame
+ result = section_seq(
+ img_path, str(tmp_path),
+ resolution=4.25, minsize=10, maxsize=300,
+ save_img=False, use_sam2=False, extended_features=False,
+ )
+ import pandas as pd
+ assert result is None or isinstance(result, pd.DataFrame)
diff --git a/fibermorph/test/test_utils_filesystem.py b/fibermorph/test/test_utils_filesystem.py
index c2a2721..a19f1f1 100644
--- a/fibermorph/test/test_utils_filesystem.py
+++ b/fibermorph/test/test_utils_filesystem.py
@@ -89,73 +89,89 @@ def test_copy_if_exist_with_string_paths(self, tmp_path):
assert (dest_dir / "source.txt").exists()
+def _make_tiff(path):
+ """Write a minimal valid TIFF image (list_images verifies files with PIL)."""
+ from PIL import Image
+ Image.new("L", (4, 4)).save(path, format="TIFF")
+
+
class TestListImages:
"""Tests for list_images function."""
-
+
def test_list_images_with_tif_files(self, tmp_path):
"""Test listing .tif files."""
# Create test files
- (tmp_path / "image1.tif").touch()
- (tmp_path / "image2.tif").touch()
+ _make_tiff(tmp_path / "image1.tif")
+ _make_tiff(tmp_path / "image2.tif")
(tmp_path / "other.txt").touch()
-
+
result = list_images(tmp_path)
assert len(result) == 2
assert all(p.suffix == ".tif" for p in result)
-
+
def test_list_images_with_tiff_files(self, tmp_path):
"""Test listing .tiff files."""
# Create test files
- (tmp_path / "image1.tiff").touch()
- (tmp_path / "image2.tiff").touch()
-
+ _make_tiff(tmp_path / "image1.tiff")
+ _make_tiff(tmp_path / "image2.tiff")
+
result = list_images(tmp_path)
assert len(result) == 2
assert all(p.suffix == ".tiff" for p in result)
-
+
def test_list_images_with_mixed_extensions(self, tmp_path):
"""Test listing both .tif and .tiff files."""
# Create test files
- (tmp_path / "image1.tif").touch()
- (tmp_path / "image2.tiff").touch()
+ _make_tiff(tmp_path / "image1.tif")
+ _make_tiff(tmp_path / "image2.tiff")
(tmp_path / "image3.jpg").touch()
-
+
result = list_images(tmp_path)
assert len(result) == 2
assert all(p.suffix in [".tif", ".tiff"] for p in result)
-
+
def test_list_images_with_nested_directories(self, tmp_path):
"""Test listing images in nested directories."""
# Create nested structure
nested_dir = tmp_path / "subdir"
nested_dir.mkdir()
- (tmp_path / "image1.tif").touch()
- (nested_dir / "image2.tif").touch()
-
+ _make_tiff(tmp_path / "image1.tif")
+ _make_tiff(nested_dir / "image2.tif")
+
result = list_images(tmp_path)
assert len(result) == 2
-
+
def test_list_images_with_empty_directory(self, tmp_path):
"""Test listing images in empty directory."""
result = list_images(tmp_path)
assert len(result) == 0
assert isinstance(result, list)
-
+
+ def test_list_images_skips_invalid_image_files(self, tmp_path):
+ """Files with a .tif extension that are not real images are excluded."""
+ _make_tiff(tmp_path / "valid.tif")
+ (tmp_path / "corrupt.tif").write_bytes(b"not a real tiff")
+ (tmp_path / "empty.tif").touch()
+ (tmp_path / "._appledouble.tif").write_bytes(b"\x00\x05\x16\x07")
+
+ result = list_images(tmp_path)
+ assert [p.name for p in result] == ["valid.tif"]
+
def test_list_images_sorted(self, tmp_path):
"""Test that results are sorted."""
# Create files in non-alphabetical order
- (tmp_path / "c.tif").touch()
- (tmp_path / "a.tif").touch()
- (tmp_path / "b.tif").touch()
-
+ _make_tiff(tmp_path / "c.tif")
+ _make_tiff(tmp_path / "a.tif")
+ _make_tiff(tmp_path / "b.tif")
+
result = list_images(tmp_path)
names = [p.name for p in result]
assert names == sorted(names)
-
+
def test_list_images_with_string_path(self, tmp_path):
"""Test with string path instead of pathlib.Path."""
- (tmp_path / "image1.tif").touch()
-
+ _make_tiff(tmp_path / "image1.tif")
+
result = list_images(str(tmp_path))
assert len(result) == 1
assert isinstance(result[0], pathlib.Path)
diff --git a/fibermorph/test/test_utils_metadata.py b/fibermorph/test/test_utils_metadata.py
new file mode 100644
index 0000000..5388677
--- /dev/null
+++ b/fibermorph/test/test_utils_metadata.py
@@ -0,0 +1,47 @@
+"""Unit tests for utils.metadata module."""
+
+import os
+import tempfile
+
+import pytest
+
+from fibermorph.utils.metadata import collect_images
+
+
+class TestCollectImages:
+ """Tests for collect_images directory scanner."""
+
+ def test_finds_tiff_files(self, tmp_path):
+ (tmp_path / "a.tiff").write_bytes(b"")
+ (tmp_path / "b.tif").write_bytes(b"")
+ (tmp_path / "c.txt").write_bytes(b"")
+ paths = collect_images(str(tmp_path))
+ names = [os.path.basename(p) for p in paths]
+ assert "a.tiff" in names
+ assert "b.tif" in names
+ assert "c.txt" not in names
+
+ def test_returns_sorted_list(self, tmp_path):
+ for name in ["z.tiff", "a.tiff", "m.tiff"]:
+ (tmp_path / name).write_bytes(b"")
+ paths = collect_images(str(tmp_path))
+ names = [os.path.basename(p) for p in paths]
+ assert names == sorted(names)
+
+ def test_empty_directory(self, tmp_path):
+ paths = collect_images(str(tmp_path))
+ assert paths == []
+
+ def test_custom_extensions(self, tmp_path):
+ (tmp_path / "a.png").write_bytes(b"")
+ (tmp_path / "b.tiff").write_bytes(b"")
+ paths = collect_images(str(tmp_path), extensions={".png"})
+ names = [os.path.basename(p) for p in paths]
+ assert "a.png" in names
+ assert "b.tiff" not in names
+
+ def test_returns_full_paths(self, tmp_path):
+ (tmp_path / "img.tiff").write_bytes(b"")
+ paths = collect_images(str(tmp_path))
+ assert len(paths) == 1
+ assert os.path.isabs(paths[0])
diff --git a/fibermorph/test/test_utils_units.py b/fibermorph/test/test_utils_units.py
new file mode 100644
index 0000000..b026a19
--- /dev/null
+++ b/fibermorph/test/test_utils_units.py
@@ -0,0 +1,45 @@
+"""Unit tests for resolution unit conversion (utils.units)."""
+
+import math
+
+import pytest
+
+from fibermorph.utils.units import px_per_unit, resolution_to_px_per_unit
+
+
+class TestPxPerUnit:
+ def test_pass_through_when_already_px_per_unit(self):
+ assert px_per_unit(5.556, per_pixel=False) == 5.556
+
+ def test_inverts_when_per_pixel(self):
+ # 0.18 µm/pixel -> 1/0.18 ≈ 5.556 px/µm
+ assert math.isclose(px_per_unit(0.18, per_pixel=True), 1 / 0.18)
+
+ def test_round_trip(self):
+ px = px_per_unit(4.25, per_pixel=False)
+ assert math.isclose(px_per_unit(1 / px, per_pixel=True), px)
+
+ @pytest.mark.parametrize("bad", [0, -1, -0.5])
+ def test_rejects_non_positive(self, bad):
+ with pytest.raises(ValueError):
+ px_per_unit(bad, per_pixel=False)
+
+
+class TestResolutionToPxPerUnit:
+ def test_section_tokens(self):
+ assert resolution_to_px_per_unit(5.556, "px_per_um") == 5.556
+ assert math.isclose(resolution_to_px_per_unit(0.18, "um_per_px"), 1 / 0.18)
+
+ def test_curvature_tokens(self):
+ assert resolution_to_px_per_unit(132.0, "px_per_mm") == 132.0
+ assert math.isclose(resolution_to_px_per_unit(1 / 132.0, "mm_per_px"), 132.0)
+
+ def test_gui_display_labels(self):
+ assert resolution_to_px_per_unit(5.556, "px/µm") == 5.556
+ assert math.isclose(resolution_to_px_per_unit(0.18, "µm/px"), 1 / 0.18)
+ assert resolution_to_px_per_unit(132.0, "px/mm") == 132.0
+ assert math.isclose(resolution_to_px_per_unit(0.01, "mm/px"), 100.0)
+
+ def test_unknown_units_raise(self):
+ with pytest.raises(ValueError):
+ resolution_to_px_per_unit(1.0, "furlongs")
diff --git a/fibermorph/utils/filesystem.py b/fibermorph/utils/filesystem.py
index d97faaa..bc6d1df 100644
--- a/fibermorph/utils/filesystem.py
+++ b/fibermorph/utils/filesystem.py
@@ -6,6 +6,8 @@
from typing import List, Union
import logging
+from PIL import Image, UnidentifiedImageError
+
logger = logging.getLogger(__name__)
@@ -92,9 +94,36 @@ def list_images(directory: Union[str, pathlib.Path]) -> List[pathlib.Path]:
"""
exts = [".tif", ".tiff"]
mainpath = pathlib.Path(directory)
- file_list = [p for p in pathlib.Path(mainpath).rglob("*") if p.suffix in exts]
-
- list.sort(file_list) # sort the files
- logger.debug(f"Found {len(file_list)} image files")
-
- return file_list
+
+ # First collect all files with the right extension
+ potential_files = [p for p in pathlib.Path(mainpath).rglob("*") if p.suffix in exts]
+
+ # Now validate each file
+ valid_files = []
+ for file_path in potential_files:
+ # Skip hidden files and macOS metadata
+ if file_path.name.startswith("._") or "__MACOSX" in file_path.parts:
+ logger.debug(f"Skipping system file: {file_path.name}")
+ continue
+
+ # Skip if not actually a file
+ if not file_path.is_file():
+ logger.debug(f"Skipping non-file: {file_path}")
+ continue
+
+ # Try to verify it's a valid image
+ try:
+ with Image.open(file_path) as img:
+ img.verify()
+ valid_files.append(file_path)
+ except UnidentifiedImageError:
+ logger.warning(f"Skipping invalid image file: {file_path.name}")
+ continue
+ except Exception as e:
+ logger.warning(f"Error checking file {file_path.name}: {e}")
+ continue
+
+ list.sort(valid_files) # sort the files
+ logger.debug(f"Found {len(valid_files)} valid image files out of {len(potential_files)} potential files")
+
+ return valid_files
diff --git a/fibermorph/utils/imaging.py b/fibermorph/utils/imaging.py
new file mode 100644
index 0000000..b8ac490
--- /dev/null
+++ b/fibermorph/utils/imaging.py
@@ -0,0 +1,52 @@
+"""Image utilities — resolution-aware downsampling.
+
+Cross-section scans are often 20 MP / tens of MB, far more pixels than a shape
+measurement needs. Downsampling to a target working resolution (and scaling the
+resolution together with the pixel count) keeps the µm measurements essentially
+unchanged (<0.5% on area/eccentricity in testing) while making processing much
+lighter.
+"""
+
+from __future__ import annotations
+
+import cv2
+import numpy as np
+
+# Target working resolution for cross-section analysis, in PIXELS PER µm. A
+# ~100 µm section is ~200 px across at 2.0 px/µm — ample for area/eccentricity/EFD.
+TARGET_SECTION_RES_MU = 2.0
+
+
+# Never shrink an image below this longest-side (px); protects already-small
+# images from being over-reduced.
+MIN_LONG_SIDE = 400
+
+
+def downsample_to_resolution(
+ gray: np.ndarray,
+ resolution_mu: float | None,
+ target: float = TARGET_SECTION_RES_MU,
+ min_long_side: int = MIN_LONG_SIDE,
+) -> tuple[np.ndarray, float | None]:
+ """Downsample ``gray`` so its working resolution is ~= ``target`` px/µm.
+
+ Never upsamples, and never shrinks the longest side below ``min_long_side``.
+ Returns ``(gray_out, effective_resolution_mu)`` where the effective
+ resolution reflects the actual (rounded) resize ratio, so µm measurements
+ computed at that resolution match the full-resolution values.
+
+ If ``resolution_mu`` is None, already <= target, or the image is already
+ small, it is returned unchanged.
+ """
+ if resolution_mu is None or resolution_mu <= target:
+ return gray, resolution_mu
+
+ factor = resolution_mu / target
+ if max(gray.shape[:2]) / factor < min_long_side:
+ return gray, resolution_mu
+
+ new_w = max(1, round(gray.shape[1] / factor))
+ new_h = max(1, round(gray.shape[0] / factor))
+ gray_ds = cv2.resize(gray, (new_w, new_h), interpolation=cv2.INTER_AREA)
+ effective_res = resolution_mu * (new_w / gray.shape[1])
+ return gray_ds, effective_res
diff --git a/fibermorph/utils/metadata.py b/fibermorph/utils/metadata.py
new file mode 100644
index 0000000..2fc5934
--- /dev/null
+++ b/fibermorph/utils/metadata.py
@@ -0,0 +1,23 @@
+"""Image collection utilities."""
+
+from __future__ import annotations
+
+import os
+
+TIFF_EXTENSIONS = {".tif", ".tiff"}
+RAW_EXTENSIONS = {".nef", ".cr2", ".arw", ".dng", ".orf", ".rw2"}
+IMAGE_EXTENSIONS = TIFF_EXTENSIONS | {".png", ".jpg", ".jpeg"}
+
+
+def collect_images(directory: str, extensions: set | None = None) -> list[str]:
+ """Return sorted list of image paths in a directory matching given extensions.
+
+ Defaults to TIFF, PNG, and RAW extensions.
+ """
+ if extensions is None:
+ extensions = IMAGE_EXTENSIONS | RAW_EXTENSIONS
+ paths = []
+ for fname in sorted(os.listdir(directory)):
+ if os.path.splitext(fname)[1].lower() in extensions:
+ paths.append(os.path.join(directory, fname))
+ return paths
diff --git a/fibermorph/utils/units.py b/fibermorph/utils/units.py
new file mode 100644
index 0000000..6e5708b
--- /dev/null
+++ b/fibermorph/utils/units.py
@@ -0,0 +1,51 @@
+"""Resolution unit handling.
+
+Internally fibermorph works in *pixels per unit*: pixels per µm for section
+analysis, pixels per mm for curvature. Microscopes and scale bars, however,
+often report the reciprocal (µm/pixel, mm/pixel). Entering one where the other
+is expected silently corrupts every calibrated measurement — the same
+µm/px-vs-px/µm mix-up that produced empty masks before.
+
+These helpers convert any accepted entry to the pixels-per-unit value the
+pipeline expects, so the GUI and CLI can offer both directions and never have
+to bake the direction into a (mislabellable) field name again.
+"""
+
+# Unit tokens whose value is per pixel (reciprocal — must be inverted).
+_PER_PIXEL_UNITS = {"um_per_px", "mm_per_px", "µm/px", "um/px", "mm/px"}
+# Unit tokens whose value is already pixels per (pass through).
+_PER_UNIT_UNITS = {"px_per_um", "px_per_mm", "px/µm", "px/um", "px/mm"}
+
+
+def px_per_unit(value: float, per_pixel: bool) -> float:
+ """Return pixels-per-unit for a resolution ``value``.
+
+ Parameters
+ ----------
+ value : float
+ The resolution number the user entered (must be > 0).
+ per_pixel : bool
+ True when ``value`` is /pixel (µm/px or mm/px) and must be
+ inverted; False when it is already pixels/ (px/µm or px/mm).
+ """
+ value = float(value)
+ if value <= 0:
+ raise ValueError("Resolution must be greater than 0.")
+ return (1.0 / value) if per_pixel else value
+
+
+def resolution_to_px_per_unit(value: float, units: str) -> float:
+ """Convert ``value`` given a unit token to pixels-per-unit.
+
+ ``units`` is one of ``px_per_um``, ``um_per_px``, ``px_per_mm``,
+ ``mm_per_px`` (the GUI display labels ``px/µm``, ``µm/px``, ``px/mm``,
+ ``mm/px`` are also accepted).
+ """
+ if units in _PER_PIXEL_UNITS:
+ return px_per_unit(value, per_pixel=True)
+ if units in _PER_UNIT_UNITS:
+ return px_per_unit(value, per_pixel=False)
+ raise ValueError(f"Unknown resolution units: {units!r}")
+
+
+__all__ = ["px_per_unit", "resolution_to_px_per_unit"]
diff --git a/fibermorph/workflows.py b/fibermorph/workflows.py
index 7ceed43..3bb441b 100644
--- a/fibermorph/workflows.py
+++ b/fibermorph/workflows.py
@@ -110,7 +110,7 @@ def curvature(
from .utils.timing import convert
from .analysis.curvature_pipeline import curvature_seq
from .analysis.parallel import tqdm_joblib
-
+
total_start = timer()
# create an output directory for the analyses
@@ -122,6 +122,10 @@ def curvature(
file_list = list_images(input_directory)
logger.info(f"Found {len(file_list)} images to analyze")
+ if not file_list:
+ logger.error(f"No valid TIFF images found in {input_directory}")
+ raise ValueError(f"No valid TIFF images found in {input_directory}")
+
with tqdm_joblib(
tqdm(desc="curvature", total=len(file_list), unit="files", miniters=1)
) as progress_bar:
@@ -140,6 +144,13 @@ def curvature(
for input_file in file_list
)
+ # Filter out any None results from failed files
+ im_df = [df for df in im_df if df is not None]
+
+ if not im_df:
+ logger.error("No images were successfully processed")
+ raise RuntimeError("No images were successfully processed")
+
summary_df = pd.concat(im_df)
jetzt = datetime.now()
@@ -165,7 +176,11 @@ def section(
resolution: float,
minsize: float,
maxsize: float,
- save_img: bool
+ save_img: bool,
+ use_sam2: bool = False,
+ sam2_checkpoint: str = "",
+ sam2_cfg: str = "",
+ extended_features: bool = False,
) -> bool:
"""Takes directory of grayscale images and analyzes cross-sectional properties.
@@ -195,7 +210,7 @@ def section(
from .utils.timing import convert
from .analysis.section_pipeline import section_seq
from .analysis.parallel import tqdm_joblib
-
+
total_start = timer()
file_list = list_images(input_directory)
@@ -211,7 +226,13 @@ def section(
) as progress_bar:
progress_bar.monitor_interval = 2
section_df = Parallel(n_jobs=jobs, verbose=0)(
- delayed(section_seq)(f, output_path, resolution, minsize, maxsize, save_img)
+ delayed(section_seq)(
+ f, output_path, resolution, minsize, maxsize, save_img,
+ use_sam2=use_sam2,
+ sam2_checkpoint=sam2_checkpoint,
+ sam2_cfg=sam2_cfg,
+ extended_features=extended_features,
+ )
for f in file_list
)
@@ -229,3 +250,81 @@ def section(
logger.info(f"section completed in {convert(total_time)}")
return True
+
+
+def batch(
+ section_directory: Union[str, pathlib.Path, None],
+ curvature_directory: Union[str, pathlib.Path, None],
+ main_output_path: Union[str, pathlib.Path],
+ jobs: int = 1,
+ resolution_mu: float = 4.25,
+ resolution_mm: float = 132.0,
+ minsize: float = 30.0,
+ maxsize: float = 150.0,
+ window_size: int = 50,
+ window_unit: str = "px",
+ save_img: bool = False,
+ use_sam2: bool = False,
+ sam2_checkpoint: str = "",
+ sam2_cfg: str = "",
+ extended_features: bool = True,
+) -> bool:
+ """Run batch analysis on section and/or curvature image directories.
+
+ Produces hair_analysis_per_image.csv (one row per image; no per-sample
+ aggregation) in a timestamped subdirectory of main_output_path.
+
+ Parameters
+ ----------
+ section_directory : directory of cross-section images, or None to skip
+ curvature_directory : directory of curvature images, or None to skip
+ main_output_path : parent directory for output
+ jobs : parallel jobs
+ resolution_mu : section resolution in pixels/µm
+ resolution_mm : curvature resolution in pixels/mm
+ minsize / maxsize : diameter filter in µm (section only)
+ window_size : Taubin sliding window size in pixels
+ window_unit : 'px' or 'mm'
+ save_img : save intermediate images
+ use_sam2 : attempt SAM2 segmentation (falls back to watershed)
+ sam2_checkpoint : path to SAM2 .pt weights file
+ sam2_cfg : SAM2 model config yaml
+ extended_features : EFD, Hu moments, radial profile, shape class (section)
+
+ Returns
+ -------
+ bool — True on success
+ """
+ from .utils.filesystem import make_subdirectory
+ from .utils.timing import convert
+ from .pipeline.batch import run_batch
+
+ total_start = timer()
+
+ jetzt = datetime.now()
+ timestamp = jetzt.strftime("%b%d_%H%M_")
+ out_dir = make_subdirectory(main_output_path, append_name=str(timestamp + "fibermorph_batch"))
+
+ run_batch(
+ section_dir = str(section_directory) if section_directory else None,
+ curv_dir = str(curvature_directory) if curvature_directory else None,
+ output_dir = str(out_dir),
+ resolution_mu = resolution_mu,
+ resolution_mm = resolution_mm,
+ min_diam = minsize,
+ max_diam = maxsize,
+ window_size = window_size,
+ window_unit = window_unit,
+ use_sam2 = use_sam2,
+ sam2_checkpoint = sam2_checkpoint,
+ sam2_cfg = sam2_cfg,
+ save_img = save_img,
+ extended_features = extended_features,
+ jobs = jobs,
+ )
+
+ total_end = timer()
+ total_time = total_end - total_start
+ tqdm.write(f"\n\nBatch analysis took: {convert(total_time)}\n\n")
+ logger.info(f"batch completed in {convert(total_time)}")
+ return True
diff --git a/poetry.lock b/poetry.lock
index 9515dee..c18dcbd 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
+# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand.
[[package]]
name = "altair"
@@ -295,7 +295,7 @@ description = "Python library for calculating contours of 2D quadrilateral grids
optional = true
python-versions = ">=3.9"
groups = ["main"]
-markers = "python_version == \"3.10\" and extra == \"viz\""
+markers = "python_version == \"3.10\" and (extra == \"viz\" or extra == \"gui\")"
files = [
{file = "contourpy-1.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:880ea32e5c774634f9fcd46504bf9f080a41ad855f4fef54f5380f5133d343c7"},
{file = "contourpy-1.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:76c905ef940a4474a6289c71d53122a4f77766eef23c03cd57016ce19d0f7b42"},
@@ -381,7 +381,7 @@ description = "Python library for calculating contours of 2D quadrilateral grids
optional = true
python-versions = ">=3.11"
groups = ["main"]
-markers = "python_version >= \"3.11\" and extra == \"viz\""
+markers = "python_version >= \"3.11\" and (extra == \"viz\" or extra == \"gui\")"
files = [
{file = "contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1"},
{file = "contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381"},
@@ -582,7 +582,7 @@ description = "Composable style cycles"
optional = true
python-versions = ">=3.8"
groups = ["main"]
-markers = "extra == \"viz\""
+markers = "extra == \"viz\" or extra == \"gui\""
files = [
{file = "cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30"},
{file = "cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c"},
@@ -659,7 +659,7 @@ description = "Tools to manipulate font files"
optional = true
python-versions = ">=3.9"
groups = ["main"]
-markers = "extra == \"viz\""
+markers = "extra == \"viz\" or extra == \"gui\""
files = [
{file = "fonttools-4.60.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9a52f254ce051e196b8fe2af4634c2d2f02c981756c6464dc192f1b6050b4e28"},
{file = "fonttools-4.60.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7420a2696a44650120cdd269a5d2e56a477e2bfa9d95e86229059beb1c19e15"},
@@ -907,7 +907,7 @@ files = [
[package.dependencies]
attrs = ">=22.2.0"
-jsonschema-specifications = ">=2023.03.6"
+jsonschema-specifications = ">=2023.3.6"
referencing = ">=0.28.4"
rpds-py = ">=0.7.1"
@@ -938,7 +938,7 @@ description = "A fast implementation of the Cassowary constraint solver"
optional = true
python-versions = ">=3.10"
groups = ["main"]
-markers = "extra == \"viz\""
+markers = "extra == \"viz\" or extra == \"gui\""
files = [
{file = "kiwisolver-1.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b4b4d74bda2b8ebf4da5bd42af11d02d04428b2c32846e4c2c93219df8a7987b"},
{file = "kiwisolver-1.4.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fb3b8132019ea572f4611d770991000d7f58127560c4889729248eb5852a102f"},
@@ -1170,7 +1170,7 @@ description = "Python plotting package"
optional = true
python-versions = ">=3.10"
groups = ["main"]
-markers = "extra == \"viz\""
+markers = "extra == \"viz\" or extra == \"gui\""
files = [
{file = "matplotlib-3.10.7-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:7ac81eee3b7c266dd92cee1cd658407b16c57eed08c7421fa354ed68234de380"},
{file = "matplotlib-3.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:667ecd5d8d37813a845053d8f5bf110b534c3c9f30e69ebd25d4701385935a6d"},
@@ -1454,6 +1454,31 @@ files = [
{file = "numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010"},
]
+[[package]]
+name = "opencv-python-headless"
+version = "4.11.0.86"
+description = "Wrapper package for OpenCV python bindings."
+optional = false
+python-versions = ">=3.6"
+groups = ["main"]
+files = [
+ {file = "opencv-python-headless-4.11.0.86.tar.gz", hash = "sha256:996eb282ca4b43ec6a3972414de0e2331f5d9cda2b41091a49739c19fb843798"},
+ {file = "opencv_python_headless-4.11.0.86-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:48128188ade4a7e517237c8e1e11a9cdf5c282761473383e77beb875bb1e61ca"},
+ {file = "opencv_python_headless-4.11.0.86-cp37-abi3-macosx_13_0_x86_64.whl", hash = "sha256:a66c1b286a9de872c343ee7c3553b084244299714ebb50fbdcd76f07ebbe6c81"},
+ {file = "opencv_python_headless-4.11.0.86-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6efabcaa9df731f29e5ea9051776715b1bdd1845d7c9530065c7951d2a2899eb"},
+ {file = "opencv_python_headless-4.11.0.86-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e0a27c19dd1f40ddff94976cfe43066fbbe9dfbb2ec1907d66c19caef42a57b"},
+ {file = "opencv_python_headless-4.11.0.86-cp37-abi3-win32.whl", hash = "sha256:f447d8acbb0b6f2808da71fddd29c1cdd448d2bc98f72d9bb78a7a898fc9621b"},
+ {file = "opencv_python_headless-4.11.0.86-cp37-abi3-win_amd64.whl", hash = "sha256:6c304df9caa7a6a5710b91709dd4786bf20a74d57672b3c31f7033cc638174ca"},
+]
+
+[package.dependencies]
+numpy = [
+ {version = ">=1.21.2", markers = "platform_system != \"Darwin\" and python_version >= \"3.10\""},
+ {version = ">=1.21.4", markers = "python_version >= \"3.10\" and platform_system == \"Darwin\""},
+ {version = ">=1.23.5", markers = "python_version >= \"3.11\""},
+ {version = ">=1.26.0", markers = "python_version >= \"3.12\""},
+]
+
[[package]]
name = "packaging"
version = "25.0"
@@ -1873,7 +1898,7 @@ description = "pyparsing - Classes and methods to define and execute parsing gra
optional = true
python-versions = ">=3.9"
groups = ["main"]
-markers = "extra == \"viz\""
+markers = "extra == \"viz\" or extra == \"gui\""
files = [
{file = "pyparsing-3.2.5-py3-none-any.whl", hash = "sha256:e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e"},
{file = "pyparsing-3.2.5.tar.gz", hash = "sha256:2df8d5b7b2802ef88e8d016a2eb9c7aeaa923529cd251ed0fe4608275d4105b6"},
@@ -2411,6 +2436,29 @@ dev = ["cython-lint (>=0.12.2)", "doit (>=0.36.0)", "mypy (==1.10.0)", "pycodest
doc = ["intersphinx_registry", "jupyterlite-pyodide-kernel", "jupyterlite-sphinx (>=0.19.1)", "jupytext", "linkify-it-py", "matplotlib (>=3.5)", "myst-nb (>=1.2.0)", "numpydoc", "pooch", "pydata-sphinx-theme (>=0.15.2)", "sphinx (>=5.0.0,<8.2.0)", "sphinx-copybutton", "sphinx-design (>=0.4.0)"]
test = ["Cython", "array-api-strict (>=2.3.1)", "asv", "gmpy2", "hypothesis (>=6.30)", "meson", "mpmath", "ninja ; sys_platform != \"emscripten\"", "pooch", "pytest (>=8.0.0)", "pytest-cov", "pytest-timeout", "pytest-xdist", "scikit-umfpack", "threadpoolctl"]
+[[package]]
+name = "seaborn"
+version = "0.13.2"
+description = "Statistical data visualization"
+optional = true
+python-versions = ">=3.8"
+groups = ["main"]
+markers = "extra == \"viz\" or extra == \"gui\""
+files = [
+ {file = "seaborn-0.13.2-py3-none-any.whl", hash = "sha256:636f8336facf092165e27924f223d3c62ca560b1f2bb5dff7ab7fad265361987"},
+ {file = "seaborn-0.13.2.tar.gz", hash = "sha256:93e60a40988f4d65e9f4885df477e2fdaff6b73a9ded434c1ab356dd57eefff7"},
+]
+
+[package.dependencies]
+matplotlib = ">=3.4,<3.6.1 || >3.6.1"
+numpy = ">=1.20,<1.24.0 || >1.24.0"
+pandas = ">=1.2"
+
+[package.extras]
+dev = ["flake8", "flit", "mypy", "pandas-stubs", "pre-commit", "pytest", "pytest-cov", "pytest-xdist"]
+docs = ["ipykernel", "nbconvert", "numpydoc", "pydata_sphinx_theme (==0.10.0rc2)", "pyyaml", "sphinx (<6.0.0)", "sphinx-copybutton", "sphinx-design", "sphinx-issues"]
+stats = ["scipy (>=1.7)", "statsmodels (>=0.12)"]
+
[[package]]
name = "six"
version = "1.17.0"
@@ -2763,11 +2811,12 @@ files = [
watchmedo = ["PyYAML (>=3.10)"]
[extras]
-gui = ["streamlit"]
+gui = ["matplotlib", "seaborn", "streamlit"]
raw = ["rawpy"]
-viz = ["matplotlib"]
+sam2 = []
+viz = ["matplotlib", "seaborn"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.10,<3.13"
-content-hash = "31800c9e794c4a66e35d6ca7e532db2eea9c0a25c7f458471beaa055b37b88c4"
+content-hash = "c283bb07f4017deb09c61c694d9d8900b2d4a27ceef4ec9a486458c30ef4b22f"
diff --git a/pyproject.toml b/pyproject.toml
index 76595c3..1f2ebd5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -8,7 +8,7 @@ build-backend = "poetry.core.masonry.api"
[tool.poetry]
name = "fibermorph"
-version = "1.0.1"
+version = "2.0.0"
description = "Interactive toolkit for analyzing hair fiber morphology with GUI and CLI"
authors = ["tinalasisi "]
license = "LICENSE"
@@ -18,17 +18,19 @@ repository = "https://github.com/lasisilab/fibermorph"
[tool.poetry.dependencies]
python = ">=3.10,<3.13"
-numpy = "^1.26.4"
+numpy = ">=1.26.4,<3"
joblib = "^1.3.2"
pandas = "^2.2.0"
-pillow = "^10.2.0"
+pillow = ">=10.2.0,<13"
requests = "^2.31.0"
-scipy = "^1.8"
+scipy = ">=1.8"
tqdm = "^4.66.1"
-scikit-image = "^0.22.0"
-rawpy = {version = "^0.19.0", optional = true}
-matplotlib = {version = "^3.8.2", optional = true}
-streamlit = {version = "^1.28.0", optional = true}
+scikit-image = ">=0.22.0"
+opencv-python-headless = ">=4.8"
+rawpy = {version = ">=0.19.0", optional = true}
+matplotlib = {version = ">=3.8.2", optional = true}
+seaborn = {version = ">=0.13", optional = true}
+streamlit = {version = ">=1.28.0", optional = true}
[tool.poetry.scripts]
fibermorph = "fibermorph.cli:main"
@@ -47,6 +49,7 @@ pre-commit = "^3.6.0"
build-frontend = "build"
[tool.poetry.extras]
-raw = ["rawpy"]
-viz = ["matplotlib"]
-gui = ["streamlit"]
+raw = ["rawpy"]
+viz = ["matplotlib", "seaborn"]
+gui = ["streamlit", "matplotlib", "seaborn"]
+sam2 = [] # install separately: pip install git+https://github.com/facebookresearch/segment-anything-2
diff --git a/requirements.txt b/requirements.txt
index 8e013f1..3dc87bc 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,6 +1,3 @@
-# Requirements for Streamlit Cloud deployment
-# Install fibermorph from the current repository
--e .
-
-# Streamlit is required for the GUI
-streamlit>=1.28.0
+# Requirements for Streamlit Cloud deployment.
+# Installs the package with GUI extras (streamlit, matplotlib, seaborn).
+-e .[gui]
\ No newline at end of file
diff --git a/streamlit_app.py b/streamlit_app.py
index 886267e..4baced4 100644
--- a/streamlit_app.py
+++ b/streamlit_app.py
@@ -1,5 +1,15 @@
-"""Entry point for Streamlit Cloud deployment."""
-from fibermorph.gui.app import main
+"""Entry point for Streamlit Cloud deployment.
-if __name__ == "__main__":
- main()
+Executes fibermorph/gui/app.py in its own namespace on every Streamlit
+re-run (the app script is top-level code, not a main() function). The
+globals dict pins __file__ to app.py's real path so the app's
+__file__-relative lookups (e.g. the SAM2 checkpoint resolver) work.
+"""
+from pathlib import Path
+
+_APP_PATH = Path(__file__).parent / "fibermorph" / "gui" / "app.py"
+
+exec( # noqa: S102
+ compile(_APP_PATH.read_text(), str(_APP_PATH), "exec"),
+ {"__file__": str(_APP_PATH), "__name__": "__main__"},
+)