tensor-ee 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. tensor_ee/__init__.py +28 -0
  2. tensor_ee/_swin_parameters.py +304 -0
  3. tensor_ee/blocks/__init__.py +0 -0
  4. tensor_ee/blocks/raster/__init__.py +36 -0
  5. tensor_ee/blocks/raster/activations.py +183 -0
  6. tensor_ee/blocks/raster/conv.py +183 -0
  7. tensor_ee/blocks/raster/merge.py +66 -0
  8. tensor_ee/blocks/raster/outputs.py +211 -0
  9. tensor_ee/blocks/raster/pooling.py +182 -0
  10. tensor_ee/blocks/raster/resample.py +64 -0
  11. tensor_ee/blocks/window/__init__.py +44 -0
  12. tensor_ee/blocks/window/activations.py +54 -0
  13. tensor_ee/blocks/window/attention.py +366 -0
  14. tensor_ee/blocks/window/conv.py +323 -0
  15. tensor_ee/blocks/window/linear.py +129 -0
  16. tensor_ee/blocks/window/merge.py +67 -0
  17. tensor_ee/blocks/window/normalization.py +197 -0
  18. tensor_ee/blocks/window/patch.py +362 -0
  19. tensor_ee/blocks/window/pooling.py +275 -0
  20. tensor_ee/blocks/window/resample.py +286 -0
  21. tensor_ee/blocks/window/segformer.py +718 -0
  22. tensor_ee/blocks/window/swin.py +602 -0
  23. tensor_ee/blocks/window/swin_upernet_config.py +361 -0
  24. tensor_ee/blocks/window/upernet.py +1016 -0
  25. tensor_ee/comparison.py +213 -0
  26. tensor_ee/compatibility.py +180 -0
  27. tensor_ee/compiler.py +356 -0
  28. tensor_ee/converter/__init__.py +72 -0
  29. tensor_ee/converter/build.py +120 -0
  30. tensor_ee/converter/convert.py +388 -0
  31. tensor_ee/converter/export.py +846 -0
  32. tensor_ee/converter/graph.py +417 -0
  33. tensor_ee/converter/operations.py +790 -0
  34. tensor_ee/converter/plan.py +675 -0
  35. tensor_ee/converter/recognizers.py +1273 -0
  36. tensor_ee/converter/request.py +146 -0
  37. tensor_ee/converter/run.py +950 -0
  38. tensor_ee/core/__init__.py +8 -0
  39. tensor_ee/core/errors.py +25 -0
  40. tensor_ee/core/grid.py +323 -0
  41. tensor_ee/core/metadata.py +398 -0
  42. tensor_ee/core/ops.py +336 -0
  43. tensor_ee/core/precision.py +133 -0
  44. tensor_ee/core/tensor.py +129 -0
  45. tensor_ee/examples/__init__.py +1 -0
  46. tensor_ee/examples/nonwater.py +251 -0
  47. tensor_ee/layouts/__init__.py +17 -0
  48. tensor_ee/layouts/boundaries.py +103 -0
  49. tensor_ee/layouts/raster.py +117 -0
  50. tensor_ee/layouts/shift.py +226 -0
  51. tensor_ee/layouts/window.py +201 -0
  52. tensor_ee/models/__init__.py +16 -0
  53. tensor_ee/models/fixtures.py +119 -0
  54. tensor_ee/models/segformer_b0/__init__.py +9 -0
  55. tensor_ee/models/segformer_b0/configuration.py +209 -0
  56. tensor_ee/models/segformer_b0/decoder.py +130 -0
  57. tensor_ee/models/segformer_b0/encoder.py +90 -0
  58. tensor_ee/models/swin_t224/__init__.py +30 -0
  59. tensor_ee/models/swin_t224/configuration.py +103 -0
  60. tensor_ee/models/swin_t224/decoder.py +53 -0
  61. tensor_ee/models/swin_t224/encoder.py +50 -0
  62. tensor_ee/onnx_import.py +431 -0
  63. tensor_ee/outputs.py +16 -0
  64. tensor_ee/primitives.py +110 -0
  65. tensor_ee/receptive_field.py +257 -0
  66. tensor_ee/resnet_models.py +185 -0
  67. tensor_ee/segwater_inference.py +235 -0
  68. tensor_ee/segwater_preprocessing.py +182 -0
  69. tensor_ee/segwater_pytorch.py +249 -0
  70. tensor_ee/semantics.py +389 -0
  71. tensor_ee/smp.py +155 -0
  72. tensor_ee/swin.py +197 -0
  73. tensor_ee/swin_workflow.py +470 -0
  74. tensor_ee/tiling.py +287 -0
  75. tensor_ee/weight_assets.py +371 -0
  76. tensor_ee/weight_mapping.py +51 -0
  77. tensor_ee/weights/__init__.py +14 -0
  78. tensor_ee/weights/assets.py +160 -0
  79. tensor_ee/weights/constants.py +156 -0
  80. tensor_ee-0.1.0.dist-info/METADATA +172 -0
  81. tensor_ee-0.1.0.dist-info/RECORD +84 -0
  82. tensor_ee-0.1.0.dist-info/WHEEL +5 -0
  83. tensor_ee-0.1.0.dist-info/licenses/LICENSE +21 -0
  84. tensor_ee-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,183 @@
1
+ """R convolution: PyTorch ``nn.Conv2d`` as ordered neighbourhood bands and
2
+ ``matrixMultiply``.
3
+
4
+ Moved whole from ``tensor_ee.compiler`` (``conv2d``, ``_pair``, ``_names``) and
5
+ ``tensor_ee.semantics`` (``conv_options`` with the coefficient checks it uses:
6
+ ``_coefficient_field``, ``finite_coefficients``). The old names are re-exports
7
+ of these objects. The errors they raise, ``SemanticUnknown`` and ``reject``
8
+ (here ``_reject``), are in ``tensor_ee.core.errors``.
9
+
10
+ Supported settings (contract section 8): odd kernels with same padding, stride
11
+ 1 or 2, dilation 1, groups 1, zero padding, finite float32 coefficients. Other
12
+ strides stay rejected.
13
+
14
+ Precision policy ``float32_operands`` (contract section 7): the weight and bias
15
+ arrays are cast ``.float()`` before ``matrixMultiply``. The node output is
16
+ materialized with ``.toFloat()`` by the caller (``compile_model`` or the
17
+ ``tensor_ee.primitives`` facade), not here.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Any
23
+
24
+ import ee
25
+ import torch
26
+ from torch import nn
27
+
28
+ from ...core.errors import SemanticUnknown, UnsupportedOperation, reject as _reject
29
+ from ...core.metadata import ANY_SETTINGS, BlockMetadata, Locality, validate_metadata
30
+ from ...core.precision import FLOAT32_OPERANDS, validate_policy
31
+ from ...core.tensor import Raster
32
+ # Library converters and coefficient transport. These module-level bindings are
33
+ # the names conv2d looks up (tests patch _inline_float32_array here).
34
+ from ...layouts.raster import project as _project, stride_sample as _stride_sample
35
+ from ...layouts.raster import zero_extended as _zero_extended
36
+ from ...weights.constants import inline_float32_array as _inline_float32_array
37
+
38
+
39
+ def _pair(value: Any) -> tuple[Any, Any]:
40
+ if isinstance(value, (tuple, list)):
41
+ if len(value) != 2:
42
+ raise UnsupportedOperation(f"Expected a scalar or pair, received {value!r}")
43
+ return tuple(value)
44
+ return value, value
45
+
46
+
47
+ def _names(count: int) -> tuple[str, ...]:
48
+ return tuple(f"c{i}" for i in range(count))
49
+
50
+
51
+ def finite_coefficients(module: nn.Module) -> None:
52
+ """Inspect local coefficients only, never an input-shaped activation."""
53
+ for name, value in (*module.named_parameters(recurse=False), *module.named_buffers(recurse=False)):
54
+ if not value.is_floating_point():
55
+ continue
56
+ if value.dtype != torch.float32:
57
+ _reject("Only float32 model parameters and buffers are supported; convert with model.float()", "model_dtype")
58
+ try:
59
+ finite = bool(torch.isfinite(value.detach()).all().item())
60
+ except Exception as error:
61
+ raise SemanticUnknown(f"Cannot inspect local coefficient {name}: {type(error).__name__}: {error}") from error
62
+ if not finite:
63
+ _reject(f"Local coefficient {name} must contain only finite tensors", "nonfinite_coefficients")
64
+
65
+
66
+ def _coefficient_field(value: Any, name: str, shape: tuple[int, ...]) -> None:
67
+ # Computational tensors require float32 even though the report's global
68
+ # floating-dtype Boolean deliberately ignores integer bookkeeping.
69
+ if not isinstance(value, torch.Tensor) or value.dtype != torch.float32:
70
+ _reject(f"Only float32 computational coefficient {name} is supported", "model_dtype")
71
+ try:
72
+ actual = tuple(value.shape)
73
+ except Exception as error:
74
+ raise SemanticUnknown(f"Cannot inspect local coefficient {name} shape: {error}") from error
75
+ if actual != shape:
76
+ _reject(f"Local coefficient {name} shape must be {shape}, received {actual}", "coefficient_shape")
77
+
78
+
79
+ def conv_options(module: nn.Conv2d) -> tuple[tuple[int, int], int]:
80
+ kh, kw = _pair(module.kernel_size)
81
+ if kh % 2 != 1 or kw % 2 != 1:
82
+ _reject("Conv2d kernels must have odd height and width")
83
+ padding = (kh // 2, kw // 2) if module.padding == "same" else module.padding
84
+ if padding != (kh // 2, kw // 2):
85
+ _reject("Conv2d must use same padding (kernel_size // 2)")
86
+ stride = _pair(module.stride)
87
+ if (stride not in ((1, 1), (2, 2)) or _pair(module.dilation) != (1, 1)
88
+ or module.groups != 1 or module.padding_mode != "zeros"):
89
+ _reject("Conv2d requires stride=1 or 2, dilation=1, groups=1, padding_mode='zeros'")
90
+ _coefficient_field(module.weight, "Conv2d.weight", (module.out_channels, module.in_channels, kh, kw))
91
+ if module.bias is not None:
92
+ _coefficient_field(module.bias, "Conv2d.bias", (module.out_channels,))
93
+ finite_coefficients(module)
94
+ return (kh, kw), stride[0]
95
+
96
+
97
+ def conv2d(raster: Raster, module: nn.Conv2d,
98
+ parameters: tuple[ee.Array, ee.Array | None] | None = None,
99
+ *, execution_dtype: str = "float32", clip_footprints: bool = False,
100
+ policy: str = FLOAT32_OPERANDS) -> Raster:
101
+ """PyTorch cross-correlation via ordered neighborhood bands and matrixMultiply."""
102
+ validate_policy(policy, allowed=FLOAT32_OPERANDS)
103
+ if execution_dtype != "float32":
104
+ raise ValueError("execution_dtype must be float32 (maximum supported precision)")
105
+ (kh, kw), stride_value = conv_options(module)
106
+ stride = (stride_value, stride_value)
107
+ if module.in_channels != len(raster.bands):
108
+ raise UnsupportedOperation("Conv2d input channel count does not match raster")
109
+ kernel = ee.Kernel.fixed(kw, kh, [[1] * kw for _ in range(kh)], kw // 2, kh // 2, False)
110
+ # Never rely on the EE-generated default band ordering. Torch's flattened
111
+ # OIHW weights require channels, then rows, then columns. EE suffixes use
112
+ # offsets relative to the kernel origin, with x before y.
113
+ patch_bands = [f"{band}_{dx}_{dy}" for band in raster.bands
114
+ for dy in range(-(kh // 2), kh // 2 + 1)
115
+ for dx in range(-(kw // 2), kw // 2 + 1)]
116
+ patch = (_zero_extended(raster).neighborhoodToBands(kernel)
117
+ .select(patch_bands).toArray().toArray(1))
118
+ if parameters is None:
119
+ weights = _inline_float32_array(module.weight.detach().cpu().float()
120
+ .reshape(module.out_channels, -1).tolist())
121
+ bias = (None if module.bias is None else
122
+ _inline_float32_array([[value] for value in module.bias.detach().cpu().float().tolist()]))
123
+ else:
124
+ weights, bias = parameters
125
+ if execution_dtype == "float32":
126
+ # Keep matrix operands in float32 rather than promoting the entire
127
+ # activation neighborhood to double through the frozen weights.
128
+ weights = weights.float()
129
+ if bias is not None:
130
+ bias = bias.float()
131
+ result = ee.Image(weights).matrixMultiply(patch)
132
+ if bias is not None:
133
+ result = result.add(ee.Image(bias))
134
+ bands = _names(module.out_channels)
135
+ result = result.arrayProject([0]).arrayFlatten([list(bands)])
136
+ # Make the dense convolution's sampling grid explicit before decimation.
137
+ # Stride one already receives that projection in _finish; avoid a second
138
+ # redundant reproject in the long residual/decoder graph.
139
+ if stride[0] > 1:
140
+ result = _project(result, raster.grid, clip_footprints)
141
+ return _stride_sample(result, bands, raster.grid, stride[0], clip_footprints)
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # Locality and cost metadata (contract section 9)
146
+ # ---------------------------------------------------------------------------
147
+ def _conv_coefficients(module: nn.Conv2d) -> int:
148
+ """Values embedded in the request when the coefficients are inline:
149
+ ``out * in * kh * kw`` weights plus ``out`` biases."""
150
+ kh, kw = _pair(module.kernel_size)
151
+ count = module.out_channels * module.in_channels * kh * kw
152
+ return count + (module.out_channels if module.bias is not None else 0)
153
+
154
+
155
+ def _conv_asset_arrays(module: nn.Conv2d) -> int:
156
+ """Server arrays read when the coefficients are supplied as EE arrays:
157
+ the weight matrix, plus the bias column."""
158
+ return 1 + (module.bias is not None)
159
+
160
+
161
+ def _no_coefficients(*args, **kwargs) -> int:
162
+ """Cost of an R block without weights: it embeds no coefficients and reads
163
+ no asset arrays. Scalar options (a slope, a threshold, the float32 maximum)
164
+ are graph literals, not coefficients, and are not counted."""
165
+ return 0
166
+
167
+
168
+ METADATA = validate_metadata({
169
+ # Radius k // 2 for a k x k kernel (receptive_field._window_axis with
170
+ # padding k // 2). The static field records the largest validated kernel,
171
+ # 7 (radius 3); kernels 1 and 3 have radius 0 and 1.
172
+ # Validated: any accepted call. Kernels 1, 3 and 7 at strides 1 and 2, and
173
+ # clip_footprints both ways (True in resnet18_full64_float32_policy.json), were run;
174
+ # the unqualified "Ready" covers them, and other forms are refused.
175
+ "conv2d": BlockMetadata(
176
+ name="conv2d", layout_in="R", layout_out="R", locality=Locality.KERNEL,
177
+ policy=FLOAT32_OPERANDS, embedded_constants=_conv_coefficients,
178
+ asset_arrays=_conv_asset_arrays, radius=3, status="Ready",
179
+ evidence=("docs/history/cnn/results/parity.json", "docs/history/cnn/results/primitives_float32.json",
180
+ "docs/history/cnn/results/resnet_primitives.json", "docs/history/cnn/results/resnet18_full64_float32_policy.json",
181
+ "docs/history/cnn/results/segwater_js_parity_1x1.json", "docs/history/cnn/results/tiled_resnet18_2x2_parity.json"),
182
+ validated=ANY_SETTINGS),
183
+ })
@@ -0,0 +1,66 @@
1
+ """R merges: channel concatenation (``torch.cat`` on dim 1) and residual addition.
2
+
3
+ Moved whole from ``tensor_ee.compiler`` (``concatenate``, ``residual_add``).
4
+ The old names are re-exports of these objects. Both require identical grids;
5
+ there is no broadcasting.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Sequence
11
+
12
+ from ...core.errors import UnsupportedOperation
13
+ from ...core.metadata import ANY_SETTINGS, BlockMetadata, Locality, Validated, validate_metadata
14
+ from ...core.precision import FLOAT32_OPERANDS, validate_policy
15
+ from ...core.tensor import Raster
16
+ from ...layouts.raster import finish as _finish
17
+ from .conv import _names, _no_coefficients
18
+
19
+
20
+ def concatenate(rasters: Sequence[Raster], dim: int = 1,
21
+ *, clip_footprints: bool = False, policy: str = FLOAT32_OPERANDS) -> Raster:
22
+ validate_policy(policy, allowed=FLOAT32_OPERANDS)
23
+ if dim not in (1, -3) or not rasters:
24
+ raise UnsupportedOperation("Concatenation requires a nonempty sequence and NCHW channel dim=1")
25
+ grid = rasters[0].grid
26
+ if any(raster.grid != grid for raster in rasters):
27
+ raise UnsupportedOperation("Skip connection tensors must have identical spatial grids")
28
+ parts = [raster.image.rename([f"part{i}_{band}" for band in raster.bands])
29
+ for i, raster in enumerate(rasters)]
30
+ result = parts[0]
31
+ for part in parts[1:]:
32
+ result = result.addBands(part)
33
+ return _finish(result, _names(sum(len(raster.bands) for raster in rasters)), grid, clip_footprints)
34
+
35
+
36
+ def residual_add(left: Raster, right: Raster, *, clip_footprints: bool = False,
37
+ policy: str = FLOAT32_OPERANDS) -> Raster:
38
+ """Elementwise addition of equally shaped tensors; broadcasting is rejected."""
39
+ validate_policy(policy, allowed=FLOAT32_OPERANDS)
40
+ if not isinstance(left, Raster) or not isinstance(right, Raster):
41
+ raise UnsupportedOperation("Residual addition requires two raster tensors")
42
+ if left.grid != right.grid or len(left.bands) != len(right.bands):
43
+ raise UnsupportedOperation("Residual addition requires identical spatial grids and channel counts")
44
+ return _finish(left.image.add(right.image.rename(list(left.bands))), left.bands, left.grid, clip_footprints)
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Locality and cost metadata (contract section 9)
49
+ # ---------------------------------------------------------------------------
50
+ METADATA = validate_metadata({
51
+ # The U-Net's skip concatenations are torch.cat(dim=1); dim -3, the same axis, never ran.
52
+ "concatenate": BlockMetadata(
53
+ name="concatenate", layout_in="R", layout_out="R", locality=Locality.POINTWISE,
54
+ policy=FLOAT32_OPERANDS, embedded_constants=_no_coefficients,
55
+ asset_arrays=_no_coefficients, status="Ready",
56
+ evidence=("docs/history/cnn/results/parity.json", "docs/history/cnn/results/resnet18_full64_float32_policy.json",
57
+ "docs/history/cnn/results/segwater_js_parity_1x1.json"),
58
+ validated=Validated(({"dim": (1,)},))),
59
+ "residual_add": BlockMetadata(
60
+ name="residual_add", layout_in="R", layout_out="R", locality=Locality.POINTWISE,
61
+ policy=FLOAT32_OPERANDS, embedded_constants=_no_coefficients,
62
+ asset_arrays=_no_coefficients, status="Ready",
63
+ evidence=("docs/history/cnn/results/resnet_primitives.json", "docs/history/cnn/results/resnet18_full64_float32_policy.json",
64
+ "docs/history/cnn/results/segwater_js_parity_1x1.json"),
65
+ validated=ANY_SETTINGS),
66
+ })
@@ -0,0 +1,211 @@
1
+ """Recipe-free segmentation outputs on explicit finite raster grids.
2
+
3
+ Scores/probabilities are float32. Classes are the ordered raw-logit argmax;
4
+ thresholds compare widened probabilities against a retained binary64 scalar.
5
+ These functions build deferred graphs without cloud inspection or initialization.
6
+
7
+ Moved whole from ``tensor_ee.outputs`` (``segmentation_outputs``,
8
+ ``binary_threshold``, ``SegmentationOutputs`` and their helpers). The old names
9
+ are re-exports of these objects. The probabilities are this module's softmax
10
+ block materialized with ``.toFloat()``, exactly as the public
11
+ ``tensor_ee.softmax`` primitive the old module called.
12
+
13
+ Precision policy ``float32_operands`` (contract section 7): the threshold
14
+ widens the float32 probabilities ``toDouble()`` and compares them with an exact
15
+ binary64 scalar (``_ee_scalar``). The generic defaults (positive class 1,
16
+ threshold 0.5) are API defaults, not the SegWater recipe (contract section 11).
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass
21
+ from numbers import Integral, Real
22
+ import struct
23
+ from typing import Sequence
24
+
25
+ import ee
26
+
27
+ from ...core.errors import UnsupportedOperation
28
+ from ...core.metadata import ANY_SETTINGS, BlockMetadata, Locality, validate_metadata
29
+ from ...core.precision import FLOAT32_OPERANDS, validate_policy
30
+ from ...core.precision import exact_scalar as _ee_scalar
31
+ from ...core.tensor import Raster
32
+ from ...layouts.raster import _float32, finish as _finish
33
+ from .activations import _real_option, common_score_mask as _common_score_mask, softmax as _softmax
34
+ from .conv import _no_coefficients
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class SegmentationOutputs:
39
+ logits: Raster
40
+ probabilities: Raster
41
+ classes: Raster
42
+
43
+
44
+ def _band_names(names: Sequence[str], count: int, option: str) -> tuple[str, ...]:
45
+ if isinstance(names, str):
46
+ raise UnsupportedOperation(f"{option} must be a sequence of {count} names")
47
+ try:
48
+ result = tuple(names)
49
+ except TypeError as error:
50
+ raise UnsupportedOperation(f"{option} must be a sequence of {count} names") from error
51
+ if (len(result) != count or any(not isinstance(name, str) or not name for name in result)
52
+ or len(set(result)) != count):
53
+ raise UnsupportedOperation(f"{option} must contain exactly {count} nonempty unique names")
54
+ return result
55
+
56
+
57
+ def _scores(raster: Raster, *, binary: bool = False) -> ee.Image:
58
+ if not isinstance(raster, Raster):
59
+ raise TypeError("scores must be a Raster")
60
+ count = len(raster.bands)
61
+ if count < 2 or (binary and count != 2):
62
+ requirement = "exactly two" if binary else "at least two"
63
+ raise UnsupportedOperation(f"scores require {requirement} channels")
64
+ _band_names(raster.bands, count, "score bands")
65
+ return raster.image.select(list(raster.bands))
66
+
67
+
68
+ def _check_literal_validity(image: ee.Image) -> None:
69
+ """Inspect only constant literals and rename/cast/reproject wrappers.
70
+
71
+ No generic graph evaluator is attempted. Computed literals, arbitrary graph
72
+ operations and remote assets remain the caller's binary/finiteness assertion.
73
+ A float cast is applied to the literal values before checking the effective
74
+ binary payload; masks/other operations stop this narrow inspection.
75
+ """
76
+ wrappers = {"Image.rename": "input", "Image.toFloat": "value",
77
+ "Image.toDouble": "value", "Image.reproject": "image"}
78
+ float_cast = False
79
+ while isinstance(image, ee.Image) and image.func is not None:
80
+ signature = image.func.getSignature()
81
+ name = signature.get("name")
82
+ if name not in wrappers:
83
+ break
84
+ float_cast = float_cast or name == "Image.toFloat"
85
+ image = image.args[wrappers[name]]
86
+ if not isinstance(image, ee.Image) or image.func is None:
87
+ return
88
+ if image.func.getSignature().get("name") != "Image.constant":
89
+ return
90
+ value = image.args.get("value")
91
+ if isinstance(value, Real):
92
+ values = (value,)
93
+ elif isinstance(value, (list, tuple)) and all(isinstance(v, Real) for v in value):
94
+ values = tuple(value)
95
+ else:
96
+ return
97
+ if len(values) != 1:
98
+ raise UnsupportedOperation("local validity must have one band matching its Raster metadata")
99
+ if float_cast:
100
+ try:
101
+ values = tuple(struct.unpack("f", struct.pack("f", float(v)))[0] for v in values)
102
+ except (OverflowError, ValueError) as error:
103
+ raise UnsupportedOperation("local validity must contain finite binary values") from error
104
+ if any(v not in (0, 1) for v in values):
105
+ raise UnsupportedOperation("local validity must contain finite binary values")
106
+
107
+
108
+ def _validity_mask(validity: Raster | None, raster: Raster) -> ee.Image | None:
109
+ if validity is None:
110
+ return None
111
+ if not isinstance(validity, Raster):
112
+ raise TypeError("validity must be a Raster")
113
+ if validity.grid != raster.grid:
114
+ raise UnsupportedOperation("validity must use the identical output grid")
115
+ _band_names(validity.bands, 1, "validity bands")
116
+ _check_literal_validity(validity.image)
117
+ image = validity.image.select(list(validity.bands))
118
+ return image.eq(1).And(image.mask().gt(0)).unmask(0, sameFootprint=False)
119
+
120
+
121
+ def _masked_scores(raster: Raster, image: ee.Image, validity: Raster | None) -> Raster:
122
+ mask = _common_score_mask(image, raster.grid)
123
+ explicit = _validity_mask(validity, raster)
124
+ if explicit is not None:
125
+ mask = mask.And(explicit)
126
+ return Raster(image.updateMask(mask), raster.bands, raster.grid)
127
+
128
+
129
+ def segmentation_outputs(logits: Raster, *, logit_bands: Sequence[str] | None = None,
130
+ probability_bands: Sequence[str] | None = None,
131
+ class_band: str = "class_index",
132
+ validity: Raster | None = None,
133
+ policy: str = FLOAT32_OPERANDS) -> SegmentationOutputs:
134
+ """Retain raw logits and return float32 probabilities/int32 logit argmax.
135
+
136
+ Ordered channel indices define classes; exact logit ties choose the first.
137
+ Processed outputs share all score masks/finiteness, finite grid and optional
138
+ explicit validity. Raw logits retain their original values and masks.
139
+ """
140
+ validate_policy(policy, allowed=FLOAT32_OPERANDS)
141
+ image = _scores(logits)
142
+ count = len(logits.bands)
143
+ logit_names = logits.bands if logit_bands is None else _band_names(logit_bands, count, "logit_bands")
144
+ probability_names = (_band_names(probability_bands, count, "probability_bands") if probability_bands is not None
145
+ else tuple(f"probability_{i}" for i in range(count)))
146
+ class_name = _band_names((class_band,), 1, "class_band")
147
+ raw = logits if logit_bands is None else Raster(image.rename(list(logit_names)), logit_names, logits.grid)
148
+ masked = _masked_scores(logits, image, validity)
149
+ # The softmax block materialized as float32, as tensor_ee.softmax does.
150
+ probabilities = _float32(_softmax(masked))
151
+ probabilities = Raster(probabilities.image.rename(list(probability_names)), probability_names, logits.grid)
152
+ # Do not derive the winner from rounded probabilities: [0,1e-8] can round
153
+ # to [.5,.5]. arrayArgmax returns the first maximum of the ordered logits.
154
+ winner = masked.image.toArray().arrayArgmax().arrayGet([0]).toInt32()
155
+ classes = _finish(winner, class_name, logits.grid)
156
+ return SegmentationOutputs(raw, probabilities, classes)
157
+
158
+
159
+ def _threshold_option(threshold: float) -> float:
160
+ if isinstance(threshold, bool) or not isinstance(threshold, Real):
161
+ raise UnsupportedOperation("threshold must be a static finite real scalar")
162
+ if not 0 <= threshold <= 1:
163
+ raise UnsupportedOperation("threshold must satisfy 0 <= threshold <= 1")
164
+ number = _real_option(threshold, "threshold")
165
+ if threshold != 0 and number == 0:
166
+ raise UnsupportedOperation("threshold must be representable in binary64 without underflow to zero")
167
+ return number
168
+
169
+
170
+ def binary_threshold(probabilities: Raster, *, positive_class: int = 1,
171
+ threshold: float = 0.5, output_band: str = "class_mask",
172
+ validity: Raster | None = None,
173
+ policy: str = FLOAT32_OPERANDS) -> Raster:
174
+ """Inclusive uint8 threshold on one of exactly two normalized channels.
175
+
176
+ Widen float32 probabilities losslessly and retain the finite binary64
177
+ threshold, so probability .5 is below threshold .50000001. Invalid pixels
178
+ are excluded rather than filled with class zero. Caller supplies normalized
179
+ probabilities and asserts binary/finiteness for computed/remote validity.
180
+ """
181
+ validate_policy(policy, allowed=FLOAT32_OPERANDS)
182
+ image = _scores(probabilities, binary=True)
183
+ if isinstance(positive_class, bool) or not isinstance(positive_class, Integral) or positive_class not in (0, 1):
184
+ raise UnsupportedOperation("positive_class must be the static index 0 or 1")
185
+ value = _threshold_option(threshold)
186
+ name = _band_names((output_band,), 1, "output_band")
187
+ masked = _masked_scores(probabilities, image, validity)
188
+ decision = masked.image.select(probabilities.bands[int(positive_class)]).toDouble().gte(_ee_scalar(value)).toUint8()
189
+ return _finish(decision, name, probabilities.grid)
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # Locality and cost metadata (contract section 9)
194
+ # ---------------------------------------------------------------------------
195
+ METADATA = validate_metadata({
196
+ # Logits, float32 probabilities and an int32 class band, all on the input grid.
197
+ # Validated: any accepted call (segmentation output validation varied the validity input; band names are labels).
198
+ "segmentation_outputs": BlockMetadata(
199
+ name="segmentation_outputs", layout_in="R", layout_out="R", locality=Locality.POINTWISE,
200
+ policy=FLOAT32_OPERANDS, embedded_constants=_no_coefficients,
201
+ asset_arrays=_no_coefficients, status="Ready",
202
+ evidence=("docs/evidence/t03-outputs.json", "docs/evidence/t05-primary.json"),
203
+ validated=ANY_SETTINGS),
204
+ # Validated: any accepted call (segmentation output validation: thresholds 0, 0.5, 1 and 0.50000001, both classes).
205
+ "binary_threshold": BlockMetadata(
206
+ name="binary_threshold", layout_in="R", layout_out="R", locality=Locality.POINTWISE,
207
+ policy=FLOAT32_OPERANDS, embedded_constants=_no_coefficients,
208
+ asset_arrays=_no_coefficients, status="Ready",
209
+ evidence=("docs/evidence/t03-outputs.json",),
210
+ validated=ANY_SETTINGS),
211
+ })
@@ -0,0 +1,182 @@
1
+ """R pooling: PyTorch ``nn.AvgPool2d`` and ``nn.MaxPool2d`` at a small set of
2
+ common CNN configurations.
3
+
4
+ Moved whole from ``tensor_ee.compiler`` (``pool2d``, both branches) and
5
+ ``tensor_ee.semantics`` (``pool_options``). The old names are re-exports of
6
+ these objects. The raster fixtures added the configurations beyond the two the reference
7
+ models use (``pool_config``).
8
+
9
+ Supported settings (contract section 8), all with ``ceil_mode=False``,
10
+ max-pool ``dilation=1`` and ``return_indices=False``, and average-pool
11
+ ``divisor_override=None``:
12
+
13
+ - **aligned** average or max, kernel = stride = k, padding 0, for k in
14
+ (2, 3, 4), on input sizes divisible by k: ``reduceResolution`` onto the
15
+ k-times coarser grid;
16
+ - **max 3 x 3, padding 1, stride 1 or 2**: a 3 x 3 neighbourhood maximum on
17
+ the input grid (then stride sampling for stride 2). The input is masked
18
+ outside its grid and Earth Engine reducers skip masked pixels, so the padding
19
+ never wins: PyTorch's negative-infinity padding;
20
+ - **average 3 x 3, stride 1, padding 1**, under both PyTorch divisor rules:
21
+ ``count_include_pad=True`` divides the in-bounds sum by 9 (zero padding);
22
+ ``count_include_pad=False`` takes the mean of the in-bounds pixels only.
23
+
24
+ Every other configuration is rejected with the list above. Each supported
25
+ configuration has a raster-setting validation fixture (``raster/pool2d/*`` in
26
+ ``tools/fixtures/raster.py``) that compares it with PyTorch: bit for bit for
27
+ max pooling, within 1e-4 for averages.
28
+
29
+ Precision policy ``float32_operands`` (contract section 7). There are no
30
+ coefficients. The node output is materialized with ``.toFloat()`` by the caller
31
+ (``compile_model`` or the ``tensor_ee.primitives`` facade).
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from dataclasses import dataclass
37
+
38
+ import ee
39
+ from torch import nn
40
+
41
+ from ...core.errors import UnsupportedOperation
42
+ from ...core.metadata import BlockMetadata, Locality, Validated, validate_metadata
43
+ from ...core.precision import FLOAT32_OPERANDS, validate_policy
44
+ from ...core.tensor import Raster
45
+ from ...layouts.raster import finish as _finish, project as _project
46
+ from ...layouts.raster import stride_sample as _stride_sample
47
+ from ...core.errors import reject as _reject
48
+ from .conv import _no_coefficients, _pair
49
+
50
+ ALIGNED_KERNELS = (2, 3, 4)
51
+ """Kernel sizes k of the aligned k x k pools (stride k, padding 0)."""
52
+
53
+ SUPPORTED = ("aligned avg/max k x k with stride k and padding 0 for k in (2, 3, 4) on sizes divisible "
54
+ "by k; max 3x3 padding 1 with stride 1 or 2; avg 3x3 stride 1 padding 1")
55
+ """The supported configurations, as the rejection message names them."""
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class PoolConfig:
60
+ """One supported pooling configuration, normalized from the PyTorch module.
61
+
62
+ ``reducer`` is ``"max"`` or ``"mean"``; ``kernel``, ``stride`` and
63
+ ``padding`` are the equal per-axis values; ``count_include_pad`` is the
64
+ average pool's divisor rule (``None`` for max pooling).
65
+ """
66
+
67
+ reducer: str
68
+ kernel: int
69
+ stride: int
70
+ padding: int
71
+ count_include_pad: bool | None = None
72
+
73
+ @property
74
+ def aligned(self) -> bool:
75
+ """An aligned k x k pool: kernel = stride, no padding."""
76
+ return self.padding == 0
77
+
78
+ def output_size(self, size: int) -> int:
79
+ """PyTorch's output size on one axis (``ceil_mode=False``)."""
80
+ return (size + 2 * self.padding - self.kernel) // self.stride + 1
81
+
82
+
83
+ def aligned_size_message(kernel: int) -> str:
84
+ """The rejection of an input size that an aligned k x k pool does not divide."""
85
+ if kernel == 2:
86
+ return "This experiment requires even spatial dimensions before pooling"
87
+ return f"Aligned {kernel}x{kernel} pooling requires spatial dimensions divisible by {kernel}"
88
+
89
+
90
+ def pool_config(module: nn.AvgPool2d | nn.MaxPool2d) -> PoolConfig:
91
+ """Validate the module's options and return its supported configuration."""
92
+ stride = module.stride if module.stride is not None else module.kernel_size
93
+ if module.ceil_mode is not False:
94
+ _reject("Pooling requires ceil_mode=False")
95
+ if isinstance(module, nn.MaxPool2d):
96
+ if _pair(module.dilation) != (1, 1) or module.return_indices is not False:
97
+ _reject("MaxPool2d requires dilation=1 and return_indices=False")
98
+ reducer, include = "max", None
99
+ elif module.divisor_override is not None or not isinstance(module.count_include_pad, bool):
100
+ _reject("AvgPool2d requires divisor_override=None and static count_include_pad")
101
+ else:
102
+ reducer, include = "mean", module.count_include_pad
103
+ kernel, strides, padding = _pair(module.kernel_size), _pair(stride), _pair(module.padding)
104
+ for k in ALIGNED_KERNELS:
105
+ if kernel == (k, k) and strides == (k, k) and padding == (0, 0):
106
+ return PoolConfig(reducer, k, k, 0, include)
107
+ if kernel == (3, 3) and padding == (1, 1):
108
+ if strides == (1, 1) or (strides == (2, 2) and reducer == "max"):
109
+ return PoolConfig(reducer, 3, int(strides[0]), 1, include)
110
+ _reject(f"Pooling supports {SUPPORTED}, without ceil_mode; received {type(module).__name__}"
111
+ f"(kernel_size={module.kernel_size!r}, stride={stride!r}, padding={module.padding!r})")
112
+
113
+
114
+ def pool_options(module: nn.AvgPool2d | nn.MaxPool2d) -> bool:
115
+ """Validate the module and return whether it pools a padded 3x3 neighbourhood
116
+ (``pool_config(module).padding``), not an aligned k x k block."""
117
+ return not pool_config(module).aligned
118
+
119
+
120
+ def pool2d(raster: Raster, module: nn.AvgPool2d | nn.MaxPool2d,
121
+ *, clip_footprints: bool = False, policy: str = FLOAT32_OPERANDS) -> Raster:
122
+ """PyTorch average or max pooling at a supported configuration (``pool_config``)."""
123
+ validate_policy(policy, allowed=FLOAT32_OPERANDS)
124
+ config = pool_config(module)
125
+ if not config.aligned:
126
+ kernel = ee.Kernel.square(1, "pixels", False)
127
+ if config.reducer == "max":
128
+ # Masked padding contributes no value to max, just as -infinity.
129
+ result = raster.image.reduceNeighborhood(ee.Reducer.max(), kernel, skipMasked=False)
130
+ elif config.count_include_pad:
131
+ # count_include_pad=True: the in-bounds sum over the full window of 9
132
+ # (PyTorch's zero padding counts in the divisor).
133
+ result = raster.image.reduceNeighborhood(ee.Reducer.sum(), kernel, skipMasked=False).divide(9)
134
+ else:
135
+ # count_include_pad=False: the mean of the in-bounds pixels only.
136
+ result = raster.image.reduceNeighborhood(ee.Reducer.mean(), kernel, skipMasked=False)
137
+ if config.stride == 1:
138
+ # The output is on the input grid; _stride_sample(..., 1) is _finish,
139
+ # which pins it there (as conv2d does at stride 1).
140
+ return _stride_sample(result, raster.bands, raster.grid, 1, clip_footprints)
141
+ result = _project(result, raster.grid, clip_footprints)
142
+ return _stride_sample(result, raster.bands, raster.grid, config.stride, clip_footprints)
143
+ k = config.kernel
144
+ reducer = ee.Reducer.max() if config.reducer == "max" else ee.Reducer.mean()
145
+ if raster.grid.width % k or raster.grid.height % k:
146
+ raise UnsupportedOperation(aligned_size_message(k))
147
+ grid = raster.grid.resized(k, raster.grid.width // k, raster.grid.height // k)
148
+ # maxPixels (k + 2)^2 keeps today's 16 at k = 2: the k x k block plus a
149
+ # one-pixel ring. It bounds the input pixels per output pixel; with
150
+ # bestEffort=False a larger need fails the request rather than changing values.
151
+ result = raster.image.reduceResolution(reducer, bestEffort=False, maxPixels=(k + 2) ** 2)
152
+ result = _project(result, grid, clip_footprints)
153
+ return _finish(result, raster.bands, grid, clip_footprints)
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # Locality and cost metadata (contract section 9)
158
+ # ---------------------------------------------------------------------------
159
+ METADATA = validate_metadata({
160
+ # receptive_field._window_axis: output pixel i of an aligned k x k pool reads
161
+ # input pixels [k i, k i + k - 1], and a padded 3x3 pool with stride s reads
162
+ # [s i - 1, s i + 1]. The radius bounds the offset from s i: k - 1 = 3 at the
163
+ # largest aligned kernel, 4; 1 for the padded pools.
164
+ # Validated, in PoolConfig's terms (``pool_config(module)``; "mean" is AvgPool2d): the
165
+ # status's three families. count_include_pad does not change an unpadded pool, so the
166
+ # aligned pools do not name it.
167
+ "pool2d": BlockMetadata(
168
+ name="pool2d", layout_in="R", layout_out="R", locality=Locality.KERNEL,
169
+ policy=FLOAT32_OPERANDS, embedded_constants=_no_coefficients,
170
+ asset_arrays=_no_coefficients, radius=3,
171
+ status=("Ready — aligned avg/max k = 2, 3, 4; max 3×3 stride 1 or 2, padding 1; "
172
+ "avg 3×3 stride 1 padding 1, both divisor rules"),
173
+ evidence=("docs/evidence/live-fixtures/20261013T065257Z-p02.json", "docs/evidence/p02-raster-offline.json",
174
+ "docs/history/cnn/results/parity.json", "docs/evidence/t05-primary.json",
175
+ "docs/history/cnn/results/resnet_primitives.json", "docs/history/cnn/results/resnet18_full64_float32_policy.json",
176
+ "docs/history/cnn/results/segwater_js_parity_1x1.json"),
177
+ validated=Validated((
178
+ *({"reducer": ("mean", "max"), "kernel": (k,), "stride": (k,), "padding": (0,)} for k in ALIGNED_KERNELS),
179
+ {"reducer": ("max",), "kernel": (3,), "stride": (1, 2), "padding": (1,)},
180
+ {"reducer": ("mean",), "kernel": (3,), "stride": (1,), "padding": (1,), "count_include_pad": (True, False)},
181
+ ), sources=(PoolConfig,))),
182
+ })