Skip to content

Fibermorph dev - #22

Closed
ezhil384 wants to merge 68 commits into
lasisilab:mainfrom
ezhil384:fibermorph-dev
Closed

ezhil384 wants to merge 68 commits into
lasisilab:mainfrom
ezhil384:fibermorph-dev

Conversation

@ezhil384

@ezhil384 ezhil384 commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

tinalasisi and others added 30 commits November 7, 2025 15:28
Attempting to fix the zip file upload errors.
- README: remove HuggingFace Spaces YAML frontmatter (was rendering as
  raw text on GitHub)
- packages.txt: replace libgl1-mesa-glx (removed in Debian bookworm/trixie)
  with libgl1
- requirements.txt: use -e ".[gui]" to include matplotlib and seaborn for
  the Streamlit Cloud visualizations tab
- streamlit_app.py: replace main() call (removed in new app.py) with exec()
  pattern so Streamlit's re-run model works correctly
- test.yaml: drop poetry in favour of plain pip (avoids poetry.lock sync
  issues after adding opencv-python-headless); add libgl1/libglib2.0-0 apt
  step; run tests on feature/** branches too
- conventional-prs.yaml: mark title check continue-on-error in fork
  (advisory only; lasisilab enforces it on the upstream PR)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…flict

libglib2.0-0 from Bullseye repos requires libffi7 which is not available
on the Trixie-based Streamlit Cloud image. opencv-python-headless needs
no system GL libraries, so packages.txt can be empty.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…as package names

apt-get was trying to install each word of the comment as a package.
Streamlit Cloud now runs Python 3.14.4. The previous caret constraints
(^1.26.4 for numpy, ^0.22 for scikit-image, ^10.2 for pillow) forced pip
to use source distributions that fail to compile on Python 3.14. Widening
to >=X allows pip to resolve pre-built wheels for newer minor versions.
Also remove quotes around .[gui] in requirements.txt for uv compatibility.
app.py was calling segment_section() with sam2_checkpoint=/sam2_cfg=
(hair_analysis names) but section_sam2.py defines checkpoint=/model_cfg=.
- TestComputeEFD: access efd["efd_coeffs"] instead of treating
  return value as ndarray directly
- TestComputeRadialProfile: update key names to radial_* prefix,
  replace missing radius_range_mu with computed radial_max - radial_min,
  add n_radial_peaks to expected key set
- TestExtractFeaturesFromArray: fix call signature (resolution_mu=,
  source_name=), adapt assertions for dict return type, fix efd key
  count (51 not 40), fix hu prefix (no underscore)
- TestClassifyShape: add n_radial_peaks and asymmetry_index to all
  feature dicts (now required by classify_shape)
- test_section_pipeline: fix hu column prefix from hu_ to hu
- TestCurlIndex: make _curved_skel dense enough to avoid disconnected
  segments causing nan; fix assertion direction (chord/arc metric gives
  lower values for curved lines, not higher)

Agent-Logs-Url: https://github.com/ezhil384/fibermorph/sessions/86945c40-fef0-4344-97fa-c44ac3c5e80d

Co-authored-by: ezhil384 <80913303+ezhil384@users.noreply.github.com>
…ctored-version

fix(tests): align test suite with refactored v2 API
Merges Abhiraj Ezhil's v2: SAM2 section segmentation (optional), extended
shape/curvature metrics, batch pipeline, rewritten Streamlit GUI with SLURM
job builder, Docker, CI rework. PR: lasisilab#16

Conflict resolutions preserve the fibermorph-dev improvements:
- workflows.py / curvature_pipeline.py: student's v2 structure with dev's
  image-validation guards and error handling re-applied.
- io/readers.py and utils/filesystem.py keep the dev versions (student's
  changes there were line-ending churn only).
- gui/app.py takes the student's rewrite wholesale; dev's zip/URL validation
  targets functions that no longer exist in the new GUI (noted as a
  feature regression to revisit).
- Normalized all CRLF line endings introduced by the fork back to LF and
  added .gitattributes to prevent recurrence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
list_images on the dev branch verifies images with PIL, so zero-byte
touch() fixtures are correctly rejected. Tests now write real minimal
TIFFs and additionally assert that corrupt/empty/AppleDouble .tif files
are excluded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- diameter_mean_mu unit conversion: multiply by µm/px instead of divide
  (was off by (1000/resolution)^2, ~57x at the 132 px/mm default); add a
  two-resolution regression test that locks the conversion direction and
  fails against the inverted form.
- streamlit_app.py: exec app.py with __file__/__name__ pinned to the app's
  real path so its __file__-relative lookups (SAM2 checkpoint resolver)
  resolve inside the package instead of the repo root.
- gui/__init__.py: main() called app.main(), deleted by the v2 refactor
  (the app is now a top-level Streamlit script); delegate to launcher,
  which runs it via streamlit run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Restore the deliberate Python <3.13 upper bound (lab decision in 1.0.1;
  the fork widened to >=3.10 while its own CI matrix, README, and
  .python-version still say 3.10-3.12, and 3.13/3.14 are untested).
- Regenerate poetry.lock: the fork edited pyproject (opencv-python-headless,
  seaborn, requests, scipy, widened numpy/pillow) without relocking, so
  poetry install failed on content-hash mismatch.
- Drop continue-on-error from conventional-prs.yaml (would have made PR
  title enforcement advisory upstream).
- Strip the UTF-8 BOM the fork introduced in README.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The section resolution parameter is pixels-per-µm (area_px/resolution_mu²,
min_diam*resolution_mu), and section_pipeline.py + the CLI already say so.
But the GUI fields and two docstrings said 'µm/px' — the inverse. Following
that label and entering the physical µm/pixel value made the size filter
reject the real cross-sections and return empty masks.

- Relabel the two GUI inputs and the shape_analysis/batch docstrings to
  'px/µm', and add a help tooltip: enter the reciprocal of a µm/pixel scale
  (e.g. 0.18 µm/pixel -> 5.556).
- section_sam2._watershed_segment now logs a specific warning when the size
  filter rejects every candidate, naming the px window, the resolution_mu,
  and the likely calibration cause. Algorithm/output unchanged.
- Quick Test surfaces a visible st.warning listing any images where no
  cross-section was detected, pointing at the resolution setting — so the
  failure is no longer silent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per the lab's decision, the app no longer guesses within- vs
between-individual grouping from filenames (removed parse_metadata from the
Quick Test flow). Each result row is now just the per-image measurements
plus the source filename; group downstream from the CSV.

Distribution figures summarise across images, so they now render only when
2+ images of a type are uploaded (a single image previously produced
degenerate boxplots/heatmaps and empty grouping figures). With one image the
app shows the per-image table + CSV and a short note. The grouping-dependent
figures (Region Comparison, Within-Individual Variability) are skipped in
Quick Test since the app no longer parses groups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The tool now does image analysis (segment -> measure) and leaves grouped
statistics (within/between individual) to the user downstream.

- Replace the fragile regex parse_metadata with parse_canonical_name: a lenient
  split of the enforced convention Individual_Sample_Side (e.g. Y_5_B.tif).
  Missing trailing fields come back blank; extra tokens are ignored; it never
  raises and never groups — the labels are attached as columns only.
- Batch pipeline: drop _aggregate_per_sample and hair_analysis_per_sample.csv.
  run_batch now returns a single per-image DataFrame with the source filename
  and individual/sample/side label columns. Docstrings/return types updated.
- Update tests to the new behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ownsampling

- Quick Test documents the Individual_Sample_Side naming convention and attaches
  the parsed labels as columns (no grouping in the app).
- Replace the grouped figure catalogue (in Quick Test and the Results tab) with a
  single simple ungrouped histogram of area + eccentricity (sections) / mean
  curvature + curl index (curvature), shown only with 2+ images. Results tab now
  shows per-image tables + that histogram; per-sample loading/figures removed.
- Add utils/imaging.downsample_to_resolution: given the px/µm resolution, large
  scans are downsampled to a ~2 px/µm working resolution with resolution scaled
  to match, so µm measurements are preserved (<0.5%) while a 20MP scan drops to
  ~2.6MP and processing is several times faster. Wired into the section GUI path
  (never upsamples; never shrinks below 400px longest side).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Since the tool is per-image and already records the source filename, parsing it
into individual/sample/side columns added no information and presumed a naming
scheme. Removed parse_canonical_name and the label columns everywhere (batch,
GUI, Results tab). The filename is preserved on every row; group downstream
however the study needs. The GUI still notes that filenames are recorded so
users can name for their own downstream grouping — but the app parses nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing both image types' uploads and settings at once was confusing and let
users mix types by accident. Each type now has its own tab with only its own
uploader, settings, run button, results table, ungrouped histogram, and CSV
download. Section results keep independent session state from curvature. The
standalone Segmentation preview tab is folded into the Cross-Section tab (its
masks appear under an expander after the results), removing the cross-tab
'run Quick Test first' dependency.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
From a multi-agent review of the tab-split GUI:
- Curvature table/histogram referenced columns the pipeline never produces
  (curv_mean/curv_median); the real names are curv_mean_mean/curv_median_mean,
  so the curvature results were silently blank. Fixed the column mapping and
  both histogram calls.
- Resolution number_inputs had no lower bound; entering 0 raised a cryptic
  'float division by zero' on every image. Added min_value guards.
- A re-run that detected nothing left the previous run's table/CSV/masks on
  screen next to the error. Now clears section/curvature results on empty runs.
- _metric_histograms crashed matplotlib on non-finite values; now drops
  inf/-inf alongside NaN.
- Segmentation mask store was a filename-keyed dict, so duplicate upload names
  silently clobbered a mask; changed to a list. Added an upfront warning when
  uploaded filenames collide (ambiguous source_file).
- Dropped model_cfg='' passed to segment_section (it defeated the SAM2 default
  config and silently forced watershed).
- Updated the stale module docstring/intro/comments left by the tab split.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GUI and CLI now take a units selector alongside the resolution value and
convert to the pixels-per-unit the pipeline expects, so the µm/px-vs-px/µm
mix-up that produced empty masks can't recur.

- utils/units.py: resolution_to_px_per_unit helper (+ tests)
- cli.py: --resolution_mu_units {px_per_um,um_per_px},
  --resolution_mm_units {px_per_mm,mm_per_px}; --resolution_mm now float
- gui/app.py: unit selectbox + live 'working resolution' caption on all four
  resolution inputs (Cross-Section, Curvature, Batch section + curvature);
  SBATCH emits :g-formatted resolutions so a fractional px/mm survives
- workflows.py: fix batch() docstring µm/pixel -> pixels/µm

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…info

The app had five tabs; three of them (Batch, Submit & Monitor, Results) only
functioned when Streamlit ran on a UMich SLURM login node and leaked personal
identifiers. Reduced to three honest tabs:

  Cross-Section | Curvature | Run at scale

- Removes the live sbatch/squeue submit+monitor and the separate Results tab
  (in-app runs already show results inline; the CLI writes its own CSVs).
- 'Run at scale' is now documentation + a downloadable SBATCH scaffold: it
  states plainly it does not submit anything, shows the generated script
  inline with a download button, and explains where the CLI writes results.
- Scrubs personal/UMich defaults: account 'tlasisi0' -> blank (YOUR_ACCOUNT
  placeholder), partitions spgpu/standard -> user-entered (YOUR_PARTITION),
  ~/fibermorph_output home path -> relative 'fibermorph_output', /nfs/turbo
  placeholders -> generic /path/to/...
- Title no longer says 'Hair Analysis Pipeline' (terminology); output CSV
  filename left unchanged to avoid breaking existing result files.
- Drops now-unused imports (re, subprocess, time) and session keys.

Validated with streamlit AppTest: reciprocal units (0.18 µm/px -> 5.55556
px/µm; 0.01 mm/px -> 100 px/mm) flow correctly into the generated script,
which contains only placeholders. 182 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Add help tooltips to the Taubin window and CLAHE controls (Curvature tab
  and Remote settings) so users understand what they do.
- Rename the cluster tab 'Run at scale' -> 'Run Remote' per the location-based
  framing (a 'Run Local' companion is a follow-up).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pipeline already measures each fibre fragment (length + curvature) and
writes them to analysis/ImageSum_*.csv, but the GUI discarded that (temp dir
deleted, only the aggregate row kept) and instead headlined Abhiraj's v2
metrics (curl index, wave count, diameter, total length).

Now the Curvature tab shows:
- Per-fragment table (primary): one row per fragment with length + mean/median
  curvature, read back from the pipeline's ImageSum output.
- Per-image summary: mean/median curvature and mean/median/total length,
  computed transparently from the fragments.
- Distribution histograms across fragments (length + curvature).
- Curl index / wave count / diameter moved behind an off-by-default
  'extended (experimental)' toggle, labelled as v2/student additions.

_process_curvature_gui now returns {fragments, image_row}; extended_curvature
defaults off. Validated: a 3-fragment synthetic image yields 3 per-fragment
rows with physically correct curvature ordering. 182 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Clarifies the local-vs-hosted model and fixes the 2 GB-image blocker: the
hosted app can't see local files and caps uploads, but the same GUI runs on
the user's machine with no such limit.

- launcher.py: sets FIBERMORPH_LOCAL=1 and raises maxUploadSize to 5 GB when
  launched via fibermorph-gui.
- app.py: new 'Run Local' tab explaining pip install + fibermorph-gui and that
  Streamlit runs fine locally; shows a live banner for whether you're local.
- Cross-Section & Curvature tabs: in local mode, a 'Folder on disk' input reads
  images straight from a directory (no upload, any size). Hosted app stays
  upload-only. Shared input-picker/gather helpers; run loops iterate uniform
  (name, path) work lists.
- Tab bar is now Cross-Section | Curvature | Run Local | Run Remote.

Validated with streamlit AppTest: hosted mode hides the folder option (4 tabs,
no exception); local mode reads a 3-fragment image from a folder and produces
the per-fragment + summary tables. 182 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
diameter_mean_mu / diameter_cv came from the v2 student fork and are being
pulled from fibermorph's outputs until the method is validated.

- curvature_pipeline.py: drop the diameter computation and the medial-axis
  distance map that existed only to feed it (the medial-axis skeleton for the
  curl-index path is unchanged). Extended curvature now yields curl_index,
  curl_index_std, wave_count, wave_count_per_mm, length_total.
- gui/app.py: remove diameter from the extended-metrics toggle, the pulled
  keys, and the summary labels; drop it from the batch-tab toggle label.
- cli.py / workflows.py / batch.py: update --extended-curvature help/docstrings.
- test_curvature_pipeline.py: remove the two diameter tests and their fixture.

The section min/max diameter SIZE FILTER (--minsize/--maxsize) is unrelated
and untouched. 180 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tinalasisi and others added 28 commits October 1, 2026 14:59
.gitignore and .github/workflows/publish.yml were committed with CRLF
line endings before .gitattributes (eol=lf) was added. Renormalize them
so the eventual dev -> main merge shows no line-ending noise. No content
changes (diff is empty with --ignore-cr-at-eol).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What changed
- fibermorph/gui/launcher.py: main() now parses its own --local flag
  (argparse, documented by "fibermorph-gui --help"). Every other
  command-line argument is passed through to "streamlit run", placed
  after fibermorph's defaults so the user's value wins (Streamlit
  uses the last value given for an option). Before this change extra
  arguments were silently dropped.
- Hosted mode (no flag): FIBERMORPH_LOCAL is not set. The launcher
  passes "--server.maxUploadSize 500", so the cap does not depend on
  the directory the command is run from (a value in
  .streamlit/config.toml is overridden). It passes no --server.address
  and no --server.port, so Streamlit's own settings for those apply.
- Local mode ("--local", or FIBERMORPH_LOCAL=1 already set in the
  environment): sets FIBERMORPH_LOCAL=1 and passes
  "--server.maxUploadSize 5000 --server.address localhost".
- Environment variable STREAMLIT_SERVER_MAX_UPLOAD_SIZE: when it is set
  (non-empty), the launcher does not pass --server.maxUploadSize in
  either mode, so the variable's value is the cap. Streamlit ranks a
  command-line option above a STREAMLIT_* variable, and above
  config.toml, so passing the option would discard a host's setting.
  New helpers: _option_value(), _upload_cap_mb(), _startup_notice().
- --server.address localhost is always passed in local mode, even when
  STREAMLIT_SERVER_ADDRESS is set. This is deliberate: local mode reads
  folders on this machine, so only an explicit "--server.address" on the
  fibermorph-gui command line may open it to other machines. "--help"
  and the module docstring state this precedence.
- The launcher prints one line to stderr naming the mode and the upload
  cap in effect (including one set by the variable above or by a
  command-line option). In local mode the line says "localhost only"
  unless --server.address names something other than localhost,
  127.0.0.1 or ::1; then it prints a WARNING that anyone who can reach
  the machine can read its folders. The first-run Streamlit
  email-prompt workaround is unchanged.
- fibermorph/gui/app.py: the Run Local view, the module docstring and
  the comment above _LOCAL now say "fibermorph-gui --local" and explain
  what plain "fibermorph-gui" does. How _LOCAL is read from
  FIBERMORPH_LOCAL is unchanged.
- README.md, CONTRIBUTING.md and CHANGELOG.md updated to match.

Why
Before this change every "fibermorph-gui" launch ran
os.environ.setdefault("FIBERMORPH_LOCAL", "1") and raised the upload
cap to 5000 MB. Anyone who started the app with that command on a
server (the repository's Dockerfile does) silently gave every visitor
a "Folder on disk" input that lists and reads image folders on the
server, plus a 5 GB upload limit. Local mode is only safe on your own
machine, so it now has to be asked for. The launcher also dropped all
extra command-line arguments and forced the upload cap on the command
line, which made STREAMLIT_SERVER_MAX_UPLOAD_SIZE unusable.

Behavior change
- "fibermorph-gui" now starts hosted mode: uploads only, 500 MB cap,
  no "Folder on disk" input, FIBERMORPH_LOCAL left unset. Servers and
  containers that start the app with "fibermorph-gui" get hosted mode
  without any configuration change.
- Hosted mode and the repository's Dockerfile: the Dockerfile sets
  STREAMLIT_SERVER_PORT=7860 and STREAMLIT_SERVER_ADDRESS=0.0.0.0. The
  launcher passes neither option in hosted mode, so the container keeps
  listening on all interfaces on port 7860. The upload cap is now 500 MB
  (it was 5000 MB); set STREAMLIT_SERVER_MAX_UPLOAD_SIZE to use another
  value, or append "--server.maxUploadSize N" to the command.
- "fibermorph-gui --local" gives the previous local-mode features and
  now also listens on localhost only. To reach it from another
  machine, add "--server.address 0.0.0.0" to the command (only on a
  network you trust: local mode lets visitors read folders on this
  machine). The STREAMLIT_SERVER_ADDRESS variable does not do this.
- Setting FIBERMORPH_LOCAL=1 before running "fibermorph-gui" still
  gives local mode, now with the localhost-only binding too. In a
  container this means FIBERMORPH_LOCAL=1 stops the published port from
  being reachable, even with STREAMLIT_SERVER_ADDRESS=0.0.0.0 set,
  unless "--server.address 0.0.0.0" is added to the command.
- Extra Streamlit options (for example "--server.port 8600") are now
  passed through to "streamlit run". Before, they were ignored.
- Anyone who relied on plain "fibermorph-gui" for local mode must now
  add --local.

How to verify
  python -m pytest -q fibermorph/test/test_gui_launcher.py \
      fibermorph/test/test_gui_app.py
  test_default_launch_is_hosted, test_local_flag_enables_local_mode,
  test_preset_env_var_enables_local_mode,
  test_extra_args_pass_through_after_defaults and
  test_hosted_has_no_folder_on_disk_option cover the mode switch.
  test_env_upload_cap_is_not_overridden,
  test_env_address_does_not_widen_local_mode,
  test_hosted_mode_leaves_address_and_port_to_streamlit and
  test_local_notice_warns_when_address_is_widened cover the precedence
  rules; the test_streamlit_* tests run the real Streamlit config
  loader in a subprocess and check the cap and address it ends up with.
  By hand: run "fibermorph-gui" and "fibermorph-gui --local" and check
  that only the second shows "Input source: Folder on disk" and
  listens on 127.0.0.1 only.

BREAKING-CHANGE: "fibermorph-gui" no longer enables local mode (folder
  input, 5 GB upload cap). Use "fibermorph-gui --local" or set
  FIBERMORPH_LOCAL=1. Servers and containers that start with
  "fibermorph-gui" now get hosted mode. In local mode the app listens on
  localhost only unless "--server.address" is given on the command line.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
What changed
- fibermorph/gui/app.py, Cross-Section view > Settings: the "SAM2
  checkpoint path" text box is now rendered only in local mode
  (FIBERMORPH_LOCAL=1, i.e. "fibermorph-gui --local"). In hosted mode
  the checkpoint passed to segment_section(..., checkpoint=...) is
  always _DEFAULT_CHECKPOINT: the SAM2_CHECKPOINT environment variable
  if set, otherwise the packaged default path.
- In hosted mode a caption replaces the box. It reads either "SAM2
  checkpoint: configured on this server." or "SAM2 checkpoint: none
  configured on this server, so SAM2 segmentation falls back to
  watershed." (chosen with os.path.isfile). The caption does not
  contain the path.
- fibermorph/gui/app.py, Run Remote view > Cross-section settings: the
  "SAM2 checkpoint path" box was pre-filled with _DEFAULT_CHECKPOINT,
  so every hosted visitor saw the server's path there. In hosted mode
  it now starts empty, with the placeholder
  "/path/to/sam2.1_hiera_tiny.pt" and a help text saying the path is for
  the machine you will run on. In local mode it is still pre-filled
  with the default. The value only ends up in the generated SBATCH
  script text (the server never loads it). If SAM2 is enabled and the
  box is blank, the script now contains
  --sam2-checkpoint 'YOUR_SAM2_CHECKPOINT' for the user to fill in,
  like the existing YOUR_ACCOUNT and YOUR_PARTITION placeholders.
- Tests in fibermorph/test/test_gui_app.py render all four views
  (section, curvature, local, remote) in hosted mode and check that the
  SAM2_CHECKPOINT directory name appears in no label, caption, help
  text, placeholder, text box value or code block. README.md and
  CHANGELOG.md describe the change.

Why
Whatever a visitor typed into the Cross-Section box was passed to
segment_section() and the server then tried to load that file as a SAM2
model. A remote user could therefore choose which file on the server
gets loaded, and section_sam2._get_sam2_generator keeps the first model
it loads for the life of the process, so one visitor's choice also
affected everyone after them. Separately, the server's own checkpoint
path was shown to visitors in the Run Remote box, where a server-local
path is also meaningless to someone building a script for a cluster.

Behavior change
- Hosted mode, Cross-Section view: the text box is gone; the app uses
  SAM2_CHECKPOINT, or the default found by _resolve_checkpoint() in
  app.py (first choice fibermorph/checkpoints/sam2.1_hiera_tiny.pt) if
  the variable is not set. A host that wants SAM2 segmentation must set
  SAM2_CHECKPOINT (or place the file at the default path) on the
  server. With no checkpoint file, SAM2 falls back to watershed, as
  before.
- SAM2_CHECKPOINT is read by the server process, and the first SAM2
  model it loads is cached until the process exits. Restart the app
  after changing SAM2_CHECKPOINT or replacing the checkpoint file.
- Hosted mode, Run Remote view: the checkpoint box is empty instead of
  showing the server path. Users who want SAM2 in their script type the
  path on their cluster, or leave it blank and edit
  YOUR_SAM2_CHECKPOINT in the downloaded script.
- Local mode ("fibermorph-gui --local"): unchanged; both boxes are
  shown, pre-filled with the same default, and their values are used.
- Cross-section results are unchanged for anyone who left the default
  path in place.

How to verify
  python -m pytest -q fibermorph/test/test_gui_app.py
  test_hosted_views_never_show_the_server_checkpoint_path (four views,
  checkpoint present and missing),
  test_hosted_remote_checkpoint_box_starts_empty_with_a_placeholder,
  test_hosted_script_uses_placeholder_for_blank_checkpoint,
  test_hosted_has_no_sam2_checkpoint_text_input,
  test_hosted_caption_says_when_no_checkpoint_is_configured,
  test_hosted_ignores_a_client_supplied_checkpoint (hosted always passes
  the server's checkpoint to segment_section even if a client sets the
  widget's session key) and
  test_local_uses_the_checkpoint_typed_in_the_box.

BREAKING-CHANGE: in hosted mode visitors can no longer enter a SAM2
  checkpoint path in the Cross-Section view; the server's
  SAM2_CHECKPOINT (or packaged default) is always used, and its path is
  not shown in any view.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
What changed
- New module fibermorph/gui/inputs.py (no Streamlit import, so it is
  unit-testable). save_uploads(uploads, tmpdir) writes the i-th upload
  (1-based, in upload order) to <tmpdir>/upload_NNNN<ext> and returns
  [(display_name, path), ...]. <ext> is the original extension,
  lower-cased, only if it is one of tif, tiff, png, jpg, jpeg;
  otherwise there is no extension. The client's filename is not used
  to build any path.
- display_name() reduces the client's name to its last component
  (splitting on both "/" and "\"), drops control characters, and falls
  back to the generated file name if nothing usable is left (empty
  name, ".", ".."). restore_names() puts the display name back into
  an error message that mentions the generated name or temp path; it
  replaces whole names only, so "upload_0001" (saved without an
  extension) is not rewritten inside "upload_00010.tif".
- fibermorph/gui/app.py: _gather_inputs() uses save_uploads() for
  uploads; _UPLOAD_TYPES is now inputs.UPLOAD_TYPES (same list).
  _process_section_gui() takes a new display_name argument and passes
  it to section_props_extended(), so the "ID" and "mask_filename"
  columns hold the uploaded name instead of the temp file's name. The
  per-file error warnings in both views map generated names back to
  the uploaded name (e.g. "Cannot identify image file 'my scan.tif'").
- Curvature needed no mapping beyond that: curvature_seq() names its
  outputs after the temp file's stem (ImageSum_upload_0001_WindowSize-
  50px.csv), but each image gets its own fresh output folder, the GUI
  reads the one ImageSum_*.csv in it, and the tables use the display
  name for source_file.
- The "Duplicate filenames" warning is kept: its text ("each file is
  still measured separately") is now true.
- Tests: fibermorph/test/test_gui_inputs.py (absolute paths, "..",
  Windows paths, empty names, duplicates, extensions) and AppTest
  cases in fibermorph/test/test_gui_app.py. CHANGELOG.md updated.

Why
1. The app saved each upload with os.path.join(tmpdir, up.name) where
   up.name is chosen by the client. os.path.join discards tmpdir when
   the name is absolute, and ".." segments (or "\" separators on a
   Windows server) leave it, so a crafted filename could create or
   overwrite a file outside the temporary directory.
2. Two uploads with the same name were written to the same temp path,
   the second overwriting the first before any analysis ran, so both
   result rows measured the second file while the page said each file
   was measured separately.

Behavior change
- Uploads with ordinary, distinct names: none. Results and displayed
  names are the same as before.
- Uploads whose names repeat: each file is now measured on its own, so
  the second row no longer duplicates the first. Rows are still
  labeled with the shared name, and the duplicate warning is shown.
- Uploads whose names include directories (e.g. "a/b.tif" or
  "C:\scans\b.tif"): shown as "b.tif"; nothing is written outside the
  temporary directory.
- Folder mode (--local) is unchanged.

How to verify
  python -m pytest -q fibermorph/test/test_gui_inputs.py \
      fibermorph/test/test_gui_app.py
  test_hostile_names_never_escape_tmpdir and
  test_duplicate_names_become_distinct_files cover the helper, and
  test_restore_names_only_replaces_whole_names covers the message
  mapping. The two
  *_duplicate_uploads_are_measured_separately_and_named_as_uploaded
  tests in test_gui_app.py (cross-section and curvature) upload
  same-named files of different size/shape through the running app and
  check that the rows differ and carry the uploaded name.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
What changed
- fibermorph/gui/launcher.py, _startup_notice(): the bind address is
  now read with _option_value(...) and "localhost" is used only when
  the option was not given at all (value is None). Before, an empty
  value ("--server.address=" or "--server.address ''") was replaced by
  "localhost" because an empty string is falsy in Python.
- An empty address is now reported like any other address that is not
  localhost, 127.0.0.1 or ::1: the line reads "listening on all network
  interfaces" and the WARNING that anyone who can reach the machine can
  read its folders is printed.
- The launcher module docstring, README.md and CHANGELOG.md say that an
  empty --server.address counts as widening the address.
- fibermorph/test/test_gui_launcher.py: "--server.address=" and
  "--server.address ''" are added to
  test_local_notice_warns_when_address_is_widened; new tests
  test_local_notice_says_every_interface_for_an_empty_address and
  test_streamlit_gets_an_empty_address_when_asked_for_one.

Why
Streamlit does not treat an empty server.address as unset: its server
uses "server.address or 0.0.0.0", so with an empty value it listens on
every network interface. The launcher still printed "localhost only"
for that case and did not print the WARNING, so the notice said the
opposite of what was happening. Local mode lets any visitor read folders
on the machine, so the notice has to be right. This only happens when
the person starting the app types the empty value; visitors cannot
cause it.

Behavior change
- "fibermorph-gui --local --server.address=" (or with a separate empty
  argument) now prints "listening on all network interfaces" and the
  WARNING line, instead of "localhost only".
- Nothing else changes: the arguments passed to "streamlit run", the
  address Streamlit binds to, and the notices for localhost, 127.0.0.1,
  ::1 and non-empty wider addresses are the same as before.

How to verify
  python -m pytest -q fibermorph/test/test_gui_launcher.py
  By hand:
  python -c "from fibermorph.gui import launcher as l; \
  print(l._startup_notice(True, ['--server.address=']))"
  prints a line that contains "all network interfaces" and "WARNING".

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
What changed
- Add fibermorph/core/frangi_v016.py with frangi_v016(), a copy of the
  2-D Frangi vesselness filter exactly as scikit-image 0.16.2 computed
  it (Gaussian-smoothed Hessian with zero padding at the border, fixed
  gamma=15, eigenvalues ordered by absolute value). The file keeps the
  scikit-image BSD-3-Clause copyright and license text. The helpers
  skimage.util.img_as_float and invert are still taken from the
  installed scikit-image; for 8-bit and float64 input they return the
  same arrays in 0.16.2 and 0.26, and the module docstring says so.
- fibermorph/core/filters.py: filter_curv() now calls
  frangi_v016(gray_img) instead of skimage.filters.frangi(gray_img).
  filter_curv_clahe() is unchanged in behavior and still uses the
  installed scikit-image; only its docstring now says it is not part of
  the v0.3.1 method.
- Add fibermorph/test/test_frangi_v016.py and the reference data in
  fibermorph/test/test_data/frangi_v016/ (input image, outputs of the
  real scikit-image 0.16.2, the script that made them, and a README).
  TestInstalledHelpers pins the behavior of img_as_float and invert
  that frangi_v016 relies on.
- CHANGELOG.md: entry under Unreleased / Fixed.

Why
The published fibermorph method (v0.3.1, Nov 2020) ran on scikit-image
0.16.2. Later scikit-image releases changed frangi(): gamma now
defaults to a value computed from the image instead of 15, and the
Hessian code changed. Every later step of the curvature pipeline
(threshold, particle removal, skeletonize, prune, Taubin circle fits)
produces identical pixels on both versions, so the filter alone made
the same fibermorph code report different curvature depending on the
installed scikit-image. For the straight-hair demo image
(027_demo_nocurv, 50 px window, 132 px/mm) the mean curvature is
0.0526 per mm with scikit-image 0.16.2 and 0.173 per mm with 0.26
(3.3 times higher). For the curly demo image (004_demo_curv, same
settings) mean curvature changes by 1.1%, median curvature by 3.4% and
length by 2.9%; the hair count is the same.

Behavior change
With the default options (no CLAHE, no extended curvature), curvature
and length values from curvature_seq(), the CLI `fibermorph
--curvature` command and the GUI Curvature view now equal the original
v0.3.1 values, whichever scikit-image is installed. Values produced
earlier by 0.3.x/1.0.x releases or the unreleased 2.0.0 code
(fibermorph-dev) on scikit-image newer than 0.16.2, will differ (the
straight-hair example above goes from 0.173 to 0.0526 per mm), so
re-run any analysis that must be comparable with the paper or with
other runs.

Two options are not the v0.3.1 method and do not reproduce it:
- CLAHE (--use-clahe, curvature_seq(use_clahe=True), the CLAHE switch
  in the GUI): filter_curv_clahe() still uses the installed
  scikit-image frangi().
- Extended curvature (--extended-curvature,
  curvature_seq(extended_curvature=True)): the skeleton is made with
  medial_axis instead of the skeletonize step v0.3.1 used, so the base
  curvature and length columns also differ from v0.3.1 (synthetic test
  image, 8 runs: mean curvature -0.4% to +1.5%, mean length +1.2% to
  +1.5%). medial_axis breaks ties at random, so these runs also differ
  from each other.
  Extended curvature is the default of run_batch(), workflows.batch()
  and of the "Extended curvature metrics" switch in the GUI's SLURM
  script builder, so batch runs started with those defaults do not
  reproduce v0.3.1 values either.

There is nothing to configure: no new dependency, flag or environment
variable. In a quick check on a 1680 x 1936 image the frozen filter
took about 1.0 s against 3.6 s for scikit-image 0.26.

How to verify
  python -m pytest -q fibermorph/test/test_frangi_v016.py
The tests compare frangi_v016() with arrays computed by scikit-image
0.16.2 (rtol 1e-9, atol scaled to the reference maximum) and check that
filter_curv() returns the same array. They fail if
skimage.filters.frangi is used instead (largest difference about 0.85
against a reference maximum of 1.9e-4). TestInstalledHelpers fails if a
future scikit-image changes img_as_float or invert for 8-bit or
float64 input.

BREAKING CHANGE: curvature values change on scikit-image newer than
0.16.2; with the default options they now match the original v0.3.1 /
published results instead of what 0.3.x/1.0.x gave on the installed
scikit-image. CLAHE and extended curvature runs do not match v0.3.1.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
What changed
- fibermorph/gui/app.py: new _upload_cap() reads Streamlit's own
  "server.maxUploadSize" setting (st.get_option) and formats it for
  the page; the module-level _UPLOAD_CAP holds the result. It replaces
  the fixed "500 MB" / "5 GB" in four places:
  * the sidebar status line ("Hosted · <cap> upload cap" or
    "Local · <cap> cap · folder input"),
  * the Run Local view's "Why" paragraph ("it caps uploads (<cap>
    here)"),
  * the hosted info box on the Run Local view ("upload-only, <cap>"),
  * the local success box on the Run Local view ("uploads are capped
    at <cap>"; it used to say "raised to 5 GB").
  If the setting cannot be read as a number, the old defaults (500 MB
  hosted, 5 GB local) are shown.
- The Run Local text no longer quotes a number for plain
  "fibermorph-gui" on the reader's own machine ("uploads only, no
  folder input"), and no longer uses "a 2 GB curvature image" as its
  example of a file that will not upload (it now says "a
  multi-gigabyte curvature image, say"), because either can be wrong
  for the cap a host has set.
- fibermorph/gui/inputs.py: new format_upload_cap(megabytes): whole
  thousands of MB are shown in GB (5000 -> "5 GB", 2000 -> "2 GB"),
  anything else in MB (500 -> "500 MB", 2500 -> "2500 MB").
- The app.py module docstring, README.md and CHANGELOG.md say that the
  500 MB and 5 GB figures are the launcher's defaults and that the
  app shows whatever cap is in effect.
- Tests: format_upload_cap in fibermorph/test/test_gui_inputs.py;
  test_hosted_text_follows_the_configured_upload_cap and
  test_local_text_follows_the_configured_upload_cap in
  fibermorph/test/test_gui_app.py (cap set to 500, 750, 2000, 300 and
  5000 MB; the page must show that cap and none of the others).

Why
The launcher lets a host choose the cap: when the environment variable
STREAMLIT_SERVER_MAX_UPLOAD_SIZE is set it passes no
--server.maxUploadSize, and "--server.maxUploadSize N" on the
fibermorph-gui command line also wins. The app text ignored those and
always said 500 MB (hosted) or 5 GB (local), so a container started
with STREAMLIT_SERVER_MAX_UPLOAD_SIZE=2000 told visitors "500 MB"
right next to a file uploader that said 2 GB.

Behavior change
- With no cap configured, the page text is the same as before ("500 MB"
  hosted, "5 GB" local), except for the wording changes listed above
  in the Run Local view.
- With STREAMLIT_SERVER_MAX_UPLOAD_SIZE, "--server.maxUploadSize N" or
  a config.toml value set, the sidebar status and the Run Local view
  show that cap. Nothing about the cap itself changes: Streamlit
  enforces the same limit as before.
- No new setting or environment variable. Hosts do not have to edit
  the app to change the number shown.

How to verify
  python -m pytest -q fibermorph/test/test_gui_app.py \
      fibermorph/test/test_gui_inputs.py
  By hand: STREAMLIT_SERVER_MAX_UPLOAD_SIZE=2000 fibermorph-gui, open
  Run Local; the sidebar footer and the info box say 2 GB, matching
  the limit under the file uploader on the Cross-Section view.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
What changed
- fibermorph/core/curvature.py, window_iter():
  - window_size=None again runs a whole-hair analysis. One Taubin
    circle is fitted to each hair, hairs with area <= 0.5 * resolution
    pixels are skipped, and the result has the columns ID, curv_mean,
    curv_median, length_mean, length_median, hair_count. The per-hair
    table is written to analysis/ImageSum_<name>.csv with columns
    curv and length. This is the code of window_iter() in v0.3.1.
  - test=True again returns the per-hair table instead of the image
    summary, in both the window-size branch (columns curv_mean,
    curv_median, length) and the whole-hair branch (curv, length).
  - One difference from v0.3.1: if no hair is longer than the minimum,
    the whole-hair branch returns hair_count 0 and NaN means, as the
    window-size branch already does, instead of failing with
    "ValueError: No objects to concatenate" from pandas.
  - Docstring now describes the returned tables.
- fibermorph/analysis/curvature_pipeline.py: the docstring of
  curvature_seq() now describes window_size=None, test=True and the
  columns of the returned tables (it still said test only "skips some
  I/O" and that a summary is always returned). No code change.
- fibermorph/test/test_core_curvature.py: the two synthetic-skeleton
  tests that read the summary columns now pass test=False (they passed
  test=True only because the summary was returned). New tests cover
  per-hair output with test=True, the whole-hair mode (columns, minimum
  length, CSV file, empty image, a window list containing None).
- CHANGELOG.md: entry under Unreleased / Fixed.

Why
The 0.3.7 refactor removed two behaviors of v0.3.1's window_iter().
(1) With window_size=None it returned an empty DataFrame and logged a
warning. None is what the CLI passes when --window_size is omitted, so
the default `fibermorph --curvature` run produced no curvature values,
and so did any caller that relied on the documented "entire fiber"
default. (2) It ignored test=True and always returned the image
summary. fibermorph.demo.demo.validation_curv() calls
curvature_seq(..., test=True) and then reads the per-hair columns
`length` and `curv_median`, so the simulated-data validation from the
paper (dummy_curv) failed with a KeyError.

Behavior change
- `fibermorph --curvature` without --window_size now outputs one
  summary row per image with curv_mean, curv_median, length_mean,
  length_median and hair_count (previously: empty).
- curvature_seq(window_size=None, ...) returns that summary; the
  extended_curvature option works on it (curl index etc. are added;
  wave_count is 0 because it needs the windowed curv_mean_mean).
- curvature_seq(..., test=True) returns one row per hair, not the
  summary. Callers that passed test=True and expected the summary
  must pass test=False; the CLI, GUI, batch pipeline and workflows
  already pass test=False and are unaffected.
- Windowed analysis with test=False is unchanged.

How to verify
  python -m pytest -q fibermorph/test/test_core_curvature.py \
      fibermorph/test/test_curvature_pipeline.py
The new classes are TestPerHairOutputWhenTestTrue and TestWholeHairMode.
Reverting either behavior makes them fail. The existing pipeline tests
that pass window_size=None now exercise the whole-hair code instead of
receiving an empty table.

BREAKING CHANGE: window_iter()/analyze_all_curv()/curvature_seq() return
the per-hair table when test=True and a whole-hair summary when
window_size=None; code that relied on 0.3.7-1.0.1 behavior (summary with
test=True, empty table with window_size=None) must be updated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
What changed
- fibermorph/gui/app.py, Cross-Section view > Settings, hosted mode:
  the caption that replaces the "SAM2 checkpoint path" box is
  reworded.
  * Checkpoint file present (os.path.isfile on the server's
    checkpoint): "SAM2 checkpoint: file found on this server. SAM2
    also needs a GPU on the server; without one the app uses
    watershed." (was "SAM2 checkpoint: configured on this server.")
  * No file: "SAM2 checkpoint: no file found on this server, so SAM2
    segmentation falls back to watershed." (was "...none configured
    on this server, ...")
  Neither caption contains the path. A comment next to the caption
  records why only the file is checked.
- fibermorph/test/test_gui_app.py: the two caption tests are renamed
  (test_hosted_caption_says_checkpoint_is_found_without_showing_path,
  test_hosted_caption_says_when_no_checkpoint_file_is_found) and check
  the new wording, that the GPU requirement is mentioned when a file
  exists, and that the path is still not shown.
- CHANGELOG.md: the Security entry for the SAM2 checkpoint describes
  the new caption.

Why
The caption only tested whether the checkpoint file exists. In
segmentation code (fibermorph/processing/section_sam2.py), SAM2 is used
only if the sam2 package imports and a CUDA GPU is present; otherwise
the app uses watershed before it looks at the checkpoint. On a CPU-only
server (for example the CPU stage of the repository's Dockerfile) with a
checkpoint file in place, the caption said "configured on this server",
which suggests SAM2 will run when it will not. "Use SAM2 segmentation
(GPU required)" is shown right above it, but the caption should not
contradict that.

Behavior change
- Text only. The checkpoint passed to segment_section(), the choice
  between SAM2 and watershed, and the measurements are unchanged.
- The hosted caption now says "file found" / "no file found" instead of
  "configured" / "none configured", and mentions the GPU requirement
  when a file is found.

How to verify
  python -m pytest -q fibermorph/test/test_gui_app.py
  test_hosted_caption_says_checkpoint_is_found_without_showing_path and
  test_hosted_caption_says_when_no_checkpoint_file_is_found.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
What changed
- CHANGELOG.md, Unreleased > Breaking Changes: new entry "Hosted
  visitors can no longer set the SAM2 checkpoint". It says that in
  hosted mode the Cross-Section view has no "SAM2 checkpoint path" box,
  that the server always uses SAM2_CHECKPOINT or the packaged default
  path, that the Run Remote checkpoint box now starts empty, that a host
  who wants SAM2 must set SAM2_CHECKPOINT (or put the file at the
  default path) on a machine with a GPU and restart the app after
  changing it, and that local mode is unchanged. It points to the
  existing Security entry for the full description.
- CHANGELOG.md, Unreleased > Added: the fibermorph.gui.inputs entry now
  lists restore_names() (puts the uploaded file name back into error
  messages) and format_upload_cap() (formats the upload cap as "500 MB"
  or "5 GB"). It listed only save_uploads(), display_name() and
  safe_extension().

Why
The SAM2 checkpoint change was committed as a breaking change, but the
changelog described it only under Security. Someone reading Breaking
Changes to decide what to do when updating a server would not learn
that visitors lost the checkpoint box and that the host now has to set
SAM2_CHECKPOINT. The inputs entry omitted two public helpers that the
app uses.

Behavior change
None. Documentation only; no code, test or runtime behavior changes.

How to verify
  git show --stat HEAD   (only CHANGELOG.md changes)
  Read "Breaking Changes" and "Added" under "Unreleased" in
  CHANGELOG.md.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
What changed
- Add fibermorph/test/test_curvature_golden.py. It runs curvature_seq()
  with the default options (no CLAHE, no extended curvature) and
  compares per-hair rows (test=True) and the image summary
  (test=False) with CSV files made by the original v0.3.1 code, using
  rtol 1e-9, for three window settings: 50 px, 0.5 mm and None (whole
  hair), at 132 px/mm. Per-hair rows are compared in order, not sorted.
  - TestSyntheticImageGolden: offline, on a synthetic 1000 x 1000 PNG
    (about 400 KB) with arcs of several radii, a near-straight line, a
    straight line and a short arc.
  - TestDemoImagesGolden: on the lab demo images 004_demo_curv.tiff
    (curly) and 027_demo_nocurv.tiff (straight), windows 50 px and
    None. The tests download the TIFFs from
    github.com/tinalasisi/fibermorph_DemoData (test_input/curv) at test
    time, once per test run (a file already downloaded is reused), and
    skip if the download fails or the file is not a TIFF. If the
    environment variable FIBERMORPH_DEMO_CURV_DIR points to a folder
    holding the two files, they are used and nothing is downloaded. The
    TIFFs are not committed.
  - TestDownloadHelper: the download helper reuses a downloaded file,
    uses FIBERMORPH_DEMO_CURV_DIR, and skips when the server cannot be
    reached.
  - TestValidationCurvCallPath: curvature_seq(test=True) returns
    per-hair rows with the columns length and curv_median that
    fibermorph.demo.demo.validation_curv() reads.
- Add fibermorph/test/test_data/curv_golden/: the synthetic PNG and the
  script that draws it, the reference CSV files, make_curv_reference.py
  (runs the unmodified v0.3.1 code to make them) and a README with the
  exact environment and commands.
- CHANGELOG.md: entry under Unreleased / Added.

Why
Curvature results changed silently between releases because
scikit-image's frangi() changed, and two behaviors of the original
window_iter() were lost in a refactor. Nothing in the test suite
compared the numbers with the published method, so neither was caught.
These tests pin the default curvature output to the original. The CLAHE
option and the extended-curvature option (medial-axis skeleton) are not
part of v0.3.1 and are not covered.

Behavior change
None for users. The test suite gains 19 tests that take about 35
seconds in total. The demo-image tests need the two TIFFs (2.6 MB and
3.3 MB), either from the download (each fetched once per run) or from
FIBERMORPH_DEMO_CURV_DIR; without network and without that variable
those 8 tests are skipped and the other 11 run. No change to the
package code.

How to verify
  python -m pytest -q fibermorph/test/test_curvature_golden.py
Offline only: add -k "Synthetic or ValidationCurv or DownloadHelper".
The references were made with Python 3.8.20, scikit-image 0.16.2,
numpy 1.18.5, scipy 1.4.1, pandas 1.0.5 and Pillow 7.2.0 (v0.3.1 pins
scikit-image 0.16.2 and scipy 1.4.1); the current code matches them to
about 1e-14 relative on Python 3.12 with scikit-image 0.26. Switching
filter_curv() back to skimage.filters.frangi, or removing the
whole-hair or test=True behavior of window_iter(), makes these tests
fail.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Brings in three commits that make the curvature analysis give the same
results as the published fibermorph method (v0.3.1, November 2020) on
any installed scikit-image, with the default options:

- fix(curvature)!: freeze the scikit-image 0.16.2 Frangi filter
  (new fibermorph/core/frangi_v016.py, used by filter_curv). The
  installed skimage.filters.frangi changed after 0.16.2. With it, the
  straight-hair demo image measured 0.173 mm^-1 mean curvature instead
  of 0.053 (3.3x). All later pipeline steps were already unchanged.
- fix(curvature)!: restore whole-hair mode (window_size=None, the CLI
  default) and the per-hair table returned with test=True. Both were
  lost in the refactor first released in 0.3.7. --demo_dummy_curv
  works again.
- test(curvature): golden tests against output of the original v0.3.1
  code run with scikit-image 0.16.2. One test uses an offline synthetic
  image. The other uses the two demo images, downloaded at test time.

Behavior change: curvature numbers from installs with scikit-image
newer than 0.16.2 change to the original method's values. Each commit
and CHANGELOG.md has the details.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in seven commits that fix how the GUI behaves when it is hosted
for other people:

- feat(gui)!: fibermorph-gui starts in hosted mode by default: uploads
  only, 500 MB cap, no folder input. Local mode (a "Folder on disk"
  input that reads this machine's folders, a 5 GB cap, listening on
  localhost only) now needs `fibermorph-gui --local`, or
  FIBERMORPH_LOCAL=1 set explicitly. Before, every fibermorph-gui start,
  including the repository's Dockerfile, silently turned local mode on,
  so every visitor could read folders on the server. Other arguments
  are passed through to `streamlit run`.
- fix(gui)!: hosted visitors can no longer type the SAM2 checkpoint
  path that the server loads. The host sets it with the SAM2_CHECKPOINT
  environment variable. fibermorph does not ship a checkpoint file.
- fix(gui): uploads are saved under generated names (upload_0001.tif,
  ...) inside the temporary folder, never under the client-supplied
  filename. The original filename is still shown in every result.
  Two uploads with the same name are now measured separately; before,
  the second one overwrote the first.
- Follow-ups: the page shows the upload cap Streamlit actually enforces,
  the SAM2 caption says that SAM2 also needs a GPU, a warning appears
  when local mode is opened to other machines, and CHANGELOG entries
  were added.

Behavior change for anyone hosting the app: a container or server that
starts with `fibermorph-gui` now runs in hosted mode, which is the safe
default. Use `--local` only on your own computer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What changed
- README.md and CHANGELOG.md no longer mention a "packaged default"
  SAM2 checkpoint path. fibermorph does not ship a checkpoint, so a
  host must download one and point SAM2_CHECKPOINT at it.
- CHANGELOG.md: whole-hair mode and the per-hair test output were
  removed by the refactor first released in 0.3.7, not in 1.0.0.
  Curvature values change compared with 0.3.x and 1.0.x releases run on
  scikit-image newer than 0.16.2, not only compared with 1.0.x.
- README.md: dropped a sentence of change history from the usage
  section. It is already in CHANGELOG.md.
- gui/app.py, Run Local view: the explanation no longer calls a local
  app "this hosted app" and no longer claims local mode has no upload
  limit. It only gives the cap number when the app is hosted.
- test_curvature_golden.py: test_local_folder_is_used_without_download
  now uses a separate download folder, so it really tests the
  FIBERMORPH_DEMO_CURV_DIR branch. Before, it passed through the
  already-downloaded branch.
- make_curv_reference.py: the docstring now says which library
  versions are pinned by v0.3.1's environment.yml, matching README.md
  in the same folder.

Why
These statements were inaccurate or confusing for anyone deploying the
app or reading the changelog.

Behavior change
None for analyses. The Run Local view's wording changes.

How to verify
pytest (375 passed); README.md and CHANGELOG.md [Unreleased].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What changed
- fibermorph/cli.py: --window_size (nargs="+") now has an argparse
  type, _window_size_value, which turns each value into a float, or
  into None for "none" / "None". A new function,
  normalize_window_sizes(values, window_unit), then checks the values
  against --window_unit and returns what the curvature workflow gets:
  None (whole hair), a list of ints for px, or a list of floats for mm.
  Problems are reported with parser.error(), so the exit code is 2.
  The check runs whichever module is chosen (--section and --raw2gray
  too), although only --curvature uses the option.
- fibermorph/cli.py: parse_args() and main() take an optional list of
  arguments (default: sys.argv[1:]), so the command line can be run
  from tests without a subprocess. The console script and
  "python -m fibermorph" are unchanged.
- --window_size help text, README.md (curvature options) and
  CHANGELOG.md ([Unreleased] > Fixed) describe the new behavior.
- fibermorph/test/test_cli.py: new tests (see How to verify).

Why
The values were never converted, so curvature code received strings.
window_iter() in fibermorph/core/curvature.py computes
int(window_size * resolution) for --window_unit mm, which raised
"TypeError: can't multiply sequence by non-int of type 'float'". With
px it only worked because int("50") succeeds; "50.0" raised
"ValueError: invalid literal for int()". The original v0.3.1 command
line has the same bug. The help text already promised "Float or integer
or None", with None (whole hair) as the default.

Behavior change
- --window_unit mm with any --window_size now runs (it crashed).
- --window_size 50.0 with px now runs as 50 (it crashed).
- --window_size none is accepted and fits the whole hair, like leaving
  the option out. It cannot be combined with numbers.
- Several values (a sweep) still work: --window_size 25 50 100.
- These now stop with a usage message and exit code 2 instead of a
  traceback or an odd result: a value that is not a number, not
  finite, or not greater than 0; a fraction of a pixel such as 50.5
  with px (it used to crash). Zero and negative px values used to run:
  they logged "less than 10 pixels, using full length" and measured
  each hair over its whole length. They now exit 2.
- Output names are unchanged: px windows are ints
  (ImageSum_<name>_WindowSize-50px.csv) and mm windows are floats
  (..._WindowSize-0.5mm.csv; 1 mm is written as 1.0mm).
- The Run Remote view of the GUI writes "--window_size 50" (whole
  pixels), which parses unchanged.
- Not covered here: other numeric options. Out-of-range values such
  as --jobs 0 or --resolution_mm 0 still end in a Python traceback with
  exit code 1; the next commit in this series rejects them with a
  usage message.

How to verify
  python -m pytest -q fibermorph/test/test_cli.py
runs parsing tests (px ints, px "50.0", px "50.5" rejected, mm floats,
several values, none, zero and negative rejected, default None) and
runs the real command line on a synthetic TIFF with px, mm, no
--window_size, and a sweep; the mm and px results are compared with
curvature_seq called directly. By hand:
  fibermorph --curvature -i <tiff folder> -o <out folder> \
      --window_size 0.5 --window_unit mm --resolution_mm 132

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
What changed
- fibermorph/cli.py: the error for "none" combined with other values
  now reads "'none' (fit the whole hair) must be given on its own, not
  repeated or combined with window sizes". Before, "--window_size none
  none" was reported as "cannot be combined with other window sizes"
  although no other size had been given. The --window_size help text
  now says that a window shorter than 10 pixels is not used.
- README.md (curvature options) and CHANGELOG.md ([Unreleased]): state
  the 10 pixel rule. The changelog entry for --window_size no longer
  says that zero and negative windows used to crash; a new Breaking
  Changes entry says that a zero or negative px window used to run and
  now exits with code 2, whichever module is chosen.
- fibermorph/test/test_cli.py: the window-size sweep test now also
  compares its numbers with curvature_seq (it only checked names
  before); a new test shows that a 0.001 mm window and a 5 px window
  give the same numbers, because both fall under 10 pixels; the "none"
  tests cover "none none" and "none 50".

Why
The curvature code (subset_gen in fibermorph/core/curvature.py) has
always replaced a window under 10 pixels by the full length of each
hair, logging "using full length". With --window_unit mm a value such
as 0.001 is accepted (132 px/mm makes it 0 px) and the output file is
named ..._WindowSize-0.001mm, so a user could believe a 0.001 mm window
was measured. The help text said a mm window "can be any number greater
than 0" and did not mention this. The changelog entry written with the
--window_size change also did not say that 0 and negative px windows ran
before that change and now stop.

Behavior change
- Compared with the previous commit, only one error message changes;
  the rest is documentation and tests. No curvature number changes.
- The documented rule is the existing one: a window under 10 pixels
  (for mm: int(window_size * --resolution_mm) < 10) measures each hair
  as a single window over its whole length. Its numbers are not the same
  as --window_size none, which fits one circle per hair and skips hairs
  shorter than 0.5 mm.
- The previous commit made a zero or negative px window exit 2 instead
  of measuring whole hairs. A script that still writes one (for
  example one downloaded from the GUI's Run Remote view, whose "Taubin
  window (px)" box has no lower limit) needs a positive window.

How to verify
  python -m pytest -q fibermorph/test/test_cli.py
By hand, both commands log "less than 10 pixels, using full length"
and give the same numbers (only the ID differs):
  fibermorph --curvature -i <tiff folder> -o <out> --window_size 5
  fibermorph --curvature -i <tiff folder> -o <out2> \
      --window_size 0.001 --window_unit mm

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
What changed
- core/filters.py: deleted filter_curv_clahe (CLAHE contrast
  enhancement, the installed scikit-image Frangi filter, and an Otsu
  threshold computed on the middle 70% of the frame, i.e. a crop of the
  top and bottom 15%). It is also gone from the fibermorph package
  exports and __all__.
- Dropped the use_clahe parameter from analysis.curvature_pipeline.
  curvature_seq, workflows.curvature, workflows.batch and
  pipeline.batch.run_batch.
- cli.py: --use-clahe no longer enables anything. It stays registered,
  hidden from --help, only to reject old scripts with a clear error
  (see Behavior change).
- gui/app.py: removed the "CLAHE preprocessing" toggle from the
  Curvature view and from the Run Remote view; the SBATCH script
  generator can no longer write --use-clahe.
- Tests: deleted the filter_curv_clahe tests and the CLAHE cases of the
  curvature pipeline tests. Added test_cli_removed_options.py (exit code
  2, message, hidden from --help), tests that the API no longer has
  use_clahe or filter_curv_clahe, and GUI tests that no CLAHE toggle is
  shown and the generated script has no --use-clahe. The golden-test
  docstring and README no longer mention the option.

Why
CLAHE preprocessing, and the 15% band crop that came with it, are not
part of the published fibermorph curvature method (v0.3.1, 2020). The
option used the installed scikit-image Frangi filter instead of the
frozen scikit-image 0.16.2 version the published numbers come from, so
its output changed with the installed scikit-image version and could
not be reproduced against the published method. The ridge-filter step
now has one path: filter_curv (the frozen 0.16.2 Frangi filter).
This commit is the first of three: the next one removes the extended
curvature option, which also changes the skeletonization step, and the
third updates the README, CHANGELOG, ROADMAP and CONTRIBUTING. Until
the third, the README still shows a curvature example with --use-clahe,
which now exits with status 2.

Behavior change
- fibermorph --curvature ... --use-clahe now stops before doing any work
  with exit status 2 and "argument --use-clahe: this option was removed
  because CLAHE preprocessing is not part of the published fibermorph
  curvature method". Scripts that still pass it (for example SBATCH
  scripts generated earlier by the web app) must have the flag deleted.
- Python callers passing use_clahe=... now get a TypeError;
  fibermorph.filter_curv_clahe no longer exists.
- Results with the default options are unchanged: use_clahe defaulted
  to False in every entry point.

How to verify
  python -m pytest fibermorph/test/test_cli_removed_options.py \
      fibermorph/test/test_curvature_pipeline.py \
      fibermorph/test/test_curvature_golden.py
  fibermorph --curvature -i in -o out --use-clahe; echo $?   # prints 2

BREAKING CHANGE: the --use-clahe flag, the use_clahe parameter of
curvature_seq, curvature, batch and run_batch, and filter_curv_clahe
are removed. The flag now exits with status 2 and an explanatory
message; the Python names raise TypeError or AttributeError.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
What changed
- analysis/curvature_pipeline.curvature_seq: removed the
  extended_curvature parameter and both of its code paths: the
  medial-axis skeleton (skimage medial_axis) used in place of
  skeletonize, and the extra output columns curl_index, curl_index_std,
  wave_count, wave_count_per_mm and length_total. Curvature now has one
  path: filter_curv (frozen scikit-image 0.16.2 Frangi filter) ->
  binarize -> remove particles -> skeletonize -> prune -> Taubin
  windows.
- core/curvature.py: deleted curl_index_from_skeleton, wave_count and
  their helper pixel_length_correction_coords (no other caller). The
  first two are also removed from the fibermorph package exports and
  __all__.
- Dropped the extended_curvature parameter from workflows.curvature,
  workflows.batch and pipeline.batch.run_batch.
- cli.py: --extended-curvature no longer enables anything. It stays
  registered, hidden from --help, only to reject old scripts with a
  clear error (see Behavior change).
- gui/app.py: removed the "Show extended (experimental) metrics" toggle
  and the Curl Index, Wave Count and Waves/mm columns from the Curvature
  view, and the "Extended curvature metrics" switch from the Run Remote
  view. The helper that runs one image now returns just the per-fragment
  table. The per-image summary still shows "Total Length (mm)": the app
  sums the fragment lengths itself; it never came from the removed
  length_total column.
- Tests: deleted test_core_curvature_extended.py (10 tests) and the two
  extended-output cases of the curvature pipeline tests. Added tests
  that the removed parameters and functions are gone, that
  --extended-curvature is rejected like --use-clahe (and that
  --extended-features for cross-sections still parses), and that the
  generated SBATCH script and the Curvature summary have no removed
  flags or columns. Added two batch tests that compare batch curvature
  output with the published v0.3.1 reference numbers. The golden-test
  docstring and README no longer mention options.

Why
Extended curvature (curl index, wave count, medial-axis skeleton) is not
part of the published fibermorph curvature method (v0.3.1, 2020). It
came from an unvalidated fork, swapped the skeletonization step for a
different algorithm, and its results were not checked against the
published method. The medial-axis step also breaks ties at random
(skimage medial_axis called without a seed), so repeated runs on the
same image could give different curvature. Curvature output now comes
only from the published method.

Behavior change
- fibermorph --curvature ... --extended-curvature now stops before doing
  any work with exit status 2 and "argument --extended-curvature: this
  option was removed because extended curvature (curl index, wave count)
  is not part of the published fibermorph curvature method". Scripts
  that still pass it (for example SBATCH scripts generated earlier by
  the web app) must have the flag deleted.
- run_batch and workflows.batch defaulted extended_curvature to True, as
  did the Run Remote switch, so batch curvature output silently used the
  medial-axis skeleton and carried the five extra columns. It now uses
  the original method: the extra columns are gone and the numbers
  change. The old batch values were not reproducible, because the
  medial-axis step breaks ties at random: for the synthetic test image
  with a 50 px window at 132 px/mm, curv_mean_mean came out between
  about 0.80 and 0.82 from one run to the next. It is now 0.8024 on
  every run, the v0.3.1 value. Batch curvature results produced before
  this change cannot be reproduced exactly; re-run them.
- workflows.curvature and curvature_seq defaulted extended_curvature to
  False, so their default output is unchanged. The command-line tool
  also defaulted to off.
- Python callers passing extended_curvature=... now get a TypeError;
  fibermorph.curl_index_from_skeleton and fibermorph.wave_count no
  longer exist.

How to verify
  python -m pytest fibermorph/test/test_cli_removed_options.py \
      fibermorph/test/test_pipeline_batch.py \
      fibermorph/test/test_curvature_golden.py \
      fibermorph/test/test_gui_app.py
  fibermorph --curvature -i in -o out --extended-curvature; echo $?  # 2

BREAKING CHANGE: the --extended-curvature flag, the extended_curvature
parameter of curvature_seq, curvature, batch and run_batch, the
functions curl_index_from_skeleton and wave_count, and the columns
curl_index, curl_index_std, wave_count, wave_count_per_mm and
length_total of the curvature_seq and batch output are removed. Batch
curvature output changes from the (run-to-run varying) medial-axis
result to the original method.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
What changed
- README.md: removed the CLAHE bullet from "What's new in v2.0", the
  curvature example that used --use-clahe and --extended-curvature, and
  the flag descriptions for both. The curvature section now says that
  curvature uses the published method (v0.3.1) with no alternative
  modes, that the two flags were removed, and what a command that still
  passes them gets (an error, exit code 2).
- CHANGELOG.md [Unreleased]: added a "Removed" entry (what was removed,
  why, what scripts that still pass the flags now get, and that
  run_batch and workflows.batch curvature output now uses the original
  method instead of the medial-axis path) and a summary under
  "Breaking Changes" that names the removed columns (curl_index,
  curl_index_std, wave_count, wave_count_per_mm and length_total of the
  curvature_seq and batch output; the GUI's own "Total Length (mm)"
  column stays). Both entries say that the old batch values were not
  reproducible, because medial_axis breaks ties at random (on the
  synthetic test image curv_mean_mean came out between about 0.80 and
  0.82 from run to run, and is now 0.8024 every time), so batch
  curvature results made before this change should be re-run. Rewrote
  the other [Unreleased] entries that described the two options as
  available: the GUI help-text entry, the golden-test entry ("default
  options"), "Curvature output refocused", the diameter entry's note
  about the medial-axis skeleton, and the scikit-image entry under
  "Fixed". The released [2.0.0] section is left as the record of what
  was added in May 2026.
- ROADMAP.md: replaced the item about validating extended curvature with
  a note that it and CLAHE were removed, dropped the three follow-up
  items about --extended-curvature, curl_index_from_skeleton and
  wave_count, and limited the planned column reference to the
  --extended-features output.
- CONTRIBUTING.md: the example pull request title "feat: validate
  wave-count on synthetic fibers" named a removed feature as work
  waiting to be done; it now reads "fix: handle an empty curvature
  directory".

Why
The code no longer has these options, so documentation that describes
them as available, or lists work to validate them, would mislead users
and anyone reading the project plan. Scripts and notes written against
earlier builds need a clear statement of what changed.

Behavior change
None. Documentation only.

How to verify
  git grep -n -i -E \
      "clahe|extended[ _-]curvature|curl[ _-]?index|wave[ _-]?count" \
      -- . ":!fibermorph/gui/visualizations.py"
  lists only the README and ROADMAP notes that the options were removed,
  the CHANGELOG, the hidden removed-flag handling in cli.py, and tests
  that check the options are gone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
What changed
- fibermorph/cli.py: new function check_numeric_options(args), called
  from parse_args() right after the --window_size check. A problem is
  reported with parser.error(), so the command exits with code 2 and a
  one-line message that starts "argument --<option>:". The rules:
    --jobs                  must not be 0 (negative values are joblib's
                            count-from-all-CPUs settings: -1 is every
                            CPU, -2 all but one; they still work)
    --resolution_mm,        must be a finite number greater than 0, and
    --resolution_mu         still finite once converted to pixels per
                            unit (a mm_per_px or um_per_px value below
                            about 5.6e-309 overflows when inverted)
    --minsize               must be 0 or more
    --maxsize               must be greater than 0
    --minsize / --maxsize   --minsize must not be larger than --maxsize
  The argparse types (int, float) are unchanged. The help text of each
  option states its limit, and parse_args() documents them.
- README.md: the curvature and section option lists state the limits,
  --resolution_mm is described as a float (it said integer, but the
  option has always been a float), and a short "Checks on numeric
  options" section lists them all.
- CHANGELOG.md ([Unreleased]): the change is described under Fixed. The
  refusal of a negative --minsize has its own Breaking Changes entry,
  next to the --window_size one, and the Fixed entry says which crash
  is not covered (see Behavior change).
- fibermorph/test/test_cli.py: parsing tests for every rule, and
  subprocess tests that run "python -m fibermorph" with 11 bad
  combinations and expect exit code 2, no "Traceback", the option name
  in the message and no output directory created; one more subprocess
  test checks that --jobs -1 still runs.

Why
These options only had a type, so out-of-range values were accepted by
argparse and failed later, deep in the run, or gave nothing:
  --jobs 0                 ValueError: n_jobs == 0 in Parallel has no
                           meaning
  --resolution_mm 0 / -5   ValueError: Resolution must be greater
                           than 0. (same for --resolution_mu)
  --resolution_mm nan      ValueError: cannot convert float NaN to
                           integer
  --resolution_mm inf      OverflowError: cannot convert float infinity
                           to integer
  --resolution_mu nan/inf  with --section: KeyError: "None of ['ID'] are
                           in the columns", or exit 0 after writing a
                           summary_section_data.csv with only its
                           header
  --maxsize 0, or --minsize larger than --maxsize
                           with --section: the same KeyError (no section
                           can match)
main() converts both resolutions for every module, so a zero or
negative --resolution_mm also stopped --section and --raw2gray runs
that never use it.

Behavior change
- Each value listed above now exits with code 2 and a usage message
  before any image is read, instead of exit code 1 with a traceback (or
  exit code 0 with an empty table).
- The checks run whichever module is chosen, so a bad --resolution_mm
  on a --section run, or a bad --resolution_mu on a --curvature run, is
  now refused although the module would not use it. A run with all of
  these options at valid values behaves as before.
- One command that used to run is now refused: a negative --minsize
  (for example --minsize -5), which acted like a lower bound of 0. A
  script that passes one needs 0 or a positive number. This includes a
  script downloaded from the GUI's Run Remote view with a "Min diameter
  (µm)" below 0; that box has no lower limit.
- Not changed: --jobs -1 and -2; --minsize 0; --minsize equal to
  --maxsize; values of --resolution_mm / --resolution_mu given in
  mm_per_px / um_per_px.
- Not fixed here: a --section run whose limits are valid but match no
  section (for example --minsize 400 --maxsize 500 on images without
  sections that size, or an absurdly large --resolution_mu) still ends
  in the same KeyError. That comes from the section workflow, which
  this change does not touch.

How to verify
  python -m pytest -q fibermorph/test/test_cli.py
By hand, each of these exits with code 2 and prints one error line:
  fibermorph --curvature -i <tiff folder> -o <out> --jobs 0
  fibermorph --curvature -i <tiff folder> -o <out> --resolution_mm nan
  fibermorph --section -i <tiff folder> -o <out> --resolution_mu 0
  fibermorph --section -i <tiff folder> -o <out> \
      --minsize 200 --maxsize 100

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
What changed
- fibermorph/cli.py: new constant MAX_WINDOW_PX (1,000,000,000) and new
  function check_window_pixel_size(args), called from parse_args() right
  after check_numeric_options(). Each --window_size value is compared
  with MAX_WINDOW_PX in pixels: as given for --window_unit px, and
  multiplied by the pixels-per-mm of --resolution_mm (after conversion
  from mm_per_px if chosen) for --window_unit mm. A longer window is
  reported with parser.error(), so the command exits with code 2 and a
  one-line message that starts "argument --window_size:" and names the
  limit, for example:
    argument --window_size: 1e+307 mm is inf pixels at --resolution_mm
    132 px_per_mm, longer than the largest window allowed
    (1,000,000,000 pixels)
  The check needs a valid resolution, so it runs after
  check_numeric_options(); a bad --resolution_mm is reported first.
  Nothing is checked when --window_size is omitted or is "none". The
  --window_size help text and the parse_args() docstring state the
  limit.
- README.md (curvature options) and CHANGELOG.md ([Unreleased]): state
  the limit. The Breaking Changes entry for zero and negative windows
  now covers over-long windows as well.
- fibermorph/test/test_cli.py: parsing tests (class TestWindowSizeLimit)
  for the boundary in px and in mm, huge values, a mm_per_px resolution,
  a sweep with one long window, other modules, and the order of the
  checks; subprocess tests that run "python -m fibermorph" with
  "--window_unit mm --window_size 1e307", "--window_size 1e300" and
  "--window_unit mm --window_size 10 --resolution_mm 1e308" and expect
  exit code 2, no "Traceback" and no output directory; and one that runs
  the largest allowed window to the end.

Why
Validation only asked for a finite number greater than 0, so very large
values passed it and then failed deep in the run, after the output
directory had been created:
  mm, --window_size 1e307        OverflowError: cannot convert float
                                 infinity to integer (window_iter in
                                 fibermorph/core/curvature.py computes
                                 int(window_size * resolution))
  mm, --window_size 10 --resolution_mm 1e308
                                 the same OverflowError
  px, --window_size 1e300        OSError: [Errno 63] File name too long,
                                 because the 300 digits of the whole
                                 number become part of the output file
                                 name ImageSum_<name>_WindowSize-
                                 <digits>px.csv
Between about 1e9 and 1e200 pixels the run did not fail, but no hair is
that long, so every image gave a row with hair_count 0 and empty
measurements.

Behavior change
- A window longer than 1,000,000,000 pixels now exits with code 2 and a
  usage message before any image is read, instead of exit code 1 with a
  traceback (very large values) or exit code 0 with an empty
  measurement (values up to about 1e200). The check runs whichever
  module is chosen, as the other --window_size checks do.
- Not changed: every window of up to 1,000,000,000 pixels, including
  windows longer than the image (they measure nothing, as before);
  output file names; the 10 pixel rule; runs without --window_size.

How to verify
  python -m pytest -q fibermorph/test/test_cli.py
By hand, each of these exits with code 2 and prints one error line, and
creates no output folder:
  fibermorph --curvature -i <tiff folder> -o <out> \
      --window_unit mm --window_size 1e307
  fibermorph --curvature -i <tiff folder> -o <out> --window_size 1e300
  fibermorph --curvature -i <tiff folder> -o <out> \
      --window_unit mm --window_size 10 --resolution_mm 1e308

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Brings in three commits that leave curvature analysis with only the
published fibermorph method (v0.3.1):

- refactor(curvature)!: remove the CLAHE preprocessing option
  (filter_curv_clahe, --use-clahe, use_clahe= parameters, the GUI
  toggle, and that path's top/bottom 15% band crop).
- refactor(curvature)!: remove the extended curvature metrics option
  (curl_index, curl_index_std, wave_count, wave_count_per_mm,
  length_total, the medial-axis skeleton path, --extended-curvature,
  extended_curvature= parameters, and the GUI toggles and SBATCH switch).
- docs: README, CHANGELOG, ROADMAP and CONTRIBUTING updated.

Behavior change:
- Scripts that pass --use-clahe or --extended-curvature now stop with a
  clear error (exit code 2) saying the option was removed.
- run_batch and workflows.batch used extended curvature by default, so
  their curvature columns came from the medial-axis skeleton and
  varied slightly from run to run. They now use the original method,
  and the results are identical on every run. Batch results made
  before this change cannot be reproduced exactly and should be re-run.
- Default curvature results are unchanged; the golden tests against
  v0.3.1 pass.
- Cross-section features, including --extended-features, are not
  affected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in four commits that fix how the command line reads its numeric
options:

- fix(cli): --window_size values are read as numbers. Before, they
  reached the curvature code as text, so any window in mm crashed with
  a TypeError and a pixel window such as "50.0" crashed with a
  ValueError. The same bug was in v0.3.1. Pixel windows must be whole
  numbers and become ints, so output files keep the ..._WindowSize-50px
  naming. mm windows stay floats (..._WindowSize-0.5mm). "none", or
  leaving the option out, fits whole hairs.
- fix(cli): the 10-pixel window floor is documented, and the error
  messages are tidied.
- fix(cli): --jobs 0, a resolution that is not a finite number greater
  than 0, a negative --minsize, --maxsize 0, and --minsize larger than
  --maxsize now stop with a usage message (exit code 2) before any image
  is read. Before, they ended in a traceback or an empty result.
- fix(cli): a --window_size longer than 1,000,000,000 pixels is refused.

Behavior change: commands that used to crash now run (mm windows) or
stop with a clear message (out-of-range values). Valid commands give
the same results and the same output names as before.

The conflict with the removal of --use-clahe / --extended-curvature in
fibermorph/cli.py was resolved by keeping both changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What changed
- fibermorph/cli.py: a --jobs larger than os.cpu_count() is reduced to
  that count, with a one-line message on stderr ("fibermorph: --jobs N
  is more than the C CPUs on this machine; using --jobs C."). The help
  text says so.
- fibermorph/cli.py: the "--window_size is too long" errors print exact
  numbers. Before, :g rounding made 1000000001 px read as "1e+09 px",
  which looked equal to the 1,000,000,000 px limit.
- fibermorph/gui/app.py, Run Remote view: "Min diameter (µm)" has a
  lower limit of 0, "Max diameter (µm)" of 1, and "Taubin window (px)"
  of 1. A warning appears when Min diameter is larger than Max diameter.
  These match the CLI's checks, so a generated SBATCH script no longer
  carries a value that fibermorph refuses.
- Tests in test_cli.py and test_gui_app.py; README.md and CHANGELOG.md
  updated.

Why
A huge --jobs crashed joblib (OverflowError, or OSError "Invalid
argument") after the output directory had been created. A value around
10,000 instead started thousands of worker processes, which kept
running after fibermorph exited. More jobs than CPUs never helped.
The Run Remote inputs had no lower limits, so they could write scripts
that the CLI's new checks stop.

Behavior change
--jobs above the machine's CPU count runs with one job per CPU instead
of crashing or hanging. On a cluster node, os.cpu_count() is the node's
CPU count, so a --jobs equal to the CPUs requested from SLURM is
unchanged. The Run Remote view refuses values below the limits above.

How to verify
pytest fibermorph/test/test_cli.py fibermorph/test/test_gui_app.py
fibermorph --curvature -i <dir> -o <out> --jobs 2147483648
(prints the message and completes)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cal/cluster

On the Streamlit-hosted app (no GPU) the "Use SAM2 segmentation" switch
could be turned on and every image then quietly fell back to watershed.

- Add sam2_runtime_status() to section_sam2: (ok, reason) from the sam2
  import and torch.cuda.is_available().
- Cross-Section: when SAM2 can't run, lock the switch off, show a warning
  with the one-line reason, and name Run Local (fibermorph-gui --local on a
  GPU machine) and Run Remote (cluster script) as the ways to run SAM2.
  The hosted checkpoint caption now only appears when SAM2 can run.
- Run Local: add how to install SAM2 and start the local GUI with it.
- Run Remote: note that SAM2 runs on the cluster's GPU node.
- README: add an "Online vs local" table; CHANGELOG entry.
- Tests: cover hosted/local unavailable, available, the real check on a
  GPU-less machine, and the Run Local/Run Remote text. Tests that need the
  local checkpoint box now simulate a GPU host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The section pipeline passes "" for --sam2-cfg and --sam2-checkpoint when
they are not given. The empty config name overrode the default, so
build_sam2 failed ("Cannot find primary config ''") and every image fell
back to watershed, even on a GPU node with SAM2 installed and an explicit
checkpoint. This also broke SAM2 in the scripts made by Run Remote.

- Treat an empty model config as "use the default config".
- Resolve an empty checkpoint as SAM2_CHECKPOINT, then the default.
- Look for the default checkpoint in fibermorph/checkpoints/ inside the
  package (where the README and the GUI say to put it), falling back to
  checkpoints/ at the repository root. It used to look only at the
  repository root.
- Add tests with a stubbed sam2/torch so they run without a GPU.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix(sam2): make --use-sam2 load SAM2 instead of silently using watershed
feat(gui): show SAM2 as unavailable on the hosted app and point to lo…
@eraser-labs

eraser-labs Bot commented Oct 5, 2026

Copy link
Copy Markdown

fibermorph-architecture

You are over your plans usage limit.

View Monitor

Upgrade your plan

View Usage

@ezhil384 ezhil384 closed this Oct 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants