From 44aa2fa6beb45f0aff7c78f1fc99c43196efdee3 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Tue, 20 Oct 2026 20:21:03 +0000 Subject: [PATCH 1/6] Bundle native OpenCV and use upstream ARM64 Rust wheels --- .github/workflows/build-standalone-env.yml | 50 ++--- .github/workflows/build-win-arm64-wheels.yml | 185 +++---------------- .github/workflows/test-local-tooling.yml | 10 +- README.md | 32 +++- requirements-nvidia-arm64.txt | 10 + scripts/build_local.py | 52 +----- scripts/build_opencv_arm64.py | 71 +++++++ scripts/smoke_win_arm64.py | 27 +++ scripts/test_local_tooling.py | 82 +++++--- win-arm64-wheels.sha256 | 2 - 10 files changed, 254 insertions(+), 267 deletions(-) create mode 100644 scripts/build_opencv_arm64.py create mode 100644 scripts/smoke_win_arm64.py delete mode 100644 win-arm64-wheels.sha256 diff --git a/.github/workflows/build-standalone-env.yml b/.github/workflows/build-standalone-env.yml index 98b122b..9094638 100644 --- a/.github/workflows/build-standalone-env.yml +++ b/.github/workflows/build-standalone-env.yml @@ -23,7 +23,13 @@ permissions: contents: write jobs: + native-wheels: + uses: ./.github/workflows/build-win-arm64-wheels.yml + permissions: + contents: read + build: + needs: native-wheels strategy: fail-fast: false matrix: @@ -213,6 +219,13 @@ jobs: echo "=== requirements_final.txt ===" cat requirements_final.txt + - name: Download verified OpenCV wheel + if: matrix.python_platform == 'aarch64-pc-windows-msvc' + uses: actions/download-artifact@v7 + with: + name: opencv-python-win-arm64 + path: win-arm64-wheels + - name: Install dependencies into standalone Python shell: bash run: | @@ -226,30 +239,15 @@ jobs: "$PYTHON" -m ensurepip --upgrade 2>/dev/null || true "$PYTHON" -m pip install --upgrade pip - # ComfyUI requires these native Rust extensions, but neither package - # publishes a Windows ARM64 wheel on PyPI yet. Install the verified - # wheels from win-arm64-wheels-v1 first so the later unpinned - # requirements resolve to these already-installed versions. + # The wheel was built and smoke-tested in this workflow run. + # Resolve it with the full requirements so NumPy/torch constraints + # are respected, and never compile the Rust extensions from source. if [[ "${{ runner.os }}" == "Windows" && "${{ runner.arch }}" == "ARM64" ]]; then - WHEEL_RELEASE="https://github.com/Comfy-Org/ComfyUI-Standalone-Environments/releases/download/win-arm64-wheels-v1" - mkdir win-arm64-wheels - # actions/checkout may convert text files to CRLF on Windows. - # Normalize once so the carriage return cannot enter a URL or - # checksum filename. - tr -d '\r' < win-arm64-wheels.sha256 > win-arm64-wheels/checksums.sha256 - while read -r _ filename; do - curl -fL --retry 3 \ - -o "win-arm64-wheels/$filename" \ - "$WHEEL_RELEASE/$filename" - done < win-arm64-wheels/checksums.sha256 - (cd win-arm64-wheels && sha256sum -c checksums.sha256) - "$PYTHON" -m pip install --no-cache-dir \ - win-arm64-wheels/blake3-1.0.9-cp313-cp313-win_arm64.whl \ - win-arm64-wheels/kornia_rs-0.1.14-cp313-cp313-win_arm64.whl + "$PYTHON" -m pip install --no-cache-dir -r requirements_final.txt pygit2 win-arm64-wheels/*.whl + else + "$PYTHON" -m pip install --no-cache-dir -r requirements_final.txt pygit2 fi - "$PYTHON" -m pip install --no-cache-dir -r requirements_final.txt pygit2 - - name: Bundle uv shell: bash run: | @@ -356,15 +354,7 @@ jobs: " if [[ "${{ runner.os }}" == "Windows" && "${{ runner.arch }}" == "ARM64" ]]; then - echo "=== Verifying hosted Windows ARM64 wheels ===" - "$PYTHON" -c " - import importlib.metadata - import blake3 - import kornia_rs - assert blake3.__version__ == '1.0.9', blake3.__version__ - assert importlib.metadata.version('kornia-rs') == '0.1.14' - print('blake3 and kornia-rs ARM64 imports OK') - " + "$PYTHON" scripts/smoke_win_arm64.py fi - name: Generate build manifest diff --git a/.github/workflows/build-win-arm64-wheels.yml b/.github/workflows/build-win-arm64-wheels.yml index 12c6076..3ed5c29 100644 --- a/.github/workflows/build-win-arm64-wheels.yml +++ b/.github/workflows/build-win-arm64-wheels.yml @@ -1,186 +1,47 @@ -name: Build win-arm64 wheels - -# Native Windows ARM64 wheels for the two ComfyUI dependencies that publish -# none on PyPI yet. Built from the upstream release tags on GitHub's -# windows-11-arm runner and attached to a GitHub release, so the -# win-nvidia-arm64 bundle can pip-install them by URL before -# `-r ComfyUI/requirements.txt`. -# -# Drop each package once upstream ships win_arm64 wheels on PyPI: -# blake3 - open PR: https://github.com/oconnor663/blake3-py/pull/112 -# kornia-rs - release CI merged in kornia/kornia-rs#1093; lands with the -# first release after v0.1.15-rc.5 -# -# Pull requests that touch this file build the wheels and upload them as -# artifacts only. The release is created on workflow_dispatch. +name: Build Windows ARM64 OpenCV +# Build public sources; NVIDIA's evaluation archive is not redistributable. +# Rust dependencies now have native PyPI wheels. Historical v1 assets remain +# available for older environment definitions that still reference them. on: + workflow_call: workflow_dispatch: - inputs: - tag: - description: Release tag to attach the wheels to - required: true pull_request: paths: - .github/workflows/build-win-arm64-wheels.yml + - .github/workflows/build-standalone-env.yml + - requirements-nvidia-arm64.txt + - scripts/build_opencv_arm64.py + - scripts/smoke_win_arm64.py permissions: contents: read -env: - # Must match the standalone bundle's interpreter (cp313 wheels). - PYTHON_VERSION: "3.13" - # Upstream release tags. Bump together with ComfyUI/requirements.txt. - BLAKE3_TAG: "1.0.9" - KORNIA_RS_TAG: "v0.1.14" - jobs: - blake3: - name: blake3 win_arm64 - runs-on: windows-11-arm - steps: - - uses: actions/checkout@v7 - with: - repository: oconnor663/blake3-py - ref: ${{ env.BLAKE3_TAG }} - persist-credentials: false - - - uses: actions/setup-python@v6 - with: - python-version: ${{ env.PYTHON_VERSION }} - architecture: arm64 - - # Same recipe as upstream's Windows ARM64 job (blake3-py#112). - - uses: PyO3/maturin-action@v1 - with: - command: build - target: aarch64 - args: --release --out dist --interpreter ${{ env.PYTHON_VERSION }} - rust-toolchain: stable - - - name: Verify native ARM64 wheel - shell: pwsh - run: | - $wheel = Get-ChildItem dist\*-win_arm64.whl | Select-Object -First 1 - if (-not $wheel) { throw "No win_arm64 wheel was produced" } - Write-Host "Built $($wheel.Name)" - python -m pip install --force-reinstall $wheel.FullName - python -c "import blake3, platform; assert platform.machine().upper() == 'ARM64', platform.machine(); assert blake3.__version__ == '${{ env.BLAKE3_TAG }}', blake3.__version__; assert len(blake3.blake3(b'abc').hexdigest()) == 64; print('blake3', blake3.__version__, 'OK')" - - - uses: actions/upload-artifact@v7 - with: - name: blake3-win_arm64 - path: dist/*-win_arm64.whl - if-no-files-found: error - - kornia-rs: - name: kornia-rs win_arm64 + opencv: runs-on: windows-11-arm - env: - CMAKE_GENERATOR: Ninja + timeout-minutes: 60 steps: - uses: actions/checkout@v7 with: - repository: kornia/kornia-rs - ref: ${{ env.KORNIA_RS_TAG }} persist-credentials: false - - uses: actions/setup-python@v6 with: - python-version: ${{ env.PYTHON_VERSION }} + python-version: "3.13" architecture: arm64 - - # Same recipe as upstream's windows-arm64 release job (kornia-rs#1093). - - uses: dtolnay/rust-toolchain@stable - with: - targets: aarch64-pc-windows-msvc - - - uses: ilammy/msvc-dev-cmd@v1 - with: - arch: arm64 - - - name: Install native build tools - run: python -m pip install --upgrade cmake ninja - - # v0.1.14's Cargo.lock pins generator 0.8.8 (via loom, kiddo), which has - # no aarch64-windows backend and fails to compile. 0.8.9 (2026-06-01) - # added it and is what upstream main locks. Patch-level bump of one - # transitive crate; the kornia-rs sources stay at the release tag. - - name: Bump generator crate for Windows ARM64 support - run: cargo update -p generator --precise 0.8.9 - - - uses: PyO3/maturin-action@v1 - with: - command: build - maturin-version: latest - args: --release --target aarch64-pc-windows-msvc --out dist -i python${{ env.PYTHON_VERSION }} -m kornia-py/Cargo.toml - - - name: Verify native ARM64 wheel + - name: Build OpenCV from pinned public sources + run: python scripts/build_opencv_arm64.py --work-dir build/opencv + - name: Install native wheels into a clean test environment shell: pwsh run: | - $wheel = Get-ChildItem dist\*-win_arm64.whl | Select-Object -First 1 - if (-not $wheel) { throw "No win_arm64 wheel was produced" } - Write-Host "Built $($wheel.Name)" - python -m pip install --force-reinstall $wheel.FullName - python -c "import kornia_rs, platform, importlib.metadata as m; version = m.version('kornia-rs'); assert platform.machine().upper() == 'ARM64', platform.machine(); assert version == '${{ env.KORNIA_RS_TAG }}'.removeprefix('v'), version; print('kornia_rs', version, 'OK')" - + python -m venv build/smoke + $wheel = Get-ChildItem build/opencv/wheels/*.whl + & build/smoke/Scripts/python.exe -m pip install --only-binary=:all: $wheel.FullName blake3==1.0.11 kornia-rs==0.1.15 + if ($LASTEXITCODE -ne 0) { throw "Native dependency install failed" } + - name: Verify ARM64 imports, hashing and image round-trip + run: build/smoke/Scripts/python.exe scripts/smoke_win_arm64.py - uses: actions/upload-artifact@v7 with: - name: kornia-rs-win_arm64 - path: dist/*-win_arm64.whl + name: opencv-python-win-arm64 + path: build/opencv/wheels/*.whl if-no-files-found: error - - release: - name: Attach wheels to release - if: github.event_name == 'workflow_dispatch' - needs: [blake3, kornia-rs] - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Refuse to overwrite a published release - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ inputs.tag }} - shell: bash - run: | - set -euo pipefail - release_state="$( - gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/releases?per_page=100" | - jq -r --arg tag "$RELEASE_TAG" ' - [.[][] | select(.tag_name == $tag)] | - if length == 0 then "missing" - elif any(.draft == false) then "published" - else "draft" - end - ' - )" - if [[ "$release_state" == "published" ]]; then - echo "::error::Release ${RELEASE_TAG} is already published; use a new tag" - exit 1 - fi - echo "Release ${RELEASE_TAG} is ${release_state}; continuing" - - - uses: actions/download-artifact@v7 - with: - path: wheels - merge-multiple: true - - - name: List wheels - run: ls -l wheels - - # Draft so the asset names can be checked before the URLs go live; - # never "latest", which belongs to the bundle releases. - - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ inputs.tag }} - name: Windows ARM64 wheels (${{ inputs.tag }}) - body: | - Native `win_arm64` wheels for ComfyUI dependencies that publish none on PyPI yet. - Built by `.github/workflows/build-win-arm64-wheels.yml` from upstream release tags: - blake3 `${{ env.BLAKE3_TAG }}`, kornia-rs `${{ env.KORNIA_RS_TAG }}`. - Consumed by the `win-nvidia-arm64` standalone bundle build. - files: wheels/*.whl - draft: true - make_latest: false - fail_on_unmatched_files: true diff --git a/.github/workflows/test-local-tooling.yml b/.github/workflows/test-local-tooling.yml index e68168d..7ffa65d 100644 --- a/.github/workflows/test-local-tooling.yml +++ b/.github/workflows/test-local-tooling.yml @@ -14,19 +14,23 @@ on: - scripts/test_local_tooling.py # Parity tests compare build_local.py constants to the CI matrix. - .github/workflows/build-standalone-env.yml + - .github/workflows/build-win-arm64-wheels.yml - .github/workflows/test-local-tooling.yml - # The Windows ARM64 tests read these data files directly. + # The Windows ARM64 tests exercise these dependency inputs. - requirements-nvidia-arm64.txt - - win-arm64-wheels.sha256 + - scripts/build_opencv_arm64.py + - scripts/smoke_win_arm64.py pull_request: paths: - scripts/build_local.py - scripts/serve_local.py - scripts/test_local_tooling.py - .github/workflows/build-standalone-env.yml + - .github/workflows/build-win-arm64-wheels.yml - .github/workflows/test-local-tooling.yml - requirements-nvidia-arm64.txt - - win-arm64-wheels.sha256 + - scripts/build_opencv_arm64.py + - scripts/smoke_win_arm64.py permissions: contents: read diff --git a/README.md b/README.md index 8daba8a..ebc94a0 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ workflow for one variant on your machine, and `scripts/serve_local.py` serves the result with the same URL layout as R2 so a dev desktop can ingest it. Nothing is uploaded anywhere. -Prerequisites: Python 3, `git`, 7-Zip (`7z` on PATH, or the default +Prerequisites: Python 3.11+, `git`, 7-Zip (`7z` on PATH, or the default Windows install location), network access, and tens of GB of free disk. Only variants matching the host OS can be built (pip resolves wheels for the running platform) - for other platforms, run the workflow via @@ -73,3 +73,33 @@ shadow a real release tag. Keep `build_local.py`'s constants in sync with the workflow matrix when it changes; `scripts/test_local_tooling.py` covers the metadata generation (`python -m unittest scripts.test_local_tooling`). + + +## Windows ARM64 native dependencies + +The `beta-win-nvidia-arm64` environment includes OpenCV so custom nodes can +import `cv2` without compiling it locally. The environment workflow builds +`opencv-python` from the pinned public commit in `scripts/build_opencv_arm64.py`, +then consumes that run's wheel artifact. It does not redistribute NVIDIA's +internal evaluation archive or rely on an unpublished release URL. + +The reusable `Build Windows ARM64 OpenCV` workflow also runs on relevant PRs. +It installs the wheel alongside the upstream PyPI ARM64 wheels for `blake3` +and `kornia-rs`, then checks native imports, a known BLAKE3 digest, color +conversion, and a lossless PNG encode/decode round-trip on Python 3.13. +The completed standalone bundle runs the same checks after stripping. +Older releases can still download the historical `win-arm64-wheels-v1` assets; +new builds no longer use those custom Rust wheels. + +Local ARM64 builds use the same OpenCV build script and require native ARM64 +Python 3.11 or later, git, and Visual Studio 2022 C++ ARM64 build tools. Build +dependencies are installed into a separate temporary venv. A fresh OpenCV +source build adds build time; the resulting desktop environment contains the +installed wheel, not the compiler or the build venv. + +This is the main, GUI-enabled OpenCV package. It has no `opencv_contrib` +modules and no FFmpeg video backend on Windows ARM64. Image round-trip checks +do not establish MP4/custom-node workflow compatibility; RTX Spark workflow +QA is still required before an environment release. Native wheel CI publishes +only a GitHub Actions artifact; publishing environments remains the existing +separate release workflow. diff --git a/requirements-nvidia-arm64.txt b/requirements-nvidia-arm64.txt index ff3c3ff..d2dd20e 100644 --- a/requirements-nvidia-arm64.txt +++ b/requirements-nvidia-arm64.txt @@ -9,3 +9,13 @@ torchaudio==2.11.0.dev20260902+cu134 # Newer cryptography releases currently omit win_arm64 wheels. This is the # newest public abi3 wheel compatible with Python 3.13 on Windows ARM64. cryptography==46.0.3 + +# Upstream now publishes native CPython 3.13 ARM64 wheels for both packages. +# Keep them binary-only so a missing wheel fails instead of needing Rust/MSVC. +--only-binary=blake3,kornia-rs +blake3==1.0.11 +kornia-rs==0.1.15 + +# OpenCV is supplied as a wheel built from pinned public sources by +# scripts/build_opencv_arm64.py (no public win_arm64 wheel on PyPI yet). +opencv-python==4.14.0+c9cce3d diff --git a/scripts/build_local.py b/scripts/build_local.py index d9431f6..3e1abca 100644 --- a/scripts/build_local.py +++ b/scripts/build_local.py @@ -26,7 +26,6 @@ """ import argparse -import hashlib import json import platform import re @@ -37,6 +36,8 @@ from datetime import datetime, timezone from pathlib import Path +from build_opencv_arm64 import build as build_opencv_wheel + REPO_ROOT = Path(__file__).resolve().parent.parent # Mirror the workflow's top-level env + matrix. Update together. @@ -82,16 +83,6 @@ "aarch64-apple-darwin": {"arm64"}, } -WINDOWS_ARM64_WHEEL_RELEASE = ( - "https://github.com/Comfy-Org/ComfyUI-Standalone-Environments/" - "releases/download/win-arm64-wheels-v1" -) -WINDOWS_ARM64_WHEELS = ( - "blake3-1.0.9-cp313-cp313-win_arm64.whl", - "kornia_rs-0.1.14-cp313-cp313-win_arm64.whl", -) -WINDOWS_ARM64_WHEEL_CHECKSUMS = REPO_ROOT / "win-arm64-wheels.sha256" - # Keep in sync with SAFE_SEGMENT in serve_local.py / r2Catalog.ts: the tag # becomes a path segment in the served URL and the archive filename. SAFE_SEGMENT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") @@ -169,26 +160,6 @@ def download(url, dest): shutil.copyfileobj(resp, fh) -def load_sha256_checksums(path): - checksums = {} - for line in path.read_text(encoding="utf-8").splitlines(): - digest, filename = line.split(maxsplit=1) - checksums[filename] = digest - return checksums - - -def verify_sha256(path, expected): - digest = hashlib.sha256() - with path.open("rb") as fh: - for chunk in iter(lambda: fh.read(1024 * 1024), b""): - digest.update(chunk) - actual = digest.hexdigest() - if actual != expected: - raise SystemExit( - f"SHA-256 mismatch for {path.name}: expected {expected}, got {actual}" - ) - - def resolve_latest_comfyui_ref(): url = "https://api.github.com/repos/Comfy-Org/ComfyUI/releases/latest" with urllib.request.urlopen(url) as resp: @@ -236,18 +207,11 @@ def install_requirements(work, variant, vendor_req_name): subprocess.run([str(python), "-m", "ensurepip", "--upgrade"], check=False, capture_output=True) run([str(python), "-m", "pip", "install", "--upgrade", "pip"], cwd=work) + wheels = [] if PYTHON_PLATFORMS[variant] == "aarch64-pc-windows-msvc": - checksums = load_sha256_checksums(WINDOWS_ARM64_WHEEL_CHECKSUMS) - wheel_paths = [] - for name in WINDOWS_ARM64_WHEELS: - wheel = work / name - download(f"{WINDOWS_ARM64_WHEEL_RELEASE}/{name}", wheel) - verify_sha256(wheel, checksums[name]) - wheel_paths.append(str(wheel)) - run([str(python), "-m", "pip", "install", "--no-cache-dir", - *wheel_paths], cwd=work) + wheels.append(str(build_opencv_wheel(work / "opencv-build"))) run([str(python), "-m", "pip", "install", "--no-cache-dir", - "-r", "requirements_final.txt", "pygit2"], cwd=work) + "-r", "requirements_final.txt", "pygit2", *wheels], cwd=work) def bundle_uv(work, variant): @@ -316,11 +280,7 @@ def smoke_test(work, variant): "import pygit2; print('pygit2', pygit2.__version__);" "print('All core imports OK')"], cwd=work) if PYTHON_PLATFORMS[variant] == "aarch64-pc-windows-msvc": - run([str(python), "-c", - "import importlib.metadata, blake3, kornia_rs;" - "assert blake3.__version__ == '1.0.9';" - "assert importlib.metadata.version('kornia-rs') == '0.1.14';" - "print('blake3 and kornia-rs ARM64 imports OK')"], cwd=work) + run([str(python), str(REPO_ROOT / "scripts" / "smoke_win_arm64.py")], cwd=work) def introspect_versions(work, uv_bin): diff --git a/scripts/build_opencv_arm64.py b/scripts/build_opencv_arm64.py new file mode 100644 index 0000000..c65d135 --- /dev/null +++ b/scripts/build_opencv_arm64.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Build OpenCV from pinned public sources on native Windows ARM64. + +Requires an ARM64 CPython >= 3.11, git, and Visual Studio 2022 C++ ARM64 +build tools. Build dependencies live in a separate venv, not the bundle. +The returned wheel includes upstream's license and third-party notices. +""" + +import argparse +import os +import platform +import subprocess +import sys +import tomllib +from pathlib import Path + +# Same public source revision used for NVIDIA's WoA evaluation build. +OPENCV_REF = "c9cce3de2eae2039ebb0c22d13c9ed85a40e338f" + + +def find_wheel(directory): + wheels = list(directory.glob("*.whl")) + if len(wheels) != 1 or not wheels[0].name.endswith("-cp37-abi3-win_arm64.whl"): + raise RuntimeError(f"Expected one cp37-abi3-win_arm64 wheel, found {wheels}") + if not wheels[0].name.startswith("opencv_python-"): + raise RuntimeError(f"Expected the main OpenCV package, found {wheels[0].name}") + return wheels[0] + + +def build(work): + if sys.platform != "win32" or platform.machine().upper() != "ARM64": + raise SystemExit("OpenCV must be built with native Windows ARM64 Python") + work = work.resolve() + work.mkdir(parents=True, exist_ok=False) + source = work / "source" + output = work / "wheels" + output.mkdir() + + def run(*args, **kwargs): + subprocess.run([str(arg) for arg in args], check=True, **kwargs) + + # Fetch a commit, not a moving branch or the internal NVIDIA binary. + run("git", "init", source) + run("git", "-C", source, "remote", "add", "origin", + "https://github.com/opencv/opencv-python.git") + run("git", "-C", source, "fetch", "--depth=1", "origin", OPENCV_REF) + run("git", "-C", source, "checkout", "--detach", "FETCH_HEAD") + run("git", "-C", source, "submodule", "update", "--init", "--depth=1", "opencv") + run(sys.executable, "-m", "venv", work / "venv") + python = work / "venv" / "Scripts" / "python.exe" + requirements = tomllib.loads((source / "pyproject.toml").read_text())["build-system"]["requires"] + run(python, "-m", "pip", "install", *requirements, "cmake==4.1.0", "wheel==0.45.1") + env = { + **os.environ, + "CI_BUILD": "1", + "ENABLE_CONTRIB": "0", + "ENABLE_HEADLESS": "0", + "ENABLE_ROLLING": "0", + "CMAKE_BUILD_PARALLEL_LEVEL": "4", + } + run(python, "setup.py", "bdist_wheel", "--py-limited-api=cp37", + "--dist-dir", output, cwd=source, env=env) + wheel = find_wheel(output) + print(f"Built {wheel.name} from {OPENCV_REF}", flush=True) + return wheel + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--work-dir", type=Path, required=True) + build(parser.parse_args().work_dir) diff --git a/scripts/smoke_win_arm64.py b/scripts/smoke_win_arm64.py new file mode 100644 index 0000000..33f9d44 --- /dev/null +++ b/scripts/smoke_win_arm64.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Exercise native dependencies on the bundle interpreter, including cv2 I/O.""" + +import importlib.metadata +import platform + +import blake3 +import cv2 +import kornia_rs # noqa: F401 - loading the native extension is the check +import numpy as np + +assert platform.machine().upper() == "ARM64", platform.machine() +assert importlib.metadata.version("blake3") == "1.0.11" +assert importlib.metadata.version("kornia-rs") == "0.1.15" +assert blake3.blake3(b"abc").hexdigest() == ( + "6437b3ac38465133ffb63b75273a8db548c558465d79db03fd359c6cd5bd9d85" +) +assert cv2.__version__.startswith("4.14.0"), cv2.__version__ +image = np.zeros((16, 16, 3), dtype=np.uint8) +image[4:12, 4:12] = (0, 128, 255) +gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) +assert gray.shape == (16, 16) and gray[8, 8] > gray[0, 0] +ok, encoded = cv2.imencode(".png", image) +assert ok +np.testing.assert_array_equal(cv2.imdecode(encoded, cv2.IMREAD_COLOR), image) +print(cv2.getBuildInformation()) +print("OpenCV PNG round-trip, blake3 hashing, and kornia-rs ARM64 imports OK") diff --git a/scripts/test_local_tooling.py b/scripts/test_local_tooling.py index 3dac8e5..4d66f78 100644 --- a/scripts/test_local_tooling.py +++ b/scripts/test_local_tooling.py @@ -11,11 +11,13 @@ import sys import tempfile import unittest +from unittest.mock import patch from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import build_local # noqa: E402 +import build_opencv_arm64 # noqa: E402 import serve_local # noqa: E402 @@ -107,27 +109,64 @@ def test_cryptography_uses_last_public_windows_arm64_wheel(self): self.assertIn("cryptography==46.0.3", self.text) -class WindowsArm64WheelChecksums(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.path = (Path(__file__).resolve().parent.parent - / "win-arm64-wheels.sha256") - cls.checksums = build_local.load_sha256_checksums(cls.path) - - def test_checksum_manifest_covers_exactly_the_hosted_wheels(self): - self.assertEqual(set(self.checksums), - set(build_local.WINDOWS_ARM64_WHEELS)) - - def test_checksums_are_sha256_digests(self): - for digest in self.checksums.values(): - self.assertRegex(digest, r"^[0-9a-f]{64}$") - - def test_hash_mismatch_is_fatal(self): +class WindowsArm64NativeDependencies(unittest.TestCase): + def test_install_resolves_opencv_with_full_requirements(self): with tempfile.TemporaryDirectory() as tmp: - wheel = Path(tmp) / "wheel.whl" - wheel.write_bytes(b"tampered") - with self.assertRaises(SystemExit): - build_local.verify_sha256(wheel, "0" * 64) + work = Path(tmp) + wheel = work / "opencv_python-4.14.0+c9cce3d-cp37-abi3-win_arm64.whl" + with patch.object(build_local, "HOST_OS", "win"), \ + patch.object(build_local.subprocess, "run"), \ + patch.object(build_local, "run") as run, \ + patch.object(build_local, "build_opencv_wheel", return_value=wheel) as build: + build_local.install_requirements( + work, "beta-win-nvidia-arm64", "requirements-nvidia-arm64.txt") + build.assert_called_once_with(work / "opencv-build") + install = run.call_args_list[-1].args[0] + self.assertIn(str(wheel), install) + self.assertIn("requirements_final.txt", install) + requirements = (work / "requirements_final.txt").read_text() + self.assertIn("--only-binary=blake3,kornia-rs", requirements) + self.assertIn("blake3==1.0.11", requirements) + self.assertIn("kornia-rs==0.1.15", requirements) + self.assertIn("-r ComfyUI/requirements.txt", requirements) + self.assertIn("-r ComfyUI/manager_requirements.txt", requirements) + + def test_x64_install_does_not_build_or_install_arm64_wheels(self): + with tempfile.TemporaryDirectory() as tmp: + with patch.object(build_local, "HOST_OS", "win"), \ + patch.object(build_local.subprocess, "run"), \ + patch.object(build_local, "run") as run, \ + patch.object(build_local, "build_opencv_wheel") as build: + build_local.install_requirements(Path(tmp), "win-nvidia", "requirements-nvidia.txt") + build.assert_not_called() + self.assertFalse(any(str(arg).endswith(".whl") for arg in run.call_args.args[0])) + + def test_wheel_selection_rejects_foreign_architecture_and_ambiguous_output(self): + with tempfile.TemporaryDirectory() as tmp: + directory = Path(tmp) + for name in ["opencv_python-4.14.0-cp37-abi3-win_amd64.whl", + "opencv_python_headless-4.14.0-cp37-abi3-win_arm64.whl"]: + wheel = directory / name + wheel.touch() + with self.assertRaises(RuntimeError): + build_opencv_arm64.find_wheel(directory) + wheel.unlink() + wheel = directory / "opencv_python-4.14.0+c9cce3d-cp37-abi3-win_arm64.whl" + wheel.touch() + self.assertEqual(build_opencv_arm64.find_wheel(directory), wheel) + (directory / "extra.whl").touch() + with self.assertRaises(RuntimeError): + build_opencv_arm64.find_wheel(directory) + + def test_foreign_host_is_rejected_before_running_tools(self): + with tempfile.TemporaryDirectory() as tmp: + work = Path(tmp) / "build" + with patch.object(build_opencv_arm64.sys, "platform", "linux"), \ + patch.object(build_opencv_arm64.subprocess, "run") as run: + with self.assertRaises(SystemExit): + build_opencv_arm64.build(work) + run.assert_not_called() + self.assertFalse(work.exists()) class WorkflowParity(unittest.TestCase): @@ -178,9 +217,6 @@ def test_python_version_overrides_match_matrix(self): def test_7z_parameters_match(self): self.assertIn(" ".join(build_local.SEVENZ_ARGS), self.text) - def test_arm64_wheel_checksum_manifest_is_verified(self): - self.assertIn("sha256sum -c checksums.sha256", self.text) - self.assertIn("tr -d '\\r' < win-arm64-wheels.sha256", self.text) class VariantOs(unittest.TestCase): diff --git a/win-arm64-wheels.sha256 b/win-arm64-wheels.sha256 deleted file mode 100644 index 9e61fba..0000000 --- a/win-arm64-wheels.sha256 +++ /dev/null @@ -1,2 +0,0 @@ -b3033cd9529212cd2aa9368594c0e4a0b049f0b31ec482f7c8941a53ab06185f blake3-1.0.9-cp313-cp313-win_arm64.whl -66f61fac8406ea819760d78ac840b2cd4c3cc95b42a6cd946ae848bf6c60bfed kornia_rs-0.1.14-cp313-cp313-win_arm64.whl From 2a95a1e18f946ba1b89ecd8586163eca591f012e Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Tue, 20 Oct 2026 20:24:15 +0000 Subject: [PATCH 2/6] Use current ARM64 MSVC generator and uv-compatible binary options --- README.md | 3 ++- requirements-nvidia-arm64.txt | 3 ++- scripts/build_opencv_arm64.py | 13 +++++++++---- scripts/test_local_tooling.py | 3 ++- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ebc94a0..51f0b8b 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,8 @@ Older releases can still download the historical `win-arm64-wheels-v1` assets; new builds no longer use those custom Rust wheels. Local ARM64 builds use the same OpenCV build script and require native ARM64 -Python 3.11 or later, git, and Visual Studio 2022 C++ ARM64 build tools. Build +Python 3.11 or later, git, and Visual Studio 2026 C++ ARM64 build tools (or +`CMAKE_GENERATOR` set to the installed Visual Studio version). Build dependencies are installed into a separate temporary venv. A fresh OpenCV source build adds build time; the resulting desktop environment contains the installed wheel, not the compiler or the build venv. diff --git a/requirements-nvidia-arm64.txt b/requirements-nvidia-arm64.txt index d2dd20e..e825714 100644 --- a/requirements-nvidia-arm64.txt +++ b/requirements-nvidia-arm64.txt @@ -12,7 +12,8 @@ cryptography==46.0.3 # Upstream now publishes native CPython 3.13 ARM64 wheels for both packages. # Keep them binary-only so a missing wheel fails instead of needing Rust/MSVC. ---only-binary=blake3,kornia-rs +--only-binary=blake3 +--only-binary=kornia-rs blake3==1.0.11 kornia-rs==0.1.15 diff --git a/scripts/build_opencv_arm64.py b/scripts/build_opencv_arm64.py index c65d135..45e878d 100644 --- a/scripts/build_opencv_arm64.py +++ b/scripts/build_opencv_arm64.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 """Build OpenCV from pinned public sources on native Windows ARM64. -Requires an ARM64 CPython >= 3.11, git, and Visual Studio 2022 C++ ARM64 -build tools. Build dependencies live in a separate venv, not the bundle. +Requires an ARM64 CPython >= 3.11, git, and Visual Studio 2026 C++ ARM64 +build tools (or set CMAKE_GENERATOR for another installed VS version). +Build dependencies live in a separate venv, not the bundle. The returned wheel includes upstream's license and third-party notices. """ @@ -49,7 +50,8 @@ def run(*args, **kwargs): run(sys.executable, "-m", "venv", work / "venv") python = work / "venv" / "Scripts" / "python.exe" requirements = tomllib.loads((source / "pyproject.toml").read_text())["build-system"]["requires"] - run(python, "-m", "pip", "install", *requirements, "cmake==4.1.0", "wheel==0.45.1") + run(python, "-m", "pip", "install", *requirements, + "cmake==4.4.3", "scikit-build==0.19.1", "wheel==0.45.1") env = { **os.environ, "CI_BUILD": "1", @@ -58,8 +60,11 @@ def run(*args, **kwargs): "ENABLE_ROLLING": "0", "CMAKE_BUILD_PARALLEL_LEVEL": "4", } + # Upstream hard-codes VS2022; the native hosted runner now uses VS2026. + # scikit-build gives these command-line CMake args precedence over setup.py. + generator = os.environ.get("CMAKE_GENERATOR", "Visual Studio 18 2026") run(python, "setup.py", "bdist_wheel", "--py-limited-api=cp37", - "--dist-dir", output, cwd=source, env=env) + "--dist-dir", output, "--", "-G", generator, cwd=source, env=env) wheel = find_wheel(output) print(f"Built {wheel.name} from {OPENCV_REF}", flush=True) return wheel diff --git a/scripts/test_local_tooling.py b/scripts/test_local_tooling.py index 4d66f78..fb5d0f0 100644 --- a/scripts/test_local_tooling.py +++ b/scripts/test_local_tooling.py @@ -125,7 +125,8 @@ def test_install_resolves_opencv_with_full_requirements(self): self.assertIn(str(wheel), install) self.assertIn("requirements_final.txt", install) requirements = (work / "requirements_final.txt").read_text() - self.assertIn("--only-binary=blake3,kornia-rs", requirements) + self.assertIn("--only-binary=blake3", requirements) + self.assertIn("--only-binary=kornia-rs", requirements) self.assertIn("blake3==1.0.11", requirements) self.assertIn("kornia-rs==0.1.15", requirements) self.assertIn("-r ComfyUI/requirements.txt", requirements) From ca85d06d699eb00d1ef3bfb2ec1039a18c505514 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Tue, 20 Oct 2026 20:29:31 +0000 Subject: [PATCH 3/6] Keep other platforms independent of the ARM64 wheel build --- .github/workflows/build-standalone-env.yml | 3 +++ README.md | 2 +- scripts/build_opencv_arm64.py | 9 ++++++++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-standalone-env.yml b/.github/workflows/build-standalone-env.yml index 9094638..8345c33 100644 --- a/.github/workflows/build-standalone-env.yml +++ b/.github/workflows/build-standalone-env.yml @@ -30,6 +30,9 @@ jobs: build: needs: native-wheels + # A failed ARM64 wheel must not skip the other platform builds. The ARM64 + # matrix entry will fail when its required artifact is unavailable. + if: ${{ !cancelled() }} strategy: fail-fast: false matrix: diff --git a/README.md b/README.md index 51f0b8b..a16fac6 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ workflow for one variant on your machine, and `scripts/serve_local.py` serves the result with the same URL layout as R2 so a dev desktop can ingest it. Nothing is uploaded anywhere. -Prerequisites: Python 3.11+, `git`, 7-Zip (`7z` on PATH, or the default +Prerequisites: Python 3, `git`, 7-Zip (`7z` on PATH, or the default Windows install location), network access, and tens of GB of free disk. Only variants matching the host OS can be built (pip resolves wheels for the running platform) - for other platforms, run the workflow via diff --git a/scripts/build_opencv_arm64.py b/scripts/build_opencv_arm64.py index 45e878d..1d883e0 100644 --- a/scripts/build_opencv_arm64.py +++ b/scripts/build_opencv_arm64.py @@ -12,7 +12,6 @@ import platform import subprocess import sys -import tomllib from pathlib import Path # Same public source revision used for NVIDIA's WoA evaluation build. @@ -31,6 +30,12 @@ def find_wheel(directory): def build(work): if sys.platform != "win32" or platform.machine().upper() != "ARM64": raise SystemExit("OpenCV must be built with native Windows ARM64 Python") + if sys.version_info < (3, 11): + raise SystemExit("Building OpenCV requires Python 3.11 or later") + # Keep importing the local tooling compatible with older Python versions + # on platforms that never build this wheel. + import tomllib + work = work.resolve() work.mkdir(parents=True, exist_ok=False) source = work / "source" @@ -46,6 +51,8 @@ def run(*args, **kwargs): "https://github.com/opencv/opencv-python.git") run("git", "-C", source, "fetch", "--depth=1", "origin", OPENCV_REF) run("git", "-C", source, "checkout", "--detach", "FETCH_HEAD") + # find_version.py uses this suffix for the version pinned in requirements. + run("git", "-C", source, "config", "core.abbrev", "7") run("git", "-C", source, "submodule", "update", "--init", "--depth=1", "opencv") run(sys.executable, "-m", "venv", work / "venv") python = work / "venv" / "Scripts" / "python.exe" From 0ee7b6066dc62b313d5df568ec314233dd3b82ab Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Tue, 20 Oct 2026 23:12:14 +0000 Subject: [PATCH 4/6] Remove README changes from ARM64 dependency update --- README.md | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/README.md b/README.md index a16fac6..8daba8a 100644 --- a/README.md +++ b/README.md @@ -73,34 +73,3 @@ shadow a real release tag. Keep `build_local.py`'s constants in sync with the workflow matrix when it changes; `scripts/test_local_tooling.py` covers the metadata generation (`python -m unittest scripts.test_local_tooling`). - - -## Windows ARM64 native dependencies - -The `beta-win-nvidia-arm64` environment includes OpenCV so custom nodes can -import `cv2` without compiling it locally. The environment workflow builds -`opencv-python` from the pinned public commit in `scripts/build_opencv_arm64.py`, -then consumes that run's wheel artifact. It does not redistribute NVIDIA's -internal evaluation archive or rely on an unpublished release URL. - -The reusable `Build Windows ARM64 OpenCV` workflow also runs on relevant PRs. -It installs the wheel alongside the upstream PyPI ARM64 wheels for `blake3` -and `kornia-rs`, then checks native imports, a known BLAKE3 digest, color -conversion, and a lossless PNG encode/decode round-trip on Python 3.13. -The completed standalone bundle runs the same checks after stripping. -Older releases can still download the historical `win-arm64-wheels-v1` assets; -new builds no longer use those custom Rust wheels. - -Local ARM64 builds use the same OpenCV build script and require native ARM64 -Python 3.11 or later, git, and Visual Studio 2026 C++ ARM64 build tools (or -`CMAKE_GENERATOR` set to the installed Visual Studio version). Build -dependencies are installed into a separate temporary venv. A fresh OpenCV -source build adds build time; the resulting desktop environment contains the -installed wheel, not the compiler or the build venv. - -This is the main, GUI-enabled OpenCV package. It has no `opencv_contrib` -modules and no FFmpeg video backend on Windows ARM64. Image round-trip checks -do not establish MP4/custom-node workflow compatibility; RTX Spark workflow -QA is still required before an environment release. Native wheel CI publishes -only a GitHub Actions artifact; publishing environments remains the existing -separate release workflow. From eb366690b4625e3e2d3767c068b649b686ef689f Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Tue, 20 Oct 2026 17:06:40 -0700 Subject: [PATCH 5/6] Cache the ARM64 OpenCV wheel and stop blocking other platforms on it --- .github/workflows/build-standalone-env.yml | 51 +++++++++++++------- .github/workflows/build-win-arm64-wheels.yml | 25 +++++++++- scripts/test_local_tooling.py | 44 +++++++++++++++++ 3 files changed, 102 insertions(+), 18 deletions(-) diff --git a/.github/workflows/build-standalone-env.yml b/.github/workflows/build-standalone-env.yml index 8345c33..50074d0 100644 --- a/.github/workflows/build-standalone-env.yml +++ b/.github/workflows/build-standalone-env.yml @@ -23,16 +23,7 @@ permissions: contents: write jobs: - native-wheels: - uses: ./.github/workflows/build-win-arm64-wheels.yml - permissions: - contents: read - build: - needs: native-wheels - # A failed ARM64 wheel must not skip the other platform builds. The ARM64 - # matrix entry will fail when its required artifact is unavailable. - if: ${{ !cancelled() }} strategy: fail-fast: false matrix: @@ -222,12 +213,38 @@ jobs: echo "=== requirements_final.txt ===" cat requirements_final.txt - - name: Download verified OpenCV wheel + # The OpenCV wheel is prepared inside this leg so the other platforms + # never wait for it. build-win-arm64-wheels.yml caches a smoke-tested + # wheel on main under this key; scripts/test_local_tooling.py keeps the + # two workflows' key and path identical. + - name: Restore smoke-tested OpenCV wheel + id: opencv-cache if: matrix.python_platform == 'aarch64-pc-windows-msvc' - uses: actions/download-artifact@v7 + uses: actions/cache/restore@v6 + with: + path: build/opencv/wheels + key: opencv-python-win-arm64-${{ hashFiles('scripts/build_opencv_arm64.py') }} + + # No entry yet for this recipe, or it was evicted: build the same pinned + # sources here rather than fail the release. The stripped-environment + # smoke test below checks the wheel either way. + - name: Set up Python for the OpenCV build + id: opencv-python + if: matrix.python_platform == 'aarch64-pc-windows-msvc' && steps.opencv-cache.outputs.cache-hit != 'true' + uses: actions/setup-python@v6 with: - name: opencv-python-win-arm64 - path: win-arm64-wheels + python-version: "3.13" + architecture: arm64 + # Leave PATH alone so later steps keep using the runner's Python. + update-environment: false + + - name: Build OpenCV from pinned public sources + if: matrix.python_platform == 'aarch64-pc-windows-msvc' && steps.opencv-cache.outputs.cache-hit != 'true' + shell: pwsh + env: + BUILD_PYTHON: ${{ steps.opencv-python.outputs.python-path }} + run: | + & $env:BUILD_PYTHON scripts/build_opencv_arm64.py --work-dir build/opencv - name: Install dependencies into standalone Python shell: bash @@ -242,11 +259,11 @@ jobs: "$PYTHON" -m ensurepip --upgrade 2>/dev/null || true "$PYTHON" -m pip install --upgrade pip - # The wheel was built and smoke-tested in this workflow run. - # Resolve it with the full requirements so NumPy/torch constraints - # are respected, and never compile the Rust extensions from source. + # Resolve the OpenCV wheel from the steps above with the full + # requirements so NumPy/torch constraints are respected, and never + # compile the Rust extensions from source. if [[ "${{ runner.os }}" == "Windows" && "${{ runner.arch }}" == "ARM64" ]]; then - "$PYTHON" -m pip install --no-cache-dir -r requirements_final.txt pygit2 win-arm64-wheels/*.whl + "$PYTHON" -m pip install --no-cache-dir -r requirements_final.txt pygit2 build/opencv/wheels/*.whl else "$PYTHON" -m pip install --no-cache-dir -r requirements_final.txt pygit2 fi diff --git a/.github/workflows/build-win-arm64-wheels.yml b/.github/workflows/build-win-arm64-wheels.yml index 3ed5c29..88034af 100644 --- a/.github/workflows/build-win-arm64-wheels.yml +++ b/.github/workflows/build-win-arm64-wheels.yml @@ -3,9 +3,22 @@ name: Build Windows ARM64 OpenCV # Build public sources; NVIDIA's evaluation archive is not redistributable. # Rust dependencies now have native PyPI wheels. Historical v1 assets remain # available for older environment definitions that still reference them. +# +# A wheel that passes the smoke test is cached under a key derived from the +# build script. Environment releases restore that entry from main, so they +# ship the same tested bytes instead of rebuilding OpenCV every time. on: - workflow_call: workflow_dispatch: + push: + branches: + - main + paths: + - .github/workflows/build-win-arm64-wheels.yml + - scripts/build_opencv_arm64.py + # GitHub evicts cache entries unused for 7 days, and a restore counts as + # use. Twice a week keeps main's entry alive between releases. + schedule: + - cron: "23 5 * * 1,4" pull_request: paths: - .github/workflows/build-win-arm64-wheels.yml @@ -29,7 +42,17 @@ jobs: with: python-version: "3.13" architecture: arm64 + # Saved in a post step that runs only when every step below succeeds, + # so a restored wheel has always passed the smoke test. + # build-standalone-env.yml restores the same key and path. + - name: Restore smoke-tested OpenCV wheel + id: cache + uses: actions/cache@v6 + with: + path: build/opencv/wheels + key: opencv-python-win-arm64-${{ hashFiles('scripts/build_opencv_arm64.py') }} - name: Build OpenCV from pinned public sources + if: steps.cache.outputs.cache-hit != 'true' run: python scripts/build_opencv_arm64.py --work-dir build/opencv - name: Install native wheels into a clean test environment shell: pwsh diff --git a/scripts/test_local_tooling.py b/scripts/test_local_tooling.py index fb5d0f0..3576ed8 100644 --- a/scripts/test_local_tooling.py +++ b/scripts/test_local_tooling.py @@ -108,6 +108,13 @@ def test_prereleases_are_not_enabled_for_all_comfyui_dependencies(self): def test_cryptography_uses_last_public_windows_arm64_wheel(self): self.assertIn("cryptography==46.0.3", self.text) + def test_opencv_pin_matches_the_pinned_source_commit(self): + # find_version.py appends the 7-character commit (core.abbrev 7), so + # bumping OPENCV_REF without this pin only fails at release install. + m = re.search(r"^opencv-python==[\d.]+\+([0-9a-f]+)$", self.text, re.M) + self.assertIsNotNone(m) + self.assertEqual(m.group(1), build_opencv_arm64.OPENCV_REF[:7]) + class WindowsArm64NativeDependencies(unittest.TestCase): def test_install_resolves_opencv_with_full_requirements(self): @@ -170,6 +177,43 @@ def test_foreign_host_is_rejected_before_running_tools(self): self.assertFalse(work.exists()) +class OpenCvWheelCache(unittest.TestCase): + """build-win-arm64-wheels.yml caches a smoke-tested wheel that + build-standalone-env.yml restores at release time. The main workflow + never runs on pull requests, so drift here would first fail a release.""" + + @classmethod + def setUpClass(cls): + workflows = Path(__file__).resolve().parent.parent / ".github" / "workflows" + cls.wheels = (workflows / "build-win-arm64-wheels.yml").read_text(encoding="utf-8") + cls.release = (workflows / "build-standalone-env.yml").read_text(encoding="utf-8") + + @staticmethod + def cache_entries(text): + return re.findall( + r"uses: actions/cache(?:/restore)?@\S+\n\s+with:\n\s+path: (\S+)\n\s+key: (.+)$", + text, re.M) + + def test_release_restores_the_entry_the_wheel_workflow_saves(self): + saved = self.cache_entries(self.wheels) + self.assertEqual(len(saved), 1) + self.assertEqual(self.cache_entries(self.release), saved) + + def test_key_follows_the_recipe_and_path_matches_the_build_output(self): + (path, key), = self.cache_entries(self.wheels) + self.assertIn("hashFiles('scripts/build_opencv_arm64.py')", key) + # build_opencv_arm64.build() writes its wheel to /wheels. + self.assertEqual(path, "build/opencv/wheels") + for text in (self.wheels, self.release): + self.assertIn("scripts/build_opencv_arm64.py --work-dir build/opencv", text) + self.assertIn("build/opencv/wheels/*.whl", self.release) + + def test_platform_builds_do_not_wait_for_another_job(self): + build_job = self.release[self.release.index("\n build:\n"): + self.release.index("\n publish-manifests:\n")] + self.assertNotRegex(build_job, r"(?m)^ needs:") + + class WorkflowParity(unittest.TestCase): """build_local.py mirrors the CI workflow's env + matrix; these fail when build-standalone-env.yml changes without updating the tooling.""" From d58c1ba7b1b50c05e518bc33a30c73a13b9634ad Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Tue, 20 Oct 2026 17:23:35 -0700 Subject: [PATCH 6/6] Build OpenCV contrib and resolve the other OpenCV names to it --- .github/workflows/build-win-arm64-wheels.yml | 5 +- requirements-nvidia-arm64.txt | 9 ++- scripts/build_local.py | 4 +- scripts/build_opencv_arm64.py | 72 ++++++++++++++++++-- scripts/smoke_win_arm64.py | 10 ++- scripts/test_local_tooling.py | 72 ++++++++++++++++---- 6 files changed, 145 insertions(+), 27 deletions(-) diff --git a/.github/workflows/build-win-arm64-wheels.yml b/.github/workflows/build-win-arm64-wheels.yml index 88034af..d30e516 100644 --- a/.github/workflows/build-win-arm64-wheels.yml +++ b/.github/workflows/build-win-arm64-wheels.yml @@ -58,8 +58,9 @@ jobs: shell: pwsh run: | python -m venv build/smoke - $wheel = Get-ChildItem build/opencv/wheels/*.whl - & build/smoke/Scripts/python.exe -m pip install --only-binary=:all: $wheel.FullName blake3==1.0.11 kornia-rs==0.1.15 + # The contrib build plus the empty wheels for the other OpenCV names. + $wheels = Get-ChildItem build/opencv/wheels/*.whl + & build/smoke/Scripts/python.exe -m pip install --only-binary=:all: $wheels.FullName blake3==1.0.11 kornia-rs==0.1.15 if ($LASTEXITCODE -ne 0) { throw "Native dependency install failed" } - name: Verify ARM64 imports, hashing and image round-trip run: build/smoke/Scripts/python.exe scripts/smoke_win_arm64.py diff --git a/requirements-nvidia-arm64.txt b/requirements-nvidia-arm64.txt index e825714..69a1e89 100644 --- a/requirements-nvidia-arm64.txt +++ b/requirements-nvidia-arm64.txt @@ -17,6 +17,11 @@ cryptography==46.0.3 blake3==1.0.11 kornia-rs==0.1.15 -# OpenCV is supplied as a wheel built from pinned public sources by -# scripts/build_opencv_arm64.py (no public win_arm64 wheel on PyPI yet). +# OpenCV is built from pinned public sources by scripts/build_opencv_arm64.py +# (no public win_arm64 wheel on PyPI yet). The contrib build provides cv2; the +# other three names are empty wheels that depend on it, so custom nodes that +# require any of them resolve to the same build. +opencv-contrib-python==4.14.0+c9cce3d opencv-python==4.14.0+c9cce3d +opencv-python-headless==4.14.0+c9cce3d +opencv-contrib-python-headless==4.14.0+c9cce3d diff --git a/scripts/build_local.py b/scripts/build_local.py index 3e1abca..1d0428b 100644 --- a/scripts/build_local.py +++ b/scripts/build_local.py @@ -36,7 +36,7 @@ from datetime import datetime, timezone from pathlib import Path -from build_opencv_arm64 import build as build_opencv_wheel +from build_opencv_arm64 import build as build_opencv_wheels REPO_ROOT = Path(__file__).resolve().parent.parent @@ -209,7 +209,7 @@ def install_requirements(work, variant, vendor_req_name): run([str(python), "-m", "pip", "install", "--upgrade", "pip"], cwd=work) wheels = [] if PYTHON_PLATFORMS[variant] == "aarch64-pc-windows-msvc": - wheels.append(str(build_opencv_wheel(work / "opencv-build"))) + wheels.extend(str(wheel) for wheel in build_opencv_wheels(work / "opencv-build")) run([str(python), "-m", "pip", "install", "--no-cache-dir", "-r", "requirements_final.txt", "pygit2", *wheels], cwd=work) diff --git a/scripts/build_opencv_arm64.py b/scripts/build_opencv_arm64.py index 1d883e0..8f3a971 100644 --- a/scripts/build_opencv_arm64.py +++ b/scripts/build_opencv_arm64.py @@ -4,30 +4,82 @@ Requires an ARM64 CPython >= 3.11, git, and Visual Studio 2026 C++ ARM64 build tools (or set CMAKE_GENERATOR for another installed VS version). Build dependencies live in a separate venv, not the bundle. -The returned wheel includes upstream's license and third-party notices. +The contrib wheel includes upstream's license and third-party notices. """ import argparse +import base64 +import hashlib import os import platform import subprocess import sys +import zipfile from pathlib import Path # Same public source revision used for NVIDIA's WoA evaluation build. OPENCV_REF = "c9cce3de2eae2039ebb0c22d13c9ed85a40e338f" +# The contrib build has every main module plus contrib's, and provides cv2. +PROVIDER = "opencv-contrib-python" +# All four OpenCV distributions install the same cv2 module, but installers +# match requirements by distribution name, and PyPI has no Windows ARM64 +# wheel for any of them. Empty wheels under the other names depend on the +# provider, so custom nodes that ask for them resolve to this build. +ALIASES = ("opencv-python", "opencv-python-headless", "opencv-contrib-python-headless") + + +def wheel_name(distribution): + return distribution.replace("-", "_") + def find_wheel(directory): wheels = list(directory.glob("*.whl")) if len(wheels) != 1 or not wheels[0].name.endswith("-cp37-abi3-win_arm64.whl"): raise RuntimeError(f"Expected one cp37-abi3-win_arm64 wheel, found {wheels}") - if not wheels[0].name.startswith("opencv_python-"): - raise RuntimeError(f"Expected the main OpenCV package, found {wheels[0].name}") + if not wheels[0].name.startswith(f"{wheel_name(PROVIDER)}-"): + raise RuntimeError(f"Expected the {PROVIDER} package, found {wheels[0].name}") return wheels[0] +def write_alias_wheel(directory, distribution, version): + """Write a wheel with no files whose only metadata pins the provider.""" + dist_info = f"{wheel_name(distribution)}-{version}.dist-info" + files = { + f"{dist_info}/METADATA": ( + "Metadata-Version: 2.1\n" + f"Name: {distribution}\n" + f"Version: {version}\n" + f"Summary: Resolves to {PROVIDER} {version}, which provides cv2\n" + f"Requires-Dist: {PROVIDER}=={version}\n" + ), + f"{dist_info}/WHEEL": ( + "Wheel-Version: 1.0\n" + "Generator: build_opencv_arm64.py\n" + "Root-Is-Purelib: true\n" + "Tag: py3-none-any\n" + ), + } + record = [] + for path, text in files.items(): + data = text.encode("utf-8") + digest = base64.urlsafe_b64encode(hashlib.sha256(data).digest()).rstrip(b"=") + record.append(f"{path},sha256={digest.decode()},{len(data)}") + record.append(f"{dist_info}/RECORD,,") + files[f"{dist_info}/RECORD"] = "\n".join(record) + "\n" + + wheel = directory / f"{wheel_name(distribution)}-{version}-py3-none-any.whl" + with zipfile.ZipFile(wheel, "w") as zf: + for path, text in files.items(): + # A fixed timestamp keeps the bytes identical across builds. + info = zipfile.ZipInfo(path, date_time=(1980, 1, 1, 0, 0, 0)) + info.external_attr = 0o644 << 16 + zf.writestr(info, text) + return wheel + + def build(work): + """Build the provider wheel plus its aliases; returns all wheel paths.""" if sys.platform != "win32" or platform.machine().upper() != "ARM64": raise SystemExit("OpenCV must be built with native Windows ARM64 Python") if sys.version_info < (3, 11): @@ -53,7 +105,10 @@ def run(*args, **kwargs): run("git", "-C", source, "checkout", "--detach", "FETCH_HEAD") # find_version.py uses this suffix for the version pinned in requirements. run("git", "-C", source, "config", "core.abbrev", "7") - run("git", "-C", source, "submodule", "update", "--init", "--depth=1", "opencv") + # Shallow, at the commits the superproject records. setup.py's own + # submodule update then has nothing left to fetch. + run("git", "-C", source, "submodule", "update", "--init", "--depth=1", + "opencv", "opencv_contrib") run(sys.executable, "-m", "venv", work / "venv") python = work / "venv" / "Scripts" / "python.exe" requirements = tomllib.loads((source / "pyproject.toml").read_text())["build-system"]["requires"] @@ -62,7 +117,7 @@ def run(*args, **kwargs): env = { **os.environ, "CI_BUILD": "1", - "ENABLE_CONTRIB": "0", + "ENABLE_CONTRIB": "1", "ENABLE_HEADLESS": "0", "ENABLE_ROLLING": "0", "CMAKE_BUILD_PARALLEL_LEVEL": "4", @@ -73,8 +128,11 @@ def run(*args, **kwargs): run(python, "setup.py", "bdist_wheel", "--py-limited-api=cp37", "--dist-dir", output, "--", "-G", generator, cwd=source, env=env) wheel = find_wheel(output) - print(f"Built {wheel.name} from {OPENCV_REF}", flush=True) - return wheel + version = wheel.name.split("-")[1] + aliases = [write_alias_wheel(output, name, version) for name in ALIASES] + print(f"Built {wheel.name} from {OPENCV_REF}, plus aliases " + f"{', '.join(ALIASES)}", flush=True) + return [wheel, *aliases] if __name__ == "__main__": diff --git a/scripts/smoke_win_arm64.py b/scripts/smoke_win_arm64.py index 33f9d44..ad8a8e1 100644 --- a/scripts/smoke_win_arm64.py +++ b/scripts/smoke_win_arm64.py @@ -9,6 +9,8 @@ import kornia_rs # noqa: F401 - loading the native extension is the check import numpy as np +from build_opencv_arm64 import ALIASES, PROVIDER + assert platform.machine().upper() == "ARM64", platform.machine() assert importlib.metadata.version("blake3") == "1.0.11" assert importlib.metadata.version("kornia-rs") == "0.1.15" @@ -16,6 +18,9 @@ "6437b3ac38465133ffb63b75273a8db548c558465d79db03fd359c6cd5bd9d85" ) assert cv2.__version__.startswith("4.14.0"), cv2.__version__ +opencv_version = importlib.metadata.version(PROVIDER) +for alias in ALIASES: + assert importlib.metadata.version(alias) == opencv_version, alias image = np.zeros((16, 16, 3), dtype=np.uint8) image[4:12, 4:12] = (0, 128, 255) gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) @@ -23,5 +28,8 @@ ok, encoded = cv2.imencode(".png", image) assert ok np.testing.assert_array_equal(cv2.imdecode(encoded, cv2.IMREAD_COLOR), image) +# ximgproc ships only in contrib. +assert cv2.ximgproc.guidedFilter(image, image, 2, 1e-2).shape == image.shape print(cv2.getBuildInformation()) -print("OpenCV PNG round-trip, blake3 hashing, and kornia-rs ARM64 imports OK") +print("OpenCV contrib PNG round-trip and aliases, blake3 hashing, and kornia-rs" + " ARM64 imports OK") diff --git a/scripts/test_local_tooling.py b/scripts/test_local_tooling.py index 3576ed8..39bc973 100644 --- a/scripts/test_local_tooling.py +++ b/scripts/test_local_tooling.py @@ -6,11 +6,15 @@ (or from scripts/: python3 -m unittest test_local_tooling) """ +import base64 +import hashlib +import importlib.metadata import json import re import sys import tempfile import unittest +import zipfile from unittest.mock import patch from pathlib import Path @@ -108,28 +112,35 @@ def test_prereleases_are_not_enabled_for_all_comfyui_dependencies(self): def test_cryptography_uses_last_public_windows_arm64_wheel(self): self.assertIn("cryptography==46.0.3", self.text) - def test_opencv_pin_matches_the_pinned_source_commit(self): + def test_every_opencv_name_pins_the_build_from_the_pinned_commit(self): # find_version.py appends the 7-character commit (core.abbrev 7), so - # bumping OPENCV_REF without this pin only fails at release install. - m = re.search(r"^opencv-python==[\d.]+\+([0-9a-f]+)$", self.text, re.M) - self.assertIsNotNone(m) - self.assertEqual(m.group(1), build_opencv_arm64.OPENCV_REF[:7]) + # bumping OPENCV_REF without these pins only fails at release install. + pins = dict(re.findall(r"^(opencv[\w-]*)==([\d.]+\+[0-9a-f]+)$", self.text, re.M)) + self.assertEqual(len(re.findall(r"^opencv", self.text, re.M)), len(pins)) + self.assertEqual(set(pins), + {build_opencv_arm64.PROVIDER, *build_opencv_arm64.ALIASES}) + versions = set(pins.values()) + self.assertEqual(len(versions), 1, versions) + self.assertTrue(versions.pop().endswith(f"+{build_opencv_arm64.OPENCV_REF[:7]}")) class WindowsArm64NativeDependencies(unittest.TestCase): def test_install_resolves_opencv_with_full_requirements(self): with tempfile.TemporaryDirectory() as tmp: work = Path(tmp) - wheel = work / "opencv_python-4.14.0+c9cce3d-cp37-abi3-win_arm64.whl" + wheels = [work / "opencv_contrib_python-4.14.0+c9cce3d-cp37-abi3-win_arm64.whl", + *(work / f"{build_opencv_arm64.wheel_name(name)}-4.14.0+c9cce3d-py3-none-any.whl" + for name in build_opencv_arm64.ALIASES)] with patch.object(build_local, "HOST_OS", "win"), \ patch.object(build_local.subprocess, "run"), \ patch.object(build_local, "run") as run, \ - patch.object(build_local, "build_opencv_wheel", return_value=wheel) as build: + patch.object(build_local, "build_opencv_wheels", return_value=wheels) as build: build_local.install_requirements( work, "beta-win-nvidia-arm64", "requirements-nvidia-arm64.txt") build.assert_called_once_with(work / "opencv-build") install = run.call_args_list[-1].args[0] - self.assertIn(str(wheel), install) + for wheel in wheels: + self.assertIn(str(wheel), install) self.assertIn("requirements_final.txt", install) requirements = (work / "requirements_final.txt").read_text() self.assertIn("--only-binary=blake3", requirements) @@ -144,28 +155,63 @@ def test_x64_install_does_not_build_or_install_arm64_wheels(self): with patch.object(build_local, "HOST_OS", "win"), \ patch.object(build_local.subprocess, "run"), \ patch.object(build_local, "run") as run, \ - patch.object(build_local, "build_opencv_wheel") as build: + patch.object(build_local, "build_opencv_wheels") as build: build_local.install_requirements(Path(tmp), "win-nvidia", "requirements-nvidia.txt") build.assert_not_called() self.assertFalse(any(str(arg).endswith(".whl") for arg in run.call_args.args[0])) - def test_wheel_selection_rejects_foreign_architecture_and_ambiguous_output(self): + def test_wheel_selection_requires_one_native_contrib_wheel(self): with tempfile.TemporaryDirectory() as tmp: directory = Path(tmp) - for name in ["opencv_python-4.14.0-cp37-abi3-win_amd64.whl", - "opencv_python_headless-4.14.0-cp37-abi3-win_arm64.whl"]: + for name in ["opencv_contrib_python-4.14.0-cp37-abi3-win_amd64.whl", + "opencv_python-4.14.0-cp37-abi3-win_arm64.whl", + "opencv_contrib_python_headless-4.14.0-cp37-abi3-win_arm64.whl"]: wheel = directory / name wheel.touch() with self.assertRaises(RuntimeError): build_opencv_arm64.find_wheel(directory) wheel.unlink() - wheel = directory / "opencv_python-4.14.0+c9cce3d-cp37-abi3-win_arm64.whl" + wheel = directory / "opencv_contrib_python-4.14.0+c9cce3d-cp37-abi3-win_arm64.whl" wheel.touch() self.assertEqual(build_opencv_arm64.find_wheel(directory), wheel) (directory / "extra.whl").touch() with self.assertRaises(RuntimeError): build_opencv_arm64.find_wheel(directory) + def test_alias_wheels_hold_only_metadata_that_pins_the_provider(self): + version = "4.14.0+c9cce3d" + with tempfile.TemporaryDirectory() as tmp: + for name in build_opencv_arm64.ALIASES: + directory = Path(tmp) / name + directory.mkdir() + wheel = build_opencv_arm64.write_alias_wheel(directory, name, version) + dist_info = f"{build_opencv_arm64.wheel_name(name)}-{version}.dist-info" + self.assertEqual(wheel.name, + f"{build_opencv_arm64.wheel_name(name)}-{version}-py3-none-any.whl") + with zipfile.ZipFile(wheel) as zf: + contents = {path: zf.read(path) for path in zf.namelist()} + zf.extractall(directory / "site") + # No cv2 files: uninstalling an alias must never touch the provider's. + self.assertTrue(all(path.startswith(f"{dist_info}/") for path in contents)) + for line in contents[f"{dist_info}/RECORD"].decode().splitlines(): + path, hash_, size = line.rsplit(",", 2) + if path.endswith("/RECORD"): + continue + digest = base64.urlsafe_b64encode( + hashlib.sha256(contents[path]).digest()).rstrip(b"=").decode() + self.assertEqual(hash_, f"sha256={digest}", path) + self.assertEqual(int(size), len(contents[path]), path) + dist, = importlib.metadata.distributions(path=[str(directory / "site")]) + self.assertEqual(dist.metadata["Name"], name) + self.assertEqual(dist.version, version) + self.assertEqual(dist.requires, [f"{build_opencv_arm64.PROVIDER}=={version}"]) + # Identical bytes on every build keep the cached wheels stable. + again = Path(tmp) / f"{name}-again" + again.mkdir() + self.assertEqual( + build_opencv_arm64.write_alias_wheel(again, name, version).read_bytes(), + wheel.read_bytes()) + def test_foreign_host_is_rejected_before_running_tools(self): with tempfile.TemporaryDirectory() as tmp: work = Path(tmp) / "build"