diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f98437f --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..256f9bb --- /dev/null +++ b/.github/workflows/release.yml @@ -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') }} diff --git a/.gitignore b/.gitignore index 446e420..c510ac4 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ env/ # Testing .pytest_cache/ .coverage +coverage.xml htmlcov/ .tox/ diff --git a/src/hoct/features/features.py b/src/hoct/features/features.py index 9492b5c..65bcb63 100644 --- a/src/hoct/features/features.py +++ b/src/hoct/features/features.py @@ -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) diff --git a/src/hoct/features/graph.py b/src/hoct/features/graph.py index ee189fb..64de854 100644 --- a/src/hoct/features/graph.py +++ b/src/hoct/features/graph.py @@ -1,4 +1,5 @@ from functools import partial +from typing import Any import dask.array as da import numpy as np @@ -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: @@ -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: @@ -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. @@ -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 diff --git a/src/hoct/tracking/_tracklet_solver.py b/src/hoct/tracking/_tracklet_solver.py index 01b088e..b9fa6b3 100644 --- a/src/hoct/tracking/_tracklet_solver.py +++ b/src/hoct/tracking/_tracklet_solver.py @@ -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(