Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .lfsconfig
Original file line number Diff line number Diff line change
Expand Up @@ -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
104 changes: 63 additions & 41 deletions README.md
Original file line number Diff line number Diff line change
@@ -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):**
Expand All @@ -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]"
Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
51 changes: 33 additions & 18 deletions src/mats/weights.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -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,
)
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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,
)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading