diff --git a/.lfsconfig b/.lfsconfig index cafb65d..b6799f7 100644 --- a/.lfsconfig +++ b/.lfsconfig @@ -16,5 +16,5 @@ # # Note that a bare `git lfs pull` will NOT fetch it -- only -I/--include # overrides fetchexclude. That's deliberate: no accidental 2.65 GB pulls. -[lfs] - fetchexclude = weights/birefnet_leaf.pth +# [lfs] +# fetchexclude = weights/birefnet_leaf.pth diff --git a/README.md b/README.md index f557c30..0239c35 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,39 @@ # MATS — Morphometric Analysis Toolbox Measure leaf **area, length, and width** in real-world units from a photo of -leaves laid on a printed calibration template. MATS finds four fiducial markers -with RF-DETR, corrects perspective, segments each leaf with a fast Otsu -threshold by default (or the heavier BiRefNet model for tougher backgrounds), -and writes a measurements CSV. +leaves laid on a printed calibration template. -Pipeline in one line: **detect markers → perspective-correct → segment leaf → -measure → CSV**. +MATS has four main steps: +1. Locate four fiducial markers using an RF-DETR detection model. +2. Applies a transform to undo any perspective distortion. +3. Segments each leaf, using either a fast Otsu threshold (default option) or a BiRefNet segmentation model for tougher backgrounds. +4. Writes out a CSV of the measurements. > Companion code for the manuscript (target journal: *Plant Phenomics*). > BiRefNet is optional and runs entirely from a locally installed checkpoint (see [Model weights](#model-weights)). > For USDA users the model weights are hosted on Agdatacommons and the pipeline is available on SciNET +## Table of Contents + +1. [Installation](#installation) +3. [Model weights](#model-weights) +4. 2. [Setup??](#) + +5. [Outputs](#outputs) +6. [Running on a compute cluster](#running-on-a-compute-cluster) +7. [How it works](#how-it-works) +8. [Troubleshooting](#troubleshooting) +9. [Citing](#citing) +10. [License](#license) + --- -## Install -MATS needs only Python ≥ 3.9 — QR codes are decoded with OpenCV, so the default -install pulls everything from wheels with **no system libraries and no conda + +## Installation + +MATS requires only Python ≥ 3.9; the recommended `pip` installation method +pulls all required packages from wheels with **no system libraries and no conda required**. **pip (recommended):** @@ -29,8 +44,9 @@ cd Morphometric-Analysis-Toolbox-for-Segmentation pip install -e ".[app]" # ".[app]" adds the Streamlit GUI ``` -**Robust QR reading (optional).** OpenCV decodes clean codes reliably. For -tougher photos (glare, skew, blur), add the `pyzbar` + `qreader` fallbacks: +**Enhanced QR reading (optional).** OpenCV reads QR codes well when they are oriented correctly and clearly +visible. For images with any issues affecting the QR codes (glare, skew, blur) you can add the `pyzbar` + `qreader` fallbacks. +`pyzbar` requires the system library `zbar`: ```bash pip install -e ".[app,qr]" @@ -49,13 +65,36 @@ If a code can't be read, the pipeline continues — pass the finished sheet size with `--sheet-dimensions` (for example, `--sheet-dimensions 12x12in`), so enhanced QR is a convenience rather than a requirement. -Then fetch the model weights once and confirm the environment: +Finally, fetch the model weights and confirm the environment: ```bash mats fetch-weights # fetches the ~134 MB RF-DETR checkpoint (mandatory, default) mats fetch-weights --only birefnet --source lfs # optional: explicitly fetch the ~2.65 GB BiRefNet checkpoint mats doctor # checks weights, GPU/CPU device, QR backends ``` +--- + +## Model weights + +The checkpoints for both the marker detection model and the leaf segmentation model are located in this repository: + +| Model | File | Size | +|---|---|---| +| RF-DETR marker detector | `rf_detr_marker.pth` | ~134 MB | +| BiRefNet leaf segmenter | `birefnet_leaf.pth` | ~2.65 GB | + +By default, only the RF-DETR model checkpoint will be downloaded. +The BiRefNet checkpoint is LFS-tracked but excluded from the default clone, so it is +downloaded only through an explicit action: + +- **Otsu (default)** — needs no BiRefNet checkpoint and never downloads one. +- **BiRefNet (optional)** — fetch explicitly with + `mats fetch-weights --only birefnet --source lfs`, or use the setup page. +- **Shared filesystem** — set `MATS_WEIGHTS_DIR` (e.g. a SCINet `/project` path) + to read weights in place with no per-user copy. + +Full details and checksums: [docs/weights.md](docs/weights.md). + ### Why the first installation is lightweight @@ -86,7 +125,7 @@ a run. --- -## Choose your path +## Running MATS - **I want to click buttons →** [Using the app](#using-the-app) - **I want to script it →** [Using the command line](#using-the-command-line) @@ -95,21 +134,22 @@ Both run the exact same pipeline and produce the same measurements. --- -## Using the app +### Using the app + +MATS comes with a point-and-click user interface. To open it, simply run: ```bash mats app ``` -This opens the Streamlit GUI in your browser. From there: +This will open the Streamlit app locally in your web browser. From there: 1. **Pick images** — a local folder, or drag-and-drop uploads. -2. **Set the scale** — enter the finished printed sheet's width, height, and - unit (for example, `12 x 12 in`). MATS applies the Template Creator margin - rules and derives the marker-centre calibration area automatically. Or tick - **Variable dimensions, read QR code** to read each image's calibration. -3. **Choose segmentation** — Otsu threshold (fast, default) or BiRefNet (accurate when its optional local checkpoint is installed). -4. **Choose workers** — the app detects the CPUs assigned to it. One worker uses +2. **Set the scale** — enter the printed sheet's width, height, and unit (e.g. + `10.5 x 9.5 in`), or tick **Variable dimensions, read QR code** to read it + from each image's template QR code automatically. +3. **Choose segmentation** — Otsu threshold (fast, default) or BiRefNet (accurate, must have local model checkpoint installed). +4. **Choose workers** — the app detects the number of CPUs available to it. One worker uses CUDA/MPS when available; two or more workers use parallel CPU processing and disable CUDA/MPS for that run. A colored warning light shows CPU allocation; counts above 75% require a one-run break-glass acknowledgement. @@ -129,7 +169,7 @@ photograph it flat. See [docs/templates.md](docs/templates.md). --- -## Using the command line +### Using the command line ```bash mats run -i ./images -o ./out -r results.csv --sheet-dimensions 12x12in @@ -198,29 +238,11 @@ A `leaf_morpho_failures.csv` records per-image warnings and failures. --- -## Model weights - -The checkpoints are tracked in this repository with Git LFS: - -| Model | File | Size | -|---|---|---| -| RF-DETR marker detector | `rf_detr_marker.pth` | ~134 MB | -| BiRefNet leaf segmenter | `birefnet_leaf.pth` | ~2.65 GB | - -RF-DETR is available in a normal checkout. BiRefNet is LFS-tracked but excluded -from the default clone, so it is downloaded only through an explicit action: - -- **Otsu (default)** — needs no BiRefNet checkpoint and never downloads one. -- **BiRefNet (optional)** — fetch explicitly with - `mats fetch-weights --only birefnet --source lfs`, or use the setup page. -- **Shared filesystem** — set `MATS_WEIGHTS_DIR` (e.g. a SCINet `/project` path) - to read weights in place with no per-user copy. -Full detail and checksums: [docs/weights.md](docs/weights.md). --- -## On a cluster (HPC / Open OnDemand) +## Running on a compute cluster An Open OnDemand Batch Connect app that serves the GUI on a compute node is in [deploy/ondemand/mats/](deploy/ondemand/mats/). See its README and diff --git a/src/mats/weights.py b/src/mats/weights.py index 4d47efe..ec40196 100644 --- a/src/mats/weights.py +++ b/src/mats/weights.py @@ -2,17 +2,15 @@ The checkpoints are large (RF-DETR ~134 MB, BiRefNet ~2.65 GB) and are delivered through two independent channels, plus a shared-filesystem escape -hatch -- :mod:`mats.paths` resolves whichever produced a real file: +hatch -- :mod:`mats.paths` resolves to whichever channel produces a real file: 1. **Hugging Face Hub** -- the default public host. Free, no account needed, but unreachable on some institutional networks (notably USDA's). -2. **Git LFS, in this repository** -- RF-DETR is fetched on every - ``git clone`` (mandatory for every run). BiRefNet is committed too, but - excluded from the default clone/fetch via ``.lfsconfig`` - (``lfs.fetchexclude``), so a plain clone stays small; it's pulled - explicitly through this module (or the BiRefNet setup page) when needed. - This channel exists because Hugging Face is not reachable from every - collaborator's network. +2. **Git LFS** -- By default, MATS will only pull the RF-DETR checkpoint file + when fetching weights via ``git-lfs pull``. To pull the larger BiRefNet file, + you can run ``mats fetch-weights --only birefnet --source lfs`` or just + ``git-lfs pull``. This channel exists because Hugging Face is not reachable + from every collaborator's network. 3. **A shared/mounted filesystem** (e.g. USDA SCINet ``/project``) -- point ``MATS_WEIGHTS_DIR`` at it and the weights are read in place, no download, for anyone who can mount it. @@ -207,7 +205,7 @@ def get_weight_status(name): checkout = _checkout_target(name) if checkout is not None and looks_like_lfs_pointer(checkout): - detail = "Excluded from `git clone` by design -- fetch it via Git LFS or Hugging Face." + detail = "Not yet fetched via Git LFS -- fetch it via the app, `mats fetch-weights --only birefnet` or Hugging Face." return WeightStatus(name, checkout, "missing", detail, 0, spec["size_bytes"], sources) target = _download_target(name) @@ -233,7 +231,7 @@ def _manual_instructions(): " (e.g. a shared SCINet /project path).\n" " - Or set RF_DETR_MARKER_CHECKPOINT / BIREFNET_CHECKPOINT to specific files.\n" " - Or, from a Git checkout with Git LFS installed:\n" - " git lfs pull --include=\"weights/birefnet_leaf.pth\"\n\n" + " git lfs pull\n\n" "See docs/weights.md.", file=sys.stderr, ) @@ -334,8 +332,13 @@ def _emit_lfs_progress(progress_path, progress_callback, fallback_total, last_do def _download_from_lfs(name, progress_callback=None): - """Fetch one checkpoint via `git lfs pull --include`, overriding this file's - .lfsconfig fetchexclude for just this invocation. + """Fetch one checkpoint via Git LFS. + + For BiRefNet, runs a plain ``git lfs pull`` (no flags) so the large + checkpoint is fetched without affecting other files. For all other + checkpoints, runs ``git lfs pull --exclude weights/birefnet_leaf.pth`` + so the 2.65 GB BiRefNet file is never pulled as a side-effect of an + unrelated weight update. Writes into the checkout's weights/ directory -- that's where Git LFS smudges content, and it's tier 3 of paths.py's resolution order, so the @@ -361,6 +364,17 @@ def _download_from_lfs(name, progress_callback=None): rel_path = f"weights/{spec['filename']}" print(f"Fetching {rel_path} via Git LFS -> {target}") + # For BiRefNet use a plain `git lfs pull` (no flags) -- without a + # fetchexclude in .lfsconfig a bare pull fetches all LFS files, which is + # what we want for this explicit opt-in download. + # For everything else, exclude the large BiRefNet checkpoint so it is + # never pulled as an unintended side-effect. + birefnet_rel = f"weights/{_MANIFEST['birefnet']['filename']}" + if name == "birefnet": + lfs_cmd = ["git", "lfs", "pull"] + else: + lfs_cmd = ["git", "lfs", "pull", "--exclude", birefnet_rel] + with tempfile.TemporaryDirectory() as tmp: progress_path = Path(tmp) / "progress" log_path = Path(tmp) / "output.log" @@ -370,7 +384,7 @@ def _download_from_lfs(name, progress_callback=None): # deadlocking the child if it writes enough to fill the OS pipe buffer. with open(log_path, "w") as log_file: proc = subprocess.Popen( - ["git", "lfs", "pull", "--include", rel_path], + lfs_cmd, cwd=_REPO_ROOT, env=env, stdout=log_file, stderr=subprocess.STDOUT, ) @@ -387,7 +401,8 @@ def _download_from_lfs(name, progress_callback=None): _emit(progress_callback, "verifying", 0, size) if not target.is_file() or looks_like_lfs_pointer(target): - print(f"error: {target} is still not a real file after `git lfs pull`.", file=sys.stderr) + print(f"error: {target} is still not a real file after `git-lfs pull`.", file=sys.stderr) + print(f"Please verify that you have Git LFS configured by running `git-lfs install`.") return False actual_size = target.stat().st_size @@ -516,12 +531,12 @@ def ensure_weight(name): f"{spec['filename']} not found and auto-fetch is disabled " f"({_AUTO_FETCH_DISABLED} is set). Pre-stage the weights, or run " f"`mats fetch-weights --only {name}` after unsetting {_AUTO_FETCH_DISABLED} " - f"(from a Git checkout, `git lfs pull --include=\"weights/{spec['filename']}\"` " - f"also works)." + f"(from a Git checkout, `git lfs pull` fetches all weights including birefnet, or " + f"`git lfs pull --exclude weights/{_MANIFEST['birefnet']['filename']}` fetches all others)." ) - # A checkout excludes BiRefNet from the default clone (.lfsconfig); a - # pointer stub here means "not yet pulled", not an error -- fetch it. + # A pointer stub for BiRefNet means the user hasn't run `git lfs pull` + # for it yet (it's large and opt-in) -- fetch it rather than failing. checkout = _checkout_target(name) if checkout is not None and looks_like_lfs_pointer(checkout) and _download_from_lfs(name): return checkout