diff --git a/.github/workflows/build_installers.yml b/.github/workflows/build_installers.yml new file mode 100644 index 000000000..206561ce7 --- /dev/null +++ b/.github/workflows/build_installers.yml @@ -0,0 +1,116 @@ +name: Build Installers + +on: + workflow_dispatch: + inputs: + ref: + description: "Git ref to build from (tag or branch, e.g., v2026.2.18)" + required: false + type: string + channels: + description: "Conda channels (space-separated, e.g., 'conda-forge royerlab')" + required: false + default: "conda-forge" + type: string + pull_request: + branches: [main] + paths: + - "packaging/**" + - ".github/workflows/build_installers.yml" + +jobs: + build: + name: Build (${{ matrix.platform }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + platform: linux-64 + - os: macos-14 + platform: osx-arm64 + - os: windows-latest + platform: win-64 + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.sha }} + + - name: Set up Miniconda + uses: conda-incubator/setup-miniconda@v3 + with: + miniforge-version: latest + activate-environment: aydin-build-installer + environment-file: packaging/environments/build_installer.yml + auto-activate-base: false + + - name: Generate icons + shell: bash -l {0} + run: python packaging/scripts/generate_icons.py + + - name: Build installer (dry run on PR) + if: github.event_name == 'pull_request' + shell: bash -l {0} + run: | + python packaging/build_installer.py \ + --output-dir _work \ + --dry-run + + - name: Build installer + if: github.event_name != 'pull_request' + shell: bash -l {0} + run: | + CHANNELS="${{ inputs.channels || 'conda-forge' }}" + python packaging/build_installer.py \ + --output-dir _work \ + --channels $CHANNELS + + - name: Smoke test installer (Linux) + if: matrix.platform == 'linux-64' && github.event_name != 'pull_request' + shell: bash -l {0} + run: | + INSTALLER=$(ls _work/AydinStudio-*.sh 2>/dev/null | head -1) + if [ -n "$INSTALLER" ]; then + echo "Testing installer: $INSTALLER" + bash "$INSTALLER" -b -p /tmp/aydin-test + /tmp/aydin-test/bin/aydin --version + echo "Smoke test passed!" + fi + + - name: Upload installer artifact + if: github.event_name != 'pull_request' + uses: actions/upload-artifact@v4 + with: + name: installer-${{ matrix.platform }} + path: | + _work/AydinStudio-* + if-no-files-found: warn + retention-days: 30 + + attach-to-release: + name: Attach to GitHub Release + needs: build + if: github.event_name == 'workflow_dispatch' && startsWith(inputs.ref, 'v') + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + path: installers/ + merge-multiple: true + + - name: Upload installers to release + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + TAG="${{ inputs.ref }}" + echo "Uploading installers to release $TAG" + ls -la installers/ + gh release upload "$TAG" installers/AydinStudio-* --clobber \ + --repo "${{ github.repository }}" || echo "Warning: upload failed (release may not exist yet)" diff --git a/.gitignore b/.gitignore index 98d87f095..0fc8a3b35 100644 --- a/.gitignore +++ b/.gitignore @@ -112,8 +112,14 @@ demo_results/ .zgroup *.png !docs/source/resources/**/*.png +!packaging/icons/*.png +!packaging/icons/*.ico +!packaging/icons/*.icns src/aydin/_version.py # Temporary working directories delme/ + +# Conda-constructor build artifacts +_work/ diff --git a/Makefile b/Makefile index 9fa82c02b..6f12d1a0b 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help setup install install-dev test test-cov test-cov-check test-heavy test-gpu test-unstable test-gui check format format-check lint validate clean build publish publish-patch docs docs-screenshots docs-build docs-publish docker-build docker-build-cli docker-build-gpu docker-build-studio docker-run-studio docker-test docker-test-all +.PHONY: help setup install install-dev test test-cov test-cov-check test-heavy test-gpu test-unstable test-gui check format format-check lint validate clean build publish publish-patch docs docs-screenshots docs-build docs-publish docker-build docker-build-cli docker-build-gpu docker-build-studio docker-run-studio docker-test docker-test-all installer installer-icons installer-env installer-clean help: @echo "Available commands:" @@ -31,6 +31,12 @@ help: @echo " make docker-test - Build + smoke test CLI image" @echo " make docker-test-all - Build + smoke test CLI + Studio images" @echo "" + @echo "Conda Packaging:" + @echo " make installer - Build native installer for current platform" + @echo " make installer-icons - Generate .ico and .icns from source PNG" + @echo " make installer-env - Create conda env with build tools" + @echo " make installer-clean - Clean installer build artifacts" + @echo "" @echo "Documentation:" @echo " make docs - Build HTML docs (regenerates screenshots first)" @echo " make docs-screenshots - Regenerate napari plugin screenshots only" @@ -177,6 +183,19 @@ docker-test-all: @command -v docker >/dev/null 2>&1 || { echo "Error: Docker is not installed."; exit 1; } ./docker/test-smoke.sh --all +# Conda packaging (native installers via conda-constructor) +installer: installer-icons + python packaging/build_installer.py --output-dir _work + +installer-icons: + python packaging/scripts/generate_icons.py + +installer-env: + conda env create -f packaging/environments/build_installer.yml --force + +installer-clean: + rm -rf _work/ + publish-patch: validate @echo "Current version: $(CURRENT_VERSION)" @command -v gh >/dev/null 2>&1 || { echo "Error: GitHub CLI (gh) is required. Install from https://cli.github.com"; exit 1; } diff --git a/packaging/LICENSE.rtf b/packaging/LICENSE.rtf new file mode 100644 index 000000000..53297c380 --- /dev/null +++ b/packaging/LICENSE.rtf @@ -0,0 +1,19 @@ +{\rtf1\ansi\ansicpg1252\deff0 +{\fonttbl{\f0\fswiss Helvetica;}{\f1\fmodern Courier New;}} +{\colortbl;\red0\green0\blue0;} +\f0\fs24 +\b BSD 3-Clause License\b0\par +\par +Copyright (c) 2022, Aydin\par +All rights reserved.\par +\par +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:\par +\par +\bullet Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.\par +\par +\bullet Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.\par +\par +\bullet Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.\par +\par +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\par +} diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 000000000..0ff78c2a6 --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,144 @@ +# Conda-Constructor Packaging + +This directory contains everything needed to build native OS installers for **Aydin Studio** using [conda-constructor](https://github.com/conda/constructor). + +| Platform | Format | Output | +|----------|--------|--------| +| Linux x86_64 | `.sh` | Shell script installer | +| macOS ARM64 | `.pkg` | Native macOS GUI installer | +| Windows x64 | `.exe` | NSIS-based GUI installer | + +## Architecture + +The installer creates a single conda environment containing Python, Aydin, and all dependencies. A desktop shortcut ("Aydin Studio") is created via [menuinst](https://github.com/conda/menuinst) v2. + +``` +packaging/ + build_installer.py # Generates construct.yaml and invokes constructor + LICENSE.rtf # License for installer UI (macOS/Windows) + conda-recipe/ + aydin/recipe.yaml # Conda recipe for the aydin package + aydin-menu/recipe.yaml # Conda recipe for the desktop shortcut package + menuinst/ + aydin-studio.json # menuinst v2 JSON (cross-platform shortcuts) + icons/ + aydin_icon.png # Linux icon (295x295) + aydin_icon.ico # Windows icon (multi-size) + aydin_icon.icns # macOS icon + scripts/ + generate_icons.py # Converts source PNG to ICO/ICNS + post_install/ + post_install.sh # Unix post-install script + post_install.bat # Windows post-install script + environments/ + build_installer.yml # Conda env for building installers +``` + +## Prerequisites + +1. **conda or mamba** installed (e.g., via [Miniforge](https://github.com/conda-forge/miniforge)) +2. The `aydin` and `aydin-menu` packages must be available on a conda channel (conda-forge or a custom channel like `anaconda.org/royerlab`) + +## Quick Start + +```bash +# 1. Create the build environment (one-time setup) +make installer-env + +# 2. Activate the environment +conda activate aydin-build-installer + +# 3. Build the installer for your platform +make installer + +# Output appears in _work/ +ls _work/AydinStudio-* +``` + +## Dry Run + +Generate `construct.yaml` without building: + +```bash +python packaging/build_installer.py --dry-run +``` + +## Custom Channels + +To include a custom channel (e.g., during bootstrap before conda-forge has the package): + +```bash +python packaging/build_installer.py --channels conda-forge royerlab +``` + +## GPU Support + +The installer ships **CPU-only PyTorch** to keep the download size reasonable. Users who need GPU acceleration can add CUDA support after installation: + +```bash +# Activate the installed environment +conda activate /path/to/aydin + +# Add CUDA support +conda install pytorch-cuda -c conda-forge +``` + +## Icon Generation + +Icons are pre-committed to `packaging/icons/`. To regenerate from the source PNG: + +```bash +# Requires Pillow: pip install Pillow +make installer-icons +``` + +On macOS, the script uses `iconutil` (from Xcode Command Line Tools) for best `.icns` quality. On other platforms, it falls back to Pillow's built-in `.icns` support. + +## CI/CD + +The GitHub Actions workflow (`.github/workflows/build_installers.yml`) builds installers for all 3 platforms. + +- **On PRs** touching `packaging/`: runs a **dry-run** only (validates construct.yaml generation) +- **On `workflow_dispatch`**: builds real installers and uploads as artifacts +- **With a tag ref** (e.g., `v2026.2.18`): also attaches installers to the GitHub Release + +### Manual Trigger + +Go to Actions > "Build Installers" > Run workflow, and provide: +- **ref**: git tag or branch (e.g., `v2026.2.18`) +- **channels**: conda channels (default: `conda-forge`) + +## Code Signing (Future) + +Code signing is not yet enabled but the infrastructure is ready. When certificates are available: + +### macOS + +Set these GitHub Actions secrets: +- `CONSTRUCTOR_SIGNING_IDENTITY`: Developer ID Installer certificate name +- `CONSTRUCTOR_NOTARIZATION_IDENTITY`: Apple ID for notarization + +The `build_installer.py` script reads these from environment variables. + +### Windows + +Set this secret: +- `CONSTRUCTOR_SIGNING_CERTIFICATE`: Path to PFX certificate file + +Without code signing, installers work but users see OS security warnings (Gatekeeper on macOS, SmartScreen on Windows). + +## Troubleshooting + +### "constructor not found" + +Activate the build environment: `conda activate aydin-build-installer` + +### Solver conflicts + +If conda's solver takes too long or fails: +1. Ensure `conda-libmamba-solver` is installed (included in `build_installer.yml`) +2. Try pinning fewer packages — `build_installer.py` lists explicit deps for solver reliability, but you can remove some if they cause conflicts + +### Icon generation fails + +Ensure Pillow is installed: `pip install Pillow`. For best `.icns` quality on macOS, install Xcode Command Line Tools: `xcode-select --install`. diff --git a/packaging/build_installer.py b/packaging/build_installer.py new file mode 100644 index 000000000..f57e01f4c --- /dev/null +++ b/packaging/build_installer.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python3 +"""Build Aydin Studio native installers using conda-constructor. + +Generates a construct.yaml from configuration and invokes ``constructor`` +to produce a platform-native installer: + - Linux: .sh (shell script installer) + - macOS: .pkg (native GUI installer) + - Windows: .exe (NSIS-based GUI installer) + +Requires the ``aydin-build-installer`` conda environment +(see packaging/environments/build_installer.yml). + +Usage: + # Build installer for current platform + python packaging/build_installer.py + + # Dry run (print construct.yaml without building) + python packaging/build_installer.py --dry-run + + # Custom channels and output directory + python packaging/build_installer.py \\ + --channels conda-forge royerlab --output-dir _work +""" + +import argparse +import os +import platform +import re +import shutil +import subprocess +from pathlib import Path + +try: + from ruamel.yaml import YAML +except ImportError: + YAML = None + +REPO_ROOT = Path(__file__).resolve().parent.parent +PACKAGING_DIR = Path(__file__).resolve().parent + +# Default Python version for the installer environment +DEFAULT_PYTHON_VERSION = "3.11" + +# Default conda channels (order matters: first has highest priority) +DEFAULT_CHANNELS = ["conda-forge"] + + +def _get_version() -> str: + """Read the Aydin version from src/aydin/__init__.py.""" + init_file = REPO_ROOT / "src" / "aydin" / "__init__.py" + text = init_file.read_text() + match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', text) + if not match: + raise RuntimeError(f"Could not find __version__ in {init_file}") + return match.group(1) + + +def _detect_platform() -> str: + """Detect the current platform as a conda-style platform string.""" + system = platform.system().lower() + machine = platform.machine().lower() + + if system == "linux": + return "linux-64" if machine in ("x86_64", "amd64") else f"linux-{machine}" + elif system == "darwin": + return "osx-arm64" if machine == "arm64" else "osx-64" + elif system == "windows": + return "win-64" + else: + raise RuntimeError(f"Unsupported platform: {system}-{machine}") + + +def _installer_filename(version: str, platform_str: str) -> str: + """Generate the installer output filename.""" + ext_map = { + "linux": "sh", + "osx": "pkg", + "win": "exe", + } + os_key = platform_str.split("-")[0] + ext = ext_map.get(os_key, "sh") + return f"AydinStudio-{version}-{platform_str}.{ext}" + + +def _build_definitions( + version: str, + platform_str: str, + channels: list, + python_version: str, +) -> dict: + """Build the construct.yaml definitions dictionary.""" + os_key = platform_str.split("-")[0] + + definitions = { + "name": "AydinStudio", + "version": version, + "company": "Royer Lab", + "reverse_domain_identifier": "org.royerlab.aydin", + "installer_filename": _installer_filename(version, platform_str), + "channels": channels, + "specs": [ + f"python={python_version}.*", + "aydin", + "aydin-menu", + # Explicit core deps for solver reliability: + "numpy", + "scipy", + "scikit-image", + "numba", + "pytorch-cpu", # CPU-only PyTorch from conda-forge + "catboost", + "lightgbm", + "napari", + "pyqt6", + "qtpy", + "qdarkstyle", + ], + "menu_packages": ["aydin-menu"], + "initialize_by_default": False, + "register_python_default": False, + "register_envs": False, + "condarc": { + "channels": channels, + "channel_priority": "strict", + }, + } + + # License file (will be copied to output_dir by _write_construct_yaml) + rtf_license = PACKAGING_DIR / "LICENSE.rtf" + txt_license = REPO_ROOT / "LICENSE.txt" + if os_key in ("osx", "win") and rtf_license.exists(): + definitions["_license_src"] = rtf_license + definitions["license_file"] = "LICENSE.rtf" + else: + definitions["_license_src"] = txt_license + definitions["license_file"] = "LICENSE.txt" + + # Platform-specific settings + post_install = None + if os_key == "linux": + definitions["installer_type"] = "sh" + definitions["default_prefix"] = "$HOME/.local/aydin" + post_install = PACKAGING_DIR / "post_install" / "post_install.sh" + elif os_key == "osx": + definitions["installer_type"] = "pkg" + definitions["pkg_name"] = "org.royerlab.aydin" + definitions["default_location_pkg"] = "/Library" + post_install = PACKAGING_DIR / "post_install" / "post_install.sh" + # Signing (enabled via env vars when certificates are available) + signing_id = os.environ.get("CONSTRUCTOR_SIGNING_IDENTITY") + if signing_id: + definitions["signing_identity_name"] = signing_id + notarization_id = os.environ.get("CONSTRUCTOR_NOTARIZATION_IDENTITY") + if notarization_id: + definitions["notarization_identity_name"] = notarization_id + elif os_key == "win": + definitions["installer_type"] = "exe" + definitions["default_prefix"] = "%LOCALAPPDATA%\\aydin" + icon_path = PACKAGING_DIR / "icons" / "aydin_icon.ico" + if icon_path.exists(): + definitions["_icon_src"] = icon_path + definitions["icon_image"] = "aydin_icon.ico" + post_install = PACKAGING_DIR / "post_install" / "post_install.bat" + # Signing (enabled via env var when certificate is available) + cert = os.environ.get("CONSTRUCTOR_SIGNING_CERTIFICATE") + if cert: + definitions["signing_certificate"] = cert + + if post_install and post_install.exists(): + definitions["_post_install_src"] = post_install + definitions["post_install"] = post_install.name + + return definitions + + +def _write_construct_yaml(definitions: dict, output_dir: Path) -> Path: + """Write construct.yaml to the output directory. + + Copies referenced files (license, post_install, icon) into the output + directory so that construct.yaml can use relative paths. Internal keys + prefixed with ``_`` are stripped before writing. + """ + output_dir.mkdir(parents=True, exist_ok=True) + + # Copy referenced files into the output directory + for key in ("_license_src", "_post_install_src", "_icon_src"): + src = definitions.pop(key, None) + if src and Path(src).exists(): + shutil.copy2(str(src), str(output_dir / Path(src).name)) + + construct_path = output_dir / "construct.yaml" + + if YAML is not None: + yaml = YAML() + yaml.default_flow_style = False + with open(construct_path, "w") as f: + yaml.dump(definitions, f) + else: + # Fallback: simple YAML writing without ruamel + import json + + # Convert to YAML-compatible format via JSON round-trip + with open(construct_path, "w") as f: + for key, value in definitions.items(): + if isinstance(value, list): + f.write(f"{key}:\n") + for item in value: + f.write(f" - {item}\n") + elif isinstance(value, dict): + f.write(f"{key}:\n") + for k, v in value.items(): + if isinstance(v, list): + f.write(f" {k}:\n") + for item in v: + f.write(f" - {item}\n") + else: + f.write(f" {k}: {json.dumps(v)}\n") + elif isinstance(value, bool): + f.write(f"{key}: {'true' if value else 'false'}\n") + else: + f.write(f"{key}: {value}\n") + + return construct_path + + +def _run_constructor(output_dir: Path, platform_str: str = None) -> None: + """Invoke constructor to build the installer.""" + if not shutil.which("constructor"): + raise RuntimeError( + "constructor not found. Install it with:\n" + " conda env create -f packaging/environments/build_installer.yml\n" + " conda activate aydin-build-installer" + ) + + cmd = ["constructor", str(output_dir)] + if platform_str: + cmd.extend(["--platform", platform_str]) + + print(f"\nRunning: {' '.join(cmd)}") + result = subprocess.run(cmd) + if result.returncode != 0: + raise RuntimeError(f"constructor failed with exit code {result.returncode}") + + +def main(): + parser = argparse.ArgumentParser( + description="Build Aydin Studio native installer", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "Examples:\n" + " %(prog)s # Build for current platform\n" + " %(prog)s --dry-run # Print construct.yaml only\n" + " %(prog)s --channels conda-forge # Use only conda-forge\n" + " %(prog)s --platform linux-64 # Cross-compile target\n" + ), + ) + parser.add_argument( + "--python-version", + default=DEFAULT_PYTHON_VERSION, + help=f"Python version for the installer (default: {DEFAULT_PYTHON_VERSION})", + ) + parser.add_argument( + "--channels", + nargs="+", + default=DEFAULT_CHANNELS, + help="Conda channels in priority order (default: conda-forge)", + ) + parser.add_argument( + "--output-dir", + default="_work", + help="Output directory for construct.yaml and installer (default: _work)", + ) + parser.add_argument( + "--platform", + default=None, + help="Target platform (default: auto-detect). " + "Options: linux-64, osx-arm64, osx-64, win-64", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Generate construct.yaml and print it, but do not run constructor", + ) + args = parser.parse_args() + + version = _get_version() + platform_str = args.platform or _detect_platform() + output_dir = Path(args.output_dir) + + print(f"Aydin version: {version}") + print(f"Target platform: {platform_str}") + print(f"Python version: {args.python_version}") + print(f"Channels: {args.channels}") + print(f"Output dir: {output_dir}") + + definitions = _build_definitions( + version=version, + platform_str=platform_str, + channels=args.channels, + python_version=args.python_version, + ) + + construct_path = _write_construct_yaml(definitions, output_dir) + print(f"\nGenerated: {construct_path}") + + # Print the construct.yaml contents + print("\n--- construct.yaml ---") + print(construct_path.read_text()) + print("--- end ---\n") + + if args.dry_run: + print("Dry run complete. Skipping constructor invocation.") + return + + _run_constructor(output_dir, platform_str) + + # List output files + print("\nBuild artifacts:") + for f in sorted(output_dir.iterdir()): + if f.name != "construct.yaml": + size_mb = f.stat().st_size / (1024 * 1024) + print(f" {f.name} ({size_mb:.1f} MB)") + + +if __name__ == "__main__": + main() diff --git a/packaging/conda-forge-recipe/meta.yaml b/packaging/conda-forge-recipe/meta.yaml new file mode 100644 index 000000000..716a3f68c --- /dev/null +++ b/packaging/conda-forge-recipe/meta.yaml @@ -0,0 +1,86 @@ +{% set name = "aydin" %} +{% set version = "2026.2.18" %} + +package: + name: {{ name|lower }} + version: {{ version }} + +source: + url: https://pypi.org/packages/source/{{ name[0] }}/{{ name }}/{{ name }}-{{ version }}.tar.gz + # sha256: UPDATE_WITH_ACTUAL_HASH_AFTER_PYPI_PUBLISH + +build: + noarch: python + number: 0 + script: {{ PYTHON }} -m pip install . -vv --no-deps --no-build-isolation + entry_points: + - aydin = aydin.cli.cli:cli + +requirements: + host: + - python >=3.9 + - pip + - hatchling + run: + - python >=3.9 + # ML Libraries + - catboost >=1.2.5,<2 + - lightgbm >=4.0.0,<5 + - pytorch >=2.4.0,<3 + # Scientific Computing + - numpy >=2.0.0,<3 + - scipy >=1.12.0,<2 + - scikit-image >=0.22.0,<0.25 + - numba >=0.59.0,<0.62 + - pywavelets >=1.6.0,<2 + # Image I/O + - tifffile + - imageio >=2.34.0,<3 + - imagecodecs >=2024.1.1,<2027 + - czifile >=2019.7.2,<2025 + - nd2reader >=3.3.0,<4 + - zarr >=2.17.0,<3 + # GUI + - napari >=0.5.0,<0.8 + - pyqt6 >=6.5 + - qdarkstyle >=3.2.0,<4 + - qtpy >=2.4.0,<3 + # CLI & Utilities + - click >=8.1.0,<9 + - requests >=2.28.0,<3 + - jsonpickle >=3.0.0,<4 + - memoization >=0.4.0,<1 + - deprecated >=1.2.13,<2 + - docstring_parser >=0.15,<1 + - arbol >=2023.1.0,<2027 + - ansicolors >=1.1.8,<2 + - colorama >=0.4.6,<1 + - pynndescent >=0.5.10,<1 + - importlib-metadata >=6.0.0,<8 + +test: + imports: + - aydin + - aydin.cli.cli + - aydin.restoration.denoise.classic + commands: + - pip check + - aydin --help + requires: + - pip + +about: + home: https://royerlab.github.io/aydin/ + summary: Self-supervised, auto-tuned image denoising for n-dimensional images + description: | + Aydin provides four interfaces (GUI, napari plugin, CLI, Python API) + for image denoising using classical, patch-based, and ML approaches + including CNN, gradient boosting, and self-supervised methods. + license: BSD-3-Clause + license_file: LICENSE.txt + dev_url: https://github.com/royerlab/aydin + doc_url: https://royerlab.github.io/aydin/ + +extra: + recipe-maintainers: + - royerloic diff --git a/packaging/conda-recipe/aydin-menu/recipe.yaml b/packaging/conda-recipe/aydin-menu/recipe.yaml new file mode 100644 index 000000000..7f858b7d6 --- /dev/null +++ b/packaging/conda-recipe/aydin-menu/recipe.yaml @@ -0,0 +1,40 @@ +context: + name: aydin-menu + version: ${{ env.get("AYDIN_VERSION", default="0.0.0") }} + +package: + name: ${{ name }} + version: ${{ version }} + +source: + path: ../../../ + +build: + noarch: generic + number: 0 + script: + - mkdir -p "${PREFIX}/Menu" # [unix] + - cp packaging/menuinst/aydin-studio.json "${PREFIX}/Menu/aydin-studio.json" # [unix] + - cp packaging/icons/aydin_icon.png "${PREFIX}/Menu/aydin_icon.png" # [unix] + - cp packaging/icons/aydin_icon.ico "${PREFIX}/Menu/aydin_icon.ico" # [unix] + - cp packaging/icons/aydin_icon.icns "${PREFIX}/Menu/aydin_icon.icns" # [unix] + - if not exist "%PREFIX%\Menu" mkdir "%PREFIX%\Menu" # [win] + - copy packaging\menuinst\aydin-studio.json "%PREFIX%\Menu\aydin-studio.json" # [win] + - copy packaging\icons\aydin_icon.png "%PREFIX%\Menu\aydin_icon.png" # [win] + - copy packaging\icons\aydin_icon.ico "%PREFIX%\Menu\aydin_icon.ico" # [win] + - copy packaging\icons\aydin_icon.icns "%PREFIX%\Menu\aydin_icon.icns" # [win] + +requirements: + run: + - aydin + +tests: + - files: + source: + - packaging/menuinst/aydin-studio.json + +about: + homepage: https://royerlab.github.io/aydin/ + license: BSD-3-Clause + license_file: LICENSE.txt + summary: Desktop menu shortcut for Aydin Studio diff --git a/packaging/conda-recipe/aydin/recipe.yaml b/packaging/conda-recipe/aydin/recipe.yaml new file mode 100644 index 000000000..4b735eae0 --- /dev/null +++ b/packaging/conda-recipe/aydin/recipe.yaml @@ -0,0 +1,80 @@ +context: + name: aydin + python_min: "3.9" + version: ${{ env.get("AYDIN_VERSION", default="0.0.0") }} + +package: + name: ${{ name }} + version: ${{ version }} + +source: + path: ../../../ + +build: + noarch: python + number: 0 + script: python -m pip install . --no-deps --no-build-isolation -vv + entry_points: + - aydin = aydin.cli.cli:cli + +requirements: + host: + - python >=${{ python_min }} + - pip + - hatchling + run: + - python >=${{ python_min }} + # ML Libraries + - catboost >=1.2.5,<2 + - lightgbm >=4.0.0,<5 + - pytorch >=2.4.0,<3 + # Scientific Computing + - numpy >=2.0.0,<3 + - scipy >=1.12.0,<2 + - scikit-image >=0.22.0,<0.25 + - numba >=0.59.0,<0.62 + - pywavelets >=1.6.0,<2 + # Image I/O + - tifffile + - imageio >=2.34.0,<3 + - imagecodecs >=2024.1.1,<2027 + - czifile >=2019.7.2,<2025 + - nd2reader >=3.3.0,<4 + - zarr >=2.17.0,<3 + # GUI + - napari >=0.5.0,<0.8 + - pyqt6 >=6.5 + - qdarkstyle >=3.2.0,<4 + - qtpy >=2.4.0,<3 + # CLI & Utilities + - click >=8.1.0,<9 + - requests >=2.28.0,<3 + - jsonpickle >=3.0.0,<4 + - memoization >=0.4.0,<1 + - deprecated >=1.2.13,<2 + - docstring-parser >=0.15,<1 + - arbol >=2023.1.0,<2027 + - ansicolors >=1.1.8,<2 + - colorama >=0.4.6,<1 + - pynndescent >=0.5.10,<1 + - importlib-metadata >=6.0.0,<8 + +tests: + - python: + imports: + - aydin + - aydin.restoration.denoise.classic + - aydin.cli.cli + - script: + - aydin --help + +about: + homepage: https://royerlab.github.io/aydin/ + license: BSD-3-Clause + license_file: LICENSE.txt + summary: Self-supervised, auto-tuned image denoising for n-dimensional images + description: | + Aydin provides four interfaces (GUI, napari plugin, CLI, Python API) + for image denoising using classical, patch-based, and ML approaches. + dev_url: https://github.com/royerlab/aydin + doc_url: https://royerlab.github.io/aydin/ diff --git a/packaging/environments/build_installer.yml b/packaging/environments/build_installer.yml new file mode 100644 index 000000000..d392c60ca --- /dev/null +++ b/packaging/environments/build_installer.yml @@ -0,0 +1,12 @@ +name: aydin-build-installer +channels: + - conda-forge +dependencies: + - python=3.11.* + - constructor>=3.11.0 + - conda-build>=3.28 + - conda-standalone>=24.7.1 + - conda>=24.7.1 + - conda-libmamba-solver + - ruamel.yaml + - pillow diff --git a/packaging/icons/aydin_icon.icns b/packaging/icons/aydin_icon.icns new file mode 100644 index 000000000..7e836673f Binary files /dev/null and b/packaging/icons/aydin_icon.icns differ diff --git a/packaging/icons/aydin_icon.ico b/packaging/icons/aydin_icon.ico new file mode 100644 index 000000000..e708275b8 Binary files /dev/null and b/packaging/icons/aydin_icon.ico differ diff --git a/packaging/icons/aydin_icon.png b/packaging/icons/aydin_icon.png new file mode 100755 index 000000000..75a726d24 Binary files /dev/null and b/packaging/icons/aydin_icon.png differ diff --git a/packaging/menuinst/aydin-studio.json b/packaging/menuinst/aydin-studio.json new file mode 100644 index 000000000..78411e9f3 --- /dev/null +++ b/packaging/menuinst/aydin-studio.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://schemas.conda.io/menuinst/menuinst-1-1-0.schema.json", + "$id": "https://github.com/royerlab/aydin", + "menu_name": "Aydin Studio", + "menu_items": [ + { + "name": "Aydin Studio", + "description": "Self-supervised image denoising for n-dimensional images", + "icon": "{{ MENU_DIR }}/aydin_icon.{{ ICON_EXT }}", + "command": [ + "{{ PYTHON }}", + "-c", + "from aydin.gui.gui import run; from aydin import __version__; run(__version__)" + ], + "activate": true, + "terminal": false, + "platforms": { + "linux": { + "Categories": [ + "Science", + "Education", + "Graphics", + "2DGraphics" + ], + "MimeType": [ + "image/tiff" + ] + }, + "osx": { + "CFBundleIdentifier": "org.royerlab.aydin", + "CFBundleDisplayName": "Aydin Studio", + "CFBundleName": "Aydin Studio", + "LSEnvironment": { + "LANG": "en_US.UTF-8" + } + }, + "win": { + "desktop": true, + "app_user_model_id": "org.royerlab.aydin", + "file_extensions": [ + ".tif", + ".tiff", + ".czi", + ".nd2" + ], + "command": [ + "{{ PYTHONW }}", + "-c", + "from aydin.gui.gui import run; from aydin import __version__; run(__version__)" + ] + } + } + } + ] +} diff --git a/packaging/post_install/post_install.bat b/packaging/post_install/post_install.bat new file mode 100644 index 000000000..422fb1d0c --- /dev/null +++ b/packaging/post_install/post_install.bat @@ -0,0 +1,26 @@ +@echo off +REM Post-installation script for Aydin Studio (Windows) +REM +REM This runs inside the installed conda environment after constructor +REM finishes extracting and linking all conda packages. +REM +REM Use this to pip-install any packages not yet available on conda-forge. +REM Once all dependencies are on conda-forge, this script becomes a no-op. + +echo Aydin Studio post-install: finalizing installation... + +REM --- pip fallback for packages not yet on conda-forge --- +REM Uncomment lines below if needed during the bootstrap period: +REM "%PREFIX%\Scripts\pip.exe" install --no-deps --quiet arbol +REM "%PREFIX%\Scripts\pip.exe" install --no-deps --quiet czifile + +echo. +echo ================================================================ +echo Aydin Studio installation complete! +echo. +echo Launch from your Start Menu or Desktop shortcut. +echo. +echo For GPU support (NVIDIA), open Anaconda Prompt and run: +echo conda install pytorch-cuda -c conda-forge +echo ================================================================ +echo. diff --git a/packaging/post_install/post_install.sh b/packaging/post_install/post_install.sh new file mode 100644 index 000000000..0d049cadb --- /dev/null +++ b/packaging/post_install/post_install.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# Post-installation script for Aydin Studio (Linux / macOS) +# +# This runs inside the installed conda environment after constructor +# finishes extracting and linking all conda packages. +# +# Use this to pip-install any packages not yet available on conda-forge. +# Once all dependencies are on conda-forge, this script becomes a no-op. + +set -euo pipefail + +echo "Aydin Studio post-install: finalizing installation..." + +# --- pip fallback for packages not yet on conda-forge --- +# Uncomment lines below if needed during the bootstrap period: +# "${PREFIX}/bin/pip" install --no-deps --quiet arbol +# "${PREFIX}/bin/pip" install --no-deps --quiet czifile + +echo "" +echo "================================================================" +echo " Aydin Studio installation complete!" +echo "" +echo " Launch from your Applications menu, or run:" +echo " ${PREFIX}/bin/aydin" +echo "" +echo " For GPU support (NVIDIA), activate the environment and run:" +echo " conda install pytorch-cuda -c conda-forge" +echo "================================================================" +echo "" diff --git a/packaging/scripts/generate_icons.py b/packaging/scripts/generate_icons.py new file mode 100644 index 000000000..3a1a7a343 --- /dev/null +++ b/packaging/scripts/generate_icons.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Generate .ico and .icns icon files from the source PNG. + +Requires: Pillow (pip install Pillow) +On macOS: uses iconutil (ships with Xcode Command Line Tools) for best .icns +On other platforms: uses Pillow's built-in .icns support (limited sizes) + +Usage: + python packaging/scripts/generate_icons.py +""" + +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +try: + from PIL import Image +except ImportError: + print("Error: Pillow is required. Install with: pip install Pillow") + sys.exit(1) + +# Paths +REPO_ROOT = Path(__file__).resolve().parent.parent.parent +SRC_PNG = REPO_ROOT / "src" / "aydin" / "gui" / "resources" / "aydin_icon.png" +OUT_DIR = REPO_ROOT / "packaging" / "icons" + +# ICO sizes (Windows supports up to 256x256) +ICO_SIZES = [16, 32, 48, 64, 128, 256] + +# ICNS sizes required by iconutil +ICNS_SIZES = { + "icon_16x16.png": 16, + "icon_16x16@2x.png": 32, + "icon_32x32.png": 32, + "icon_32x32@2x.png": 64, + "icon_128x128.png": 128, + "icon_128x128@2x.png": 256, + "icon_256x256.png": 256, +} + + +def generate_ico(src: Path, out_dir: Path) -> None: + """Generate multi-size .ico file for Windows.""" + img = Image.open(src).convert("RGBA") + sizes = [(s, s) for s in ICO_SIZES] + out_path = out_dir / "aydin_icon.ico" + img.save(str(out_path), format="ICO", sizes=sizes) + print(f" Generated {out_path} ({len(sizes)} sizes)") + + +def generate_icns_iconutil(src: Path, out_dir: Path) -> bool: + """Generate .icns using macOS iconutil (best quality).""" + if not shutil.which("iconutil"): + return False + + img = Image.open(src).convert("RGBA") + with tempfile.TemporaryDirectory() as tmpdir: + iconset = Path(tmpdir) / "aydin.iconset" + iconset.mkdir() + + for name, size in ICNS_SIZES.items(): + resized = img.resize((size, size), Image.LANCZOS) + resized.save(str(iconset / name), format="PNG") + + out_path = out_dir / "aydin_icon.icns" + result = subprocess.run( + ["iconutil", "-c", "icns", str(iconset), "-o", str(out_path)], + capture_output=True, + text=True, + ) + if result.returncode == 0: + print(f" Generated {out_path} (via iconutil)") + return True + print(f" Warning: iconutil failed: {result.stderr.strip()}") + return False + + +def generate_icns_pillow(src: Path, out_dir: Path) -> None: + """Generate .icns using Pillow (fallback for non-macOS). + + Pillow's ICNS writer saves the image at its current size. + We resize to 256x256 which is the standard macOS app icon size. + For full multi-resolution .icns, use iconutil on macOS instead. + """ + img = Image.open(src).convert("RGBA") + # Resize to 256x256 — the primary icon size for macOS + img = img.resize((256, 256), Image.LANCZOS) + out_path = out_dir / "aydin_icon.icns" + img.save(str(out_path), format="ICNS") + print(f" Generated {out_path} (via Pillow, 256x256 only)") + + +def copy_png(src: Path, out_dir: Path) -> None: + """Copy source PNG to icons directory.""" + out_path = out_dir / "aydin_icon.png" + shutil.copy2(str(src), str(out_path)) + print(f" Copied {out_path}") + + +def main(): + if not SRC_PNG.exists(): + print(f"Error: Source PNG not found at {SRC_PNG}") + sys.exit(1) + + OUT_DIR.mkdir(parents=True, exist_ok=True) + print(f"Generating icons from {SRC_PNG}") + + copy_png(SRC_PNG, OUT_DIR) + generate_ico(SRC_PNG, OUT_DIR) + + # Try iconutil first (macOS), fall back to Pillow + if not generate_icns_iconutil(SRC_PNG, OUT_DIR): + generate_icns_pillow(SRC_PNG, OUT_DIR) + + print("Done.") + + +if __name__ == "__main__": + main()