Files
kornia--kornia/tests/augmentation/test_onnx_export.py
T
wehub-resource-sync 3a2c66702c
Tests on CPU (scheduled) / check-skip (push) Has been cancelled
Tests on CPU (scheduled) / pre-tests (push) Has been cancelled
Tests on CPU (scheduled) / tests-cpu-ubuntu (float32) (push) Has been cancelled
Tests on CPU (scheduled) / tests-cpu-ubuntu (float64) (push) Has been cancelled
Tests on CPU (scheduled) / tests-cpu-windows (3.11, float32, 2.5.1) (push) Has been cancelled
Tests on CPU (scheduled) / tests-cpu-windows (3.11, float32, 2.9.1) (push) Has been cancelled
Tests on CPU (scheduled) / tests-cpu-windows (3.11, float64, 2.5.1) (push) Has been cancelled
Tests on CPU (scheduled) / tests-cpu-windows (3.11, float64, 2.9.1) (push) Has been cancelled
Tests on CPU (scheduled) / tests-cpu-windows (3.12, float32, 2.5.1) (push) Has been cancelled
Tests on CPU (scheduled) / tests-cpu-windows (3.12, float32, 2.9.1) (push) Has been cancelled
Tests on CPU (scheduled) / tests-cpu-windows (3.12, float64, 2.5.1) (push) Has been cancelled
Tests on CPU (scheduled) / tests-cpu-windows (3.12, float64, 2.9.1) (push) Has been cancelled
Tests on CPU (scheduled) / tests-cpu-windows (3.13, float32, 2.9.1) (push) Has been cancelled
Tests on CPU (scheduled) / tests-cpu-windows (3.13, float64, 2.9.1) (push) Has been cancelled
Tests on CPU (scheduled) / tests-cpu-mac (3.11, float32, 2.5.1) (push) Has been cancelled
Tests on CPU (scheduled) / tests-cpu-mac (3.11, float32, 2.9.1) (push) Has been cancelled
Tests on CPU (scheduled) / tests-cpu-mac (3.12, float32, 2.5.1) (push) Has been cancelled
Tests on CPU (scheduled) / tests-cpu-mac (3.12, float32, 2.9.1) (push) Has been cancelled
Tests on CPU (scheduled) / tests-cpu-mac (3.13, float32, 2.9.1) (push) Has been cancelled
Tests on CPU (scheduled) / coverage (push) Has been cancelled
Tests on CPU (scheduled) / typing (push) Has been cancelled
Tests on CPU (scheduled) / tutorials (push) Has been cancelled
Tests on CPU (scheduled) / docs (push) Has been cancelled
Lint / TOML Format (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:49:27 +08:00

321 lines
12 KiB
Python

# LICENSE HEADER MANAGED BY add-license-header
#
# Copyright 2018 Kornia Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""ONNX export regression tests for kornia.augmentation modules.
These tests pin the set of augmentations that successfully export through
``torch.onnx.export`` (legacy TorchScript-tracer path) at opset 20, so future
changes to the augmentation base classes do not silently regress export
support.
Opset 20 was chosen as the floor because:
- ``aten::affine_grid_generator`` (used by every geometric augmentation that
goes through ``warp_affine``) only lowers from opset 20 upward.
- ``aten::grid_sampler`` is supported from opset 16, well below 20.
- Earlier opsets work for non-geometric augmentations, but standardising on a
single opset keeps the test matrix small.
What this file does NOT test:
- Numerical equivalence between the exported ONNX graph and the eager forward.
That requires onnxruntime, which is not a hard dependency of kornia. Add a
separate test file (e.g. ``test_onnx_export_numerical.py``) gated on the
optional ``onnxruntime`` import when that work lands.
- The ``dynamo=True`` export path. That is tracked separately because it has
different op coverage (notably, ``aten::linalg_solve`` lowers cleanly under
dynamo but not under the legacy tracer).
- Augmentations that currently cannot export under the legacy tracer due to
missing ONNX op support, see ``XFAIL_OPS`` below.
Adding an augmentation to ``EXPORTABLE_OPS`` is the way to advertise that it
cleanly exports today; adding to ``XFAIL_OPS`` marks a known gap with the
underlying op so we don't lose the signal that it's still pending.
"""
from __future__ import annotations
import io
import warnings
from typing import Any, Callable, Tuple
import pytest
import torch
import kornia.augmentation as K
def _hflip() -> torch.nn.Module:
return K.RandomHorizontalFlip(p=1.0)
def _vflip() -> torch.nn.Module:
return K.RandomVerticalFlip(p=1.0)
def _color_jiggle() -> torch.nn.Module:
return K.ColorJiggle(0.1, 0.1, 0.1, 0.1, p=1.0)
def _brightness() -> torch.nn.Module:
return K.RandomBrightness(brightness=(0.5, 1.5), p=1.0)
def _grayscale() -> torch.nn.Module:
return K.RandomGrayscale(p=1.0)
def _invert() -> torch.nn.Module:
return K.RandomInvert(p=1.0)
def _gaussian_blur() -> torch.nn.Module:
return K.RandomGaussianBlur((3, 3), (0.1, 2.0), p=1.0)
def _erasing() -> torch.nn.Module:
return K.RandomErasing(p=1.0)
def _posterize() -> torch.nn.Module:
return K.RandomPosterize(3, p=1.0)
def _solarize() -> torch.nn.Module:
return K.RandomSolarize(0.1, p=1.0)
def _normalize() -> torch.nn.Module:
return K.Normalize(mean=torch.zeros(3), std=torch.ones(3))
def _affine() -> torch.nn.Module:
return K.RandomAffine(degrees=10.0, p=1.0)
def _rotation() -> torch.nn.Module:
return K.RandomRotation(degrees=10.0, p=1.0)
def _perspective() -> torch.nn.Module:
return K.RandomPerspective(p=1.0)
def _augmentation_sequential() -> torch.nn.Module:
"""A torchgeo-typical pipeline: per-element random flips followed by normalize."""
return K.AugmentationSequential(
K.RandomHorizontalFlip(p=0.5),
K.RandomVerticalFlip(p=0.5),
K.Normalize(mean=torch.zeros(3), std=torch.ones(3)),
data_keys=["input"],
)
# Augmentations that export today through the legacy TorchScript tracer at opset 20.
EXPORTABLE_OPS: list[Tuple[str, Callable[[], torch.nn.Module]]] = [
("RandomHorizontalFlip", _hflip),
("RandomVerticalFlip", _vflip),
("ColorJiggle", _color_jiggle),
("RandomBrightness", _brightness),
("RandomGrayscale", _grayscale),
("RandomInvert", _invert),
("RandomGaussianBlur", _gaussian_blur),
("RandomErasing", _erasing),
("RandomPosterize", _posterize),
("RandomSolarize", _solarize),
("Normalize", _normalize),
# Geometric augmentations enabled by:
# - bumping opset to 20 (for ``affine_grid_generator``)
# - the closed-form 3x3 inverse in ``kornia.core.utils._inverse_3x3_closed_form``
# that replaces ``aten::linalg_inv`` during ONNX export.
("RandomAffine", _affine),
("RandomRotation", _rotation),
# ``RandomPerspective`` exports via the closed-form Heckbert decomposition in
# ``kornia.geometry.transform.imgwarp._get_perspective_transform_closed_form``,
# which replaces the 8x8 ``torch.linalg.solve`` with two unit-square-to-quad
# constructions and one closed-form 3x3 inverse.
("RandomPerspective", _perspective),
# Container: pinned because torchgeo and similar callers wrap their pipelines
# in ``AugmentationSequential``. The container's ``forward`` strips trailing
# ``None`` positional args injected by the legacy ONNX tracer.
("AugmentationSequential", _augmentation_sequential),
]
# Currently no augmentations are blocked at export time. Augmentations that export
# but produce numerically different results from eager are tracked separately in
# ``ONNX_NUMERICAL_KNOWN_DRIFT`` below.
XFAIL_OPS: list[Tuple[str, Callable[[], torch.nn.Module], str]] = []
def _try_export(module: torch.nn.Module, x: torch.Tensor) -> int:
"""Run ``torch.onnx.export`` against an in-memory buffer and return the byte count."""
module.eval()
buf = io.BytesIO()
with warnings.catch_warnings():
warnings.simplefilter("ignore")
torch.onnx.export(
module,
(x,),
buf,
opset_version=20,
input_names=["input"],
output_names=["output"],
dynamo=False,
)
return len(buf.getvalue())
@pytest.mark.parametrize("name,factory", EXPORTABLE_OPS, ids=[n for n, _ in EXPORTABLE_OPS])
def test_onnx_export_exportable(name: str, factory: Callable[[], torch.nn.Module]) -> None:
"""Augmentation exports cleanly via ``torch.onnx.export`` (legacy tracer, opset 20)."""
torch.manual_seed(0)
x = torch.randn(2, 3, 32, 32)
size = _try_export(factory(), x)
assert size > 0, f"{name}: ONNX graph was empty"
@pytest.mark.parametrize("name,factory,reason", XFAIL_OPS, ids=[n for n, _, _ in XFAIL_OPS])
def test_onnx_export_known_blocked(name: str, factory: Callable[[], torch.nn.Module], reason: str) -> None:
"""Augmentation cannot export today; pinned so we notice if it starts working."""
torch.manual_seed(0)
x = torch.randn(2, 3, 32, 32)
with pytest.raises(Exception):
_try_export(factory(), x)
# ----------------------------------------------------------------------------
# Numerical-equivalence checks (require onnxruntime).
#
# Each entry pins a concrete deterministic configuration of the augmentation is
# fixed angles, fixed brightness, etc. So comparing eager and ONNX is a
# clean apples-to-apples test of "did the export faithfully capture the
# computation". Augmentations with random parameter ranges are deliberately
# excluded from the numerical check because ``torch.onnx.export`` consumes RNG
# during tracing in ways that don't line up with a separate eager call, even
# under the same ``torch.manual_seed``, that mismatch is RNG bookkeeping and
# would mask real bugs. The export-success tests above cover the random case.
# ----------------------------------------------------------------------------
ort = pytest.importorskip("onnxruntime", reason="onnxruntime is required for numerical-equivalence checks")
np = pytest.importorskip("numpy")
# Deterministic factories for numerical tests. Tuple ranges of the form ``(v, v)``
# evaluate to a single fixed value under uniform sampling. Geometric augmentations
# additionally rely on the in-place-mutation fix in
# ``kornia.geometry.conversions.normal_transform_pixel`` and the ``deg2rad``
# inlining in the affine/shear ``compute_transformation`` paths.
def _hflip_det() -> torch.nn.Module:
return K.RandomHorizontalFlip(p=1.0)
def _vflip_det() -> torch.nn.Module:
return K.RandomVerticalFlip(p=1.0)
def _grayscale_det() -> torch.nn.Module:
return K.RandomGrayscale(p=1.0)
def _invert_det() -> torch.nn.Module:
return K.RandomInvert(p=1.0)
def _normalize_det() -> torch.nn.Module:
return K.Normalize(mean=torch.zeros(3), std=torch.ones(3))
def _brightness_det() -> torch.nn.Module:
return K.RandomBrightness(brightness=(1.2, 1.2), p=1.0)
def _color_jiggle_det() -> torch.nn.Module:
return K.ColorJiggle(brightness=(1.2, 1.2), p=1.0)
def _gaussian_blur_det() -> torch.nn.Module:
return K.RandomGaussianBlur((3, 3), (1.5, 1.5), p=1.0)
def _solarize_det() -> torch.nn.Module:
return K.RandomSolarize(thresholds=(0.5, 0.5), additions=(0.0, 0.0), p=1.0)
def _rotation_det() -> torch.nn.Module:
return K.RandomRotation(degrees=(15.0, 15.0), p=1.0)
def _affine_det() -> torch.nn.Module:
return K.RandomAffine(degrees=(15.0, 15.0), translate=(0.0, 0.0), scale=(1.0, 1.0), shear=(0.0, 0.0), p=1.0)
def _affine_with_shear_det() -> torch.nn.Module:
return K.RandomAffine(degrees=(0.0, 0.0), shear=(5.0, 5.0), p=1.0)
# Augmentations whose exported graph produces numerically equivalent results to
# eager when configured with deterministic parameters. ``max diff < 1e-3``
# threshold is conservative, most are float32 precision (< 1e-5).
ONNX_NUMERICAL_EQUIVALENT: list[Tuple[str, Callable[[], torch.nn.Module]]] = [
# Parameter-free / no random sampling
("RandomHorizontalFlip", _hflip_det),
("RandomVerticalFlip", _vflip_det),
("RandomGrayscale", _grayscale_det),
("RandomInvert", _invert_det),
("Normalize", _normalize_det),
# Random params pinned to deterministic ranges
("RandomBrightness", _brightness_det),
("ColorJiggle", _color_jiggle_det),
("RandomGaussianBlur", _gaussian_blur_det),
("RandomSolarize", _solarize_det),
# Geometric augmentations, exercise the warp_affine / grid_sample path.
# Previously diverged due to in-place mutation in normal_transform_pixel
# losing the scale factors under tracing.
("RandomRotation", _rotation_det),
("RandomAffine", _affine_det),
("RandomAffine_with_shear", _affine_with_shear_det),
]
def _run_onnx(module: torch.nn.Module, x: torch.Tensor) -> Any:
module.eval()
buf = io.BytesIO()
with warnings.catch_warnings():
warnings.simplefilter("ignore")
torch.onnx.export(
module,
(x,),
buf,
opset_version=20,
dynamo=False,
input_names=["input"],
output_names=["output"],
)
sess = ort.InferenceSession(buf.getvalue())
return sess.run(["output"], {"input": x.numpy()})[0]
@pytest.mark.parametrize("name,factory", ONNX_NUMERICAL_EQUIVALENT, ids=[n for n, _ in ONNX_NUMERICAL_EQUIVALENT])
def test_onnx_export_numerically_matches_eager(name: str, factory: Callable[[], torch.nn.Module]) -> None:
"""Exported graph produces the same numbers as eager for the deterministic configuration."""
torch.manual_seed(0)
x = torch.randn(2, 3, 32, 32)
aug = factory()
aug.eval()
eager = aug(x).numpy()
onnx_out = _run_onnx(aug, x)
assert eager.shape == onnx_out.shape, f"{name}: shape mismatch {eager.shape} vs {onnx_out.shape}"
max_diff = float(np.abs(eager - onnx_out).max())
assert max_diff < 1e-3, f"{name}: max abs diff {max_diff:.4f} exceeds 1e-3"