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 @@ -[![Test](https://github.com/lasisilab/fibermorph/actions/workflows/test.yaml/badge.svg)](https://github.com/lasisilab/fibermorph/actions/workflows/test.yaml) [![PyPI version](https://img.shields.io/pypi/v/fibermorph.svg)](https://pypi.org/project/fibermorph/) +[![Test](https://github.com/lasisilab/fibermorph/actions/workflows/test.yaml/badge.svg)](https://github.com/lasisilab/fibermorph/actions/workflows/test.yaml) [![PyPI version](https://img.shields.io/pypi/v/fibermorph.svg)](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 --