Skip to content
Merged
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
64 changes: 64 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
name: CI

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- name: Install uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
cache-dependency-glob: "pyproject.toml"

# Pin ruff to the version used by the pre-commit hooks so CI matches the
# project's lint baseline.
- name: Lint with ruff
run: |
uvx ruff@0.11.12 check .
uvx ruff@0.11.12 format --check .

test:
needs: lint
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12"]

steps:
- uses: actions/checkout@v5

- name: Install uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
cache-dependency-glob: "pyproject.toml"
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: uv sync --extra dev

# GEFF fixtures, model weights, and a Gurobi license are not provisioned in
# CI, so the data/model/solver tests skip; the unit tests and the
# download mechanism (served from a local HTTP server) still run.
- name: Run tests with coverage
run: uv run pytest --cov=hoct --cov-report=xml -q

- name: Upload coverage to Codecov
if: matrix.python-version == '3.12' && github.repository == 'royerlab/hoct'
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
108 changes: 108 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
name: Release

on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
test_release:
description: 'Test release (skips PyPI publish)'
required: false
default: false
type: boolean

permissions:
contents: write
id-token: write

jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 0 # required for hatch-vcs to read git tags/history

- name: Install uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
cache-dependency-glob: "pyproject.toml"
python-version: "3.12"

- name: Install dependencies
run: uv sync --extra dev

- name: Run tests
run: uv run pytest -q

- name: Build package
run: uv build

- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/

release:
needs: build
runs-on: ubuntu-latest
# Only publish from the main repo and not for dry-run releases.
if: github.repository == 'royerlab/hoct' && github.event.inputs.test_release != 'true'
environment:
name: pypi
url: https://pypi.org/p/hoct
steps:
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: dist
path: dist/

- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
attestations: true

test-release:
needs: build
runs-on: ubuntu-latest
# Dry run for workflow_dispatch test releases or forks.
if: github.event.inputs.test_release == 'true' || github.repository != 'royerlab/hoct'
steps:
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: dist
path: dist/

- name: Test PyPI upload (dry run)
run: |
echo "🧪 This would upload to PyPI:"
ls -la dist/
echo "✅ Test release completed successfully!"

github-release:
needs: build
runs-on: ubuntu-latest
if: github.repository == 'royerlab/hoct' && github.event.inputs.test_release != 'true'
steps:
- name: Checkout repository
uses: actions/checkout@v5

- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: dist
path: dist/

- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: dist/*
generate_release_notes: true
draft: false
prerelease: ${{ contains(github.ref, 'alpha') || contains(github.ref, 'beta') || contains(github.ref, 'rc') }}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ env/
# Testing
.pytest_cache/
.coverage
coverage.xml
htmlcov/
.tox/

Expand Down
17 changes: 15 additions & 2 deletions src/hoct/features/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,21 @@ def normalize_image(
clip: bool = False,
uq: float = 0.999,
) -> NDArray:
"""
Normalize an image to [0, 1] using the quantile.
"""Normalize an image to ``[0, 1]`` using an upper quantile.

Parameters
----------
image : NDArray
Image to normalize.
clip : bool, default=False
If True, clip the normalized values to ``[0, 1]``.
uq : float, default=0.999
Upper quantile used as the normalization maximum (robust to outliers).

Returns
-------
NDArray
The normalized image as ``float32``.
"""
image = np.asarray(image, dtype=np.float32)

Expand Down
18 changes: 17 additions & 1 deletion src/hoct/features/graph.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from functools import partial
from typing import Any

import dask.array as da
import numpy as np
Expand All @@ -9,7 +10,7 @@
from tracksdata.utils._multiprocessing import multiprocessing_apply

from hoct.features.constants import EDGE_GT_KEY, REGIONPROPS
from hoct.features.features import add_border_dist, add_delta_t, add_is_div
from hoct.features.features import add_border_dist, add_delta_t, add_is_div, normalize_image


def convert_to_3d(graph: td.graph.RustWorkXGraph) -> None:
Expand Down Expand Up @@ -69,6 +70,8 @@ def create_graph(
delta_t: float,
scale: tuple[float, ...] | None = None,
images: ArrayLike | None = None,
normalize_images: bool = True,
normalize_kwargs: dict[str, Any] | None = None,
gt_graph: td.graph.BaseGraph | None = None,
out_graph: td.graph.BaseGraph | None = None,
) -> td.graph.InMemoryGraph:
Expand All @@ -92,6 +95,13 @@ def create_graph(
images : ArrayLike | None
Optional intensity images of shape (T, [Z,] Y, X).
If None, only geometric features are computed.
normalize_images : bool, default=True
If True and ``images`` is provided, normalize each time point with
:func:`hoct.features.normalize_image` before extracting intensity
features. Has no effect when ``images`` is None.
normalize_kwargs : dict[str, Any] | None
Keyword arguments forwarded to :func:`hoct.features.normalize_image`
(e.g. ``clip``, ``uq``). Defaults to an empty dict.
gt_graph : td.graph.BaseGraph | None
Optional ground truth graph for training. If provided, adds ground truth edge labels.
If None (inference mode), skips ground truth-related features.
Expand Down Expand Up @@ -123,6 +133,12 @@ def create_graph(
if images is not None and not isinstance(images, da.Array):
images = da.from_array(images)

if images is not None and normalize_images:
images = images.rechunk((1, *images.shape[1:]))
if normalize_kwargs is None:
normalize_kwargs = {}
images = images.map_blocks(normalize_image, **normalize_kwargs)

if scale is None:
scale = (1.0,) * labels.ndim

Expand Down
2 changes: 0 additions & 2 deletions src/hoct/tracking/_tracklet_solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,6 @@ def _tracklet_graph(
list(tracklet_edges_df.iter_rows(named=True)),
)

tracklet_graph.summary(attrs_stats=True)

tracklet_invalid_edges = tracklet_graph.edge_attrs().filter(pl.col("delta_t") <= 0)
if len(tracklet_invalid_edges) > 0:
raise ValueError(
Expand Down
Loading