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.
- tensor_ee/__init__.py +28 -0
- tensor_ee/_swin_parameters.py +304 -0
- tensor_ee/blocks/__init__.py +0 -0
- tensor_ee/blocks/raster/__init__.py +36 -0
- tensor_ee/blocks/raster/activations.py +183 -0
- tensor_ee/blocks/raster/conv.py +183 -0
- tensor_ee/blocks/raster/merge.py +66 -0
- tensor_ee/blocks/raster/outputs.py +211 -0
- tensor_ee/blocks/raster/pooling.py +182 -0
- tensor_ee/blocks/raster/resample.py +64 -0
- tensor_ee/blocks/window/__init__.py +44 -0
- tensor_ee/blocks/window/activations.py +54 -0
- tensor_ee/blocks/window/attention.py +366 -0
- tensor_ee/blocks/window/conv.py +323 -0
- tensor_ee/blocks/window/linear.py +129 -0
- tensor_ee/blocks/window/merge.py +67 -0
- tensor_ee/blocks/window/normalization.py +197 -0
- tensor_ee/blocks/window/patch.py +362 -0
- tensor_ee/blocks/window/pooling.py +275 -0
- tensor_ee/blocks/window/resample.py +286 -0
- tensor_ee/blocks/window/segformer.py +718 -0
- tensor_ee/blocks/window/swin.py +602 -0
- tensor_ee/blocks/window/swin_upernet_config.py +361 -0
- tensor_ee/blocks/window/upernet.py +1016 -0
- tensor_ee/comparison.py +213 -0
- tensor_ee/compatibility.py +180 -0
- tensor_ee/compiler.py +356 -0
- tensor_ee/converter/__init__.py +72 -0
- tensor_ee/converter/build.py +120 -0
- tensor_ee/converter/convert.py +388 -0
- tensor_ee/converter/export.py +846 -0
- tensor_ee/converter/graph.py +417 -0
- tensor_ee/converter/operations.py +790 -0
- tensor_ee/converter/plan.py +675 -0
- tensor_ee/converter/recognizers.py +1273 -0
- tensor_ee/converter/request.py +146 -0
- tensor_ee/converter/run.py +950 -0
- tensor_ee/core/__init__.py +8 -0
- tensor_ee/core/errors.py +25 -0
- tensor_ee/core/grid.py +323 -0
- tensor_ee/core/metadata.py +398 -0
- tensor_ee/core/ops.py +336 -0
- tensor_ee/core/precision.py +133 -0
- tensor_ee/core/tensor.py +129 -0
- tensor_ee/examples/__init__.py +1 -0
- tensor_ee/examples/nonwater.py +251 -0
- tensor_ee/layouts/__init__.py +17 -0
- tensor_ee/layouts/boundaries.py +103 -0
- tensor_ee/layouts/raster.py +117 -0
- tensor_ee/layouts/shift.py +226 -0
- tensor_ee/layouts/window.py +201 -0
- tensor_ee/models/__init__.py +16 -0
- tensor_ee/models/fixtures.py +119 -0
- tensor_ee/models/segformer_b0/__init__.py +9 -0
- tensor_ee/models/segformer_b0/configuration.py +209 -0
- tensor_ee/models/segformer_b0/decoder.py +130 -0
- tensor_ee/models/segformer_b0/encoder.py +90 -0
- tensor_ee/models/swin_t224/__init__.py +30 -0
- tensor_ee/models/swin_t224/configuration.py +103 -0
- tensor_ee/models/swin_t224/decoder.py +53 -0
- tensor_ee/models/swin_t224/encoder.py +50 -0
- tensor_ee/onnx_import.py +431 -0
- tensor_ee/outputs.py +16 -0
- tensor_ee/primitives.py +110 -0
- tensor_ee/receptive_field.py +257 -0
- tensor_ee/resnet_models.py +185 -0
- tensor_ee/segwater_inference.py +235 -0
- tensor_ee/segwater_preprocessing.py +182 -0
- tensor_ee/segwater_pytorch.py +249 -0
- tensor_ee/semantics.py +389 -0
- tensor_ee/smp.py +155 -0
- tensor_ee/swin.py +197 -0
- tensor_ee/swin_workflow.py +470 -0
- tensor_ee/tiling.py +287 -0
- tensor_ee/weight_assets.py +371 -0
- tensor_ee/weight_mapping.py +51 -0
- tensor_ee/weights/__init__.py +14 -0
- tensor_ee/weights/assets.py +160 -0
- tensor_ee/weights/constants.py +156 -0
- tensor_ee-0.1.0.dist-info/METADATA +172 -0
- tensor_ee-0.1.0.dist-info/RECORD +84 -0
- tensor_ee-0.1.0.dist-info/WHEEL +5 -0
- tensor_ee-0.1.0.dist-info/licenses/LICENSE +21 -0
- tensor_ee-0.1.0.dist-info/top_level.txt +1 -0
tensor_ee/tiling.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""TensorEE inference on aligned context windows with disjoint output cores.
|
|
2
|
+
|
|
3
|
+
The source is one *finite* global tensor. Each tile is compiled on its larger
|
|
4
|
+
context window, with the window affine translated from the same global grid.
|
|
5
|
+
Context is clamped at the true source boundary, so every intermediate tensor
|
|
6
|
+
has the same finite-padding boundary there as a full-image forward pass. Only
|
|
7
|
+
artificial interior context edges are protected by the caller-supplied halo.
|
|
8
|
+
|
|
9
|
+
The halo must cover the model's exact receptive support. Alignment must cover
|
|
10
|
+
the deepest striding/pooling lattice. These requirements cannot be inferred
|
|
11
|
+
from a random fixture's numerically small distant effects.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Sequence
|
|
18
|
+
|
|
19
|
+
import ee
|
|
20
|
+
from torch import nn
|
|
21
|
+
|
|
22
|
+
from tensor_ee.compiler import (CompiledModel, Grid, Raster, UnsupportedOperation,
|
|
23
|
+
WeightProvider, compile_model)
|
|
24
|
+
from tensor_ee.receptive_field import ReceptiveFieldReport, analyze_receptive_field
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class PixelWindow:
|
|
29
|
+
"""A half-open rectangle in integer coordinates of the global input grid."""
|
|
30
|
+
|
|
31
|
+
x: int
|
|
32
|
+
y: int
|
|
33
|
+
width: int
|
|
34
|
+
height: int
|
|
35
|
+
|
|
36
|
+
def __post_init__(self) -> None:
|
|
37
|
+
if any(type(value) is not int for value in (self.x, self.y, self.width, self.height)):
|
|
38
|
+
raise ValueError("PixelWindow coordinates and dimensions must be integers")
|
|
39
|
+
if self.x < 0 or self.y < 0 or self.width <= 0 or self.height <= 0:
|
|
40
|
+
raise ValueError("PixelWindow requires nonnegative origins and positive dimensions")
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def right(self) -> int:
|
|
44
|
+
return self.x + self.width
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def bottom(self) -> int:
|
|
48
|
+
return self.y + self.height
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def pixels(self) -> int:
|
|
52
|
+
return self.width * self.height
|
|
53
|
+
|
|
54
|
+
def grid(self, global_grid: Grid) -> Grid:
|
|
55
|
+
if self.right > global_grid.width or self.bottom > global_grid.height:
|
|
56
|
+
raise ValueError("PixelWindow is outside the global finite input")
|
|
57
|
+
a, b, c, d, e, f = global_grid.transform
|
|
58
|
+
return Grid(global_grid.crs,
|
|
59
|
+
(a, b, c + a * self.x, d, e, f + e * self.y),
|
|
60
|
+
self.width, self.height)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class Tile:
|
|
65
|
+
"""One output core and its larger finite context, in global pixel indices."""
|
|
66
|
+
|
|
67
|
+
index: int
|
|
68
|
+
row: int
|
|
69
|
+
column: int
|
|
70
|
+
core: PixelWindow
|
|
71
|
+
context: PixelWindow
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class TilePlan:
|
|
76
|
+
global_grid: Grid
|
|
77
|
+
roi: PixelWindow
|
|
78
|
+
core_size: tuple[int, int]
|
|
79
|
+
halo: tuple[int, int]
|
|
80
|
+
alignment: int
|
|
81
|
+
tiles: tuple[Tile, ...]
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def roi_grid(self) -> Grid:
|
|
85
|
+
return self.roi.grid(self.global_grid)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def vertical_seams(self) -> tuple[int, ...]:
|
|
89
|
+
return tuple(sorted({tile.core.right for tile in self.tiles
|
|
90
|
+
if tile.core.right < self.roi.right}))
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def horizontal_seams(self) -> tuple[int, ...]:
|
|
94
|
+
return tuple(sorted({tile.core.bottom for tile in self.tiles
|
|
95
|
+
if tile.core.bottom < self.roi.bottom}))
|
|
96
|
+
|
|
97
|
+
def seam_pixel_count(self, width: int = 1) -> int:
|
|
98
|
+
"""Count pixels within ``width`` pixels on each side of internal seams."""
|
|
99
|
+
_positive_integer(width, "seam width")
|
|
100
|
+
|
|
101
|
+
def stripe_pixels(seams: tuple[int, ...], start: int, end: int) -> int:
|
|
102
|
+
intervals = sorted((max(start, seam - width), min(end, seam + width))
|
|
103
|
+
for seam in seams)
|
|
104
|
+
total, previous_end = 0, start
|
|
105
|
+
for left, right in intervals:
|
|
106
|
+
total += max(0, right - max(left, previous_end))
|
|
107
|
+
previous_end = max(previous_end, right)
|
|
108
|
+
return total
|
|
109
|
+
|
|
110
|
+
x_pixels = stripe_pixels(self.vertical_seams, self.roi.x, self.roi.right)
|
|
111
|
+
y_pixels = stripe_pixels(self.horizontal_seams, self.roi.y, self.roi.bottom)
|
|
112
|
+
return (x_pixels * self.roi.height + y_pixels * self.roi.width
|
|
113
|
+
- x_pixels * y_pixels)
|
|
114
|
+
|
|
115
|
+
def metadata(self) -> dict:
|
|
116
|
+
def rectangle(window: PixelWindow) -> dict:
|
|
117
|
+
return {"x": window.x, "y": window.y,
|
|
118
|
+
"width": window.width, "height": window.height}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
"global_dimensions": [self.global_grid.width, self.global_grid.height],
|
|
122
|
+
"global_transform": list(self.global_grid.transform),
|
|
123
|
+
"roi": rectangle(self.roi), "core_size": list(self.core_size),
|
|
124
|
+
"halo": list(self.halo), "alignment": self.alignment,
|
|
125
|
+
"tile_count": len(self.tiles), "core_pixel_count": self.roi.pixels,
|
|
126
|
+
"context_pixel_count_sum": sum(tile.context.pixels for tile in self.tiles),
|
|
127
|
+
"vertical_seams": list(self.vertical_seams),
|
|
128
|
+
"horizontal_seams": list(self.horizontal_seams),
|
|
129
|
+
"tiles": [{"index": tile.index, "row": tile.row, "column": tile.column,
|
|
130
|
+
"core": rectangle(tile.core), "context": rectangle(tile.context),
|
|
131
|
+
"context_transform": list(tile.context.grid(self.global_grid).transform)}
|
|
132
|
+
for tile in self.tiles],
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _positive_integer(value: int, name: str) -> None:
|
|
137
|
+
if type(value) is not int or value <= 0:
|
|
138
|
+
raise ValueError(f"{name} must be a positive integer")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _pair(value: int | tuple[int, int], name: str, *, allow_zero: bool = False) -> tuple[int, int]:
|
|
142
|
+
try:
|
|
143
|
+
values = (value, value) if type(value) is int else tuple(value)
|
|
144
|
+
except TypeError as error:
|
|
145
|
+
raise ValueError(f"{name} must be an integer or an integer pair") from error
|
|
146
|
+
if len(values) != 2 or any(type(item) is not int or item < (0 if allow_zero else 1)
|
|
147
|
+
for item in values):
|
|
148
|
+
qualifier = "nonnegative" if allow_zero else "positive"
|
|
149
|
+
raise ValueError(f"{name} must contain two {qualifier} integers")
|
|
150
|
+
return values
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def plan_tiles(global_grid: Grid, core_size: int | tuple[int, int],
|
|
154
|
+
halo: int | tuple[int, int], *, alignment: int,
|
|
155
|
+
roi: PixelWindow | None = None) -> TilePlan:
|
|
156
|
+
"""Partition a finite aligned ROI; clamp contexts to real global boundaries.
|
|
157
|
+
|
|
158
|
+
Core sizes are (width, height), halo is (horizontal, vertical). All origins,
|
|
159
|
+
dimensions, and halo widths must be multiples of ``alignment``. A final
|
|
160
|
+
core may be shorter than ``core_size`` while remaining aligned. The caller
|
|
161
|
+
must supply a halo at least as large as the model's exact required support.
|
|
162
|
+
"""
|
|
163
|
+
_positive_integer(alignment, "alignment")
|
|
164
|
+
core = _pair(core_size, "core_size")
|
|
165
|
+
margin = _pair(halo, "halo", allow_zero=True)
|
|
166
|
+
region = roi or PixelWindow(0, 0, global_grid.width, global_grid.height)
|
|
167
|
+
region.grid(global_grid) # Validate actual finite domain before planning.
|
|
168
|
+
aligned = {"global width": global_grid.width, "global height": global_grid.height,
|
|
169
|
+
"ROI x": region.x, "ROI y": region.y, "ROI width": region.width,
|
|
170
|
+
"ROI height": region.height, "core width": core[0], "core height": core[1],
|
|
171
|
+
"horizontal halo": margin[0], "vertical halo": margin[1]}
|
|
172
|
+
for name, value in aligned.items():
|
|
173
|
+
if value % alignment:
|
|
174
|
+
raise ValueError(f"{name} must be a multiple of alignment {alignment}")
|
|
175
|
+
tiles = []
|
|
176
|
+
for row, y in enumerate(range(region.y, region.bottom, core[1])):
|
|
177
|
+
for column, x in enumerate(range(region.x, region.right, core[0])):
|
|
178
|
+
output = PixelWindow(x, y, min(core[0], region.right - x),
|
|
179
|
+
min(core[1], region.bottom - y))
|
|
180
|
+
left, top = max(0, x - margin[0]), max(0, y - margin[1])
|
|
181
|
+
right = min(global_grid.width, output.right + margin[0])
|
|
182
|
+
bottom = min(global_grid.height, output.bottom + margin[1])
|
|
183
|
+
context = PixelWindow(left, top, right - left, bottom - top)
|
|
184
|
+
tiles.append(Tile(len(tiles), row, column, output, context))
|
|
185
|
+
return TilePlan(global_grid, region, core, margin, alignment, tuple(tiles))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def seam_mask(plan: TilePlan, width: int = 1) -> ee.Image:
|
|
189
|
+
"""Mask an exact union of bands around internal core boundaries."""
|
|
190
|
+
_positive_integer(width, "seam width")
|
|
191
|
+
coordinates = ee.Image.pixelCoordinates(plan.global_grid.projection())
|
|
192
|
+
x, y = coordinates.select("x"), coordinates.select("y")
|
|
193
|
+
mask = ee.Image.constant(0).reproject(plan.global_grid.projection())
|
|
194
|
+
for seam in plan.vertical_seams:
|
|
195
|
+
mask = mask.Or(x.gte(seam - width).And(x.lt(seam + width)))
|
|
196
|
+
for seam in plan.horizontal_seams:
|
|
197
|
+
mask = mask.Or(y.gte(seam - width).And(y.lt(seam + width)))
|
|
198
|
+
return mask.And(plan.roi_grid.mask()).rename("seam")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass(frozen=True)
|
|
202
|
+
class CompiledTiledModel(Raster):
|
|
203
|
+
plan: TilePlan | None = None
|
|
204
|
+
tiles: tuple[CompiledModel, ...] = ()
|
|
205
|
+
nodes: dict[str, Raster] = field(default_factory=dict)
|
|
206
|
+
coverage: ee.Image | None = None
|
|
207
|
+
planned_coverage: ee.Image | None = None
|
|
208
|
+
receptive_field: ReceptiveFieldReport | None = None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def compile_tiled_model(model: nn.Module, image: ee.Image,
|
|
212
|
+
input_bands: Sequence[str], plan: TilePlan, *,
|
|
213
|
+
weight_provider: WeightProvider | None = None,
|
|
214
|
+
output_nodes: Sequence[str] = ()) -> CompiledTiledModel:
|
|
215
|
+
"""Compile every context in EE, retain disjoint cores, and mosaic in EE.
|
|
216
|
+
|
|
217
|
+
One shared weight provider serves all tiles, so an asset provider can reuse
|
|
218
|
+
its arrays. Selected diagnostic output nodes must have input resolution.
|
|
219
|
+
The returned grid is the *global* grid; its image is masked to the ROI.
|
|
220
|
+
Every compiled intermediate uses float32 and clipped finite footprints.
|
|
221
|
+
"""
|
|
222
|
+
if not plan.tiles:
|
|
223
|
+
raise ValueError("TilePlan must contain at least one core")
|
|
224
|
+
support = analyze_receptive_field(model)
|
|
225
|
+
if plan.alignment % support.alignment:
|
|
226
|
+
raise ValueError(f"Tile alignment must be a multiple of model alignment {support.alignment}")
|
|
227
|
+
for tile in plan.tiles:
|
|
228
|
+
context, core = tile.context, tile.core
|
|
229
|
+
for value in (core.x, core.y, core.width, core.height,
|
|
230
|
+
context.x, context.y, context.width, context.height):
|
|
231
|
+
if value % support.alignment:
|
|
232
|
+
raise ValueError(f"Tile origins and dimensions require model alignment {support.alignment}")
|
|
233
|
+
artificial_margins = []
|
|
234
|
+
if context.x > 0:
|
|
235
|
+
artificial_margins.append(core.x - context.x)
|
|
236
|
+
if context.y > 0:
|
|
237
|
+
artificial_margins.append(core.y - context.y)
|
|
238
|
+
if context.right < plan.global_grid.width:
|
|
239
|
+
artificial_margins.append(context.right - core.right)
|
|
240
|
+
if context.bottom < plan.global_grid.height:
|
|
241
|
+
artificial_margins.append(context.bottom - core.bottom)
|
|
242
|
+
if any(margin < support.safe_halo for margin in artificial_margins):
|
|
243
|
+
raise ValueError(f"Artificial context boundaries require halo at least {support.safe_halo}")
|
|
244
|
+
compiled_tiles = tuple(compile_model(
|
|
245
|
+
model, image, input_bands, tile.context.grid(plan.global_grid),
|
|
246
|
+
weight_provider=weight_provider, include_unfused_nodes=False,
|
|
247
|
+
execution_dtype="float32", clip_footprints=True)
|
|
248
|
+
for tile in plan.tiles)
|
|
249
|
+
|
|
250
|
+
def core_images(rasters: Sequence[Raster]) -> list[ee.Image]:
|
|
251
|
+
bands = rasters[0].bands
|
|
252
|
+
pieces = []
|
|
253
|
+
for tile, raster in zip(plan.tiles, rasters, strict=True):
|
|
254
|
+
if raster.grid != tile.context.grid(plan.global_grid):
|
|
255
|
+
raise UnsupportedOperation("Tiled output must preserve the input spatial grid")
|
|
256
|
+
if raster.bands != bands:
|
|
257
|
+
raise UnsupportedOperation("All tiled outputs must have identical bands")
|
|
258
|
+
core_grid = tile.core.grid(plan.global_grid)
|
|
259
|
+
pieces.append(raster.image.select(list(bands)).clip(core_grid.geometry())
|
|
260
|
+
.updateMask(core_grid.mask()))
|
|
261
|
+
return pieces
|
|
262
|
+
|
|
263
|
+
def mosaic(rasters: Sequence[Raster]) -> Raster:
|
|
264
|
+
result = (ee.ImageCollection.fromImages(core_images(rasters)).mosaic().rename(list(rasters[0].bands))
|
|
265
|
+
.setDefaultProjection(plan.global_grid.projection())
|
|
266
|
+
.updateMask(plan.roi_grid.mask()).toFloat())
|
|
267
|
+
return Raster(result, rasters[0].bands, plan.global_grid)
|
|
268
|
+
|
|
269
|
+
output = mosaic(compiled_tiles)
|
|
270
|
+
nodes = {}
|
|
271
|
+
for name in output_nodes:
|
|
272
|
+
if any(name not in tile.nodes for tile in compiled_tiles):
|
|
273
|
+
raise ValueError(f"Requested tiled output node {name!r} is absent")
|
|
274
|
+
nodes[name] = mosaic([tile.nodes[name] for tile in compiled_tiles])
|
|
275
|
+
masks = [ee.Image.constant(1).rename("tile_coverage")
|
|
276
|
+
.reproject(plan.global_grid.projection())
|
|
277
|
+
.updateMask(tile.core.grid(plan.global_grid).mask()) for tile in plan.tiles]
|
|
278
|
+
planned_coverage = (ee.ImageCollection.fromImages(masks).sum().rename("planned_tile_coverage")
|
|
279
|
+
.setDefaultProjection(plan.global_grid.projection())
|
|
280
|
+
.updateMask(plan.roi_grid.mask()))
|
|
281
|
+
actual_masks = [piece.mask().reduce(ee.Reducer.min()).unmask(0, sameFootprint=False)
|
|
282
|
+
.rename("tile_coverage").reproject(plan.global_grid.projection())
|
|
283
|
+
for piece in core_images(compiled_tiles)]
|
|
284
|
+
coverage = (ee.ImageCollection.fromImages(actual_masks).sum().rename("tile_coverage")
|
|
285
|
+
.setDefaultProjection(plan.global_grid.projection()).updateMask(plan.roi_grid.mask()))
|
|
286
|
+
return CompiledTiledModel(output.image, output.bands, output.grid,
|
|
287
|
+
plan, compiled_tiles, nodes, coverage, planned_coverage, support)
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Store arbitrary frozen convolution tensors in Earth Engine table assets.
|
|
2
|
+
|
|
3
|
+
Each feature has a harmless point geometry and properties ``layer``, ``row``,
|
|
4
|
+
``weights_json`` (one JSON string of a numeric chunk), and, when present, ``bias``. EE table exports
|
|
5
|
+
reject null geometries and list-valued properties. Chunks are sorted by an explicit integer index;
|
|
6
|
+
no inference weight is downloaded or embedded in an asset-backed graph.
|
|
7
|
+
|
|
8
|
+
Uploads are independently measured, bounded request graphs. Nine significant
|
|
9
|
+
figures preserve every finite float32 value on restoration through
|
|
10
|
+
``ee.Array.float()``. Float32 is the maximum supported model precision.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import contextlib
|
|
15
|
+
import json
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
import time
|
|
18
|
+
from typing import Any, Callable, Iterator, Mapping, Sequence
|
|
19
|
+
|
|
20
|
+
import ee
|
|
21
|
+
import numpy as np
|
|
22
|
+
import torch
|
|
23
|
+
from torch import nn
|
|
24
|
+
from torch.fx import Node, symbolic_trace
|
|
25
|
+
|
|
26
|
+
from .compiler import UnsupportedOperation, _ROOT_MODULES
|
|
27
|
+
from .semantics import semantic_call, diagnostic_convolution
|
|
28
|
+
# The asset format and its provider moved to the library; these names are
|
|
29
|
+
# re-exports of the same objects (one FeatureCollectionWeightProvider class).
|
|
30
|
+
from .weights.assets import ( # noqa: F401
|
|
31
|
+
ConvolutionWeights, FeatureCollectionWeightProvider, convolution_weights, feature_records)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def effective_convolutions(model: nn.Module) -> list[ConvolutionWeights]:
|
|
35
|
+
"""Collect exactly the final graph's conv tensors, including BN folding.
|
|
36
|
+
|
|
37
|
+
Names follow FX node keys, as ``compile_model`` does. A convolution with a
|
|
38
|
+
sole BatchNorm consumer is replaced by its folded convolution, keyed by the
|
|
39
|
+
BN node. Original conv diagnostics are deliberately absent from the asset.
|
|
40
|
+
"""
|
|
41
|
+
if any(module.training for module in model.modules()):
|
|
42
|
+
raise ValueError("Set model.eval() before collecting inference weights")
|
|
43
|
+
if isinstance(model, _ROOT_MODULES):
|
|
44
|
+
model = nn.Sequential(model)
|
|
45
|
+
traced = symbolic_trace(model)
|
|
46
|
+
result = []
|
|
47
|
+
for node in traced.graph.nodes:
|
|
48
|
+
if node.op in ("placeholder", "output"):
|
|
49
|
+
continue
|
|
50
|
+
try:
|
|
51
|
+
call = semantic_call(node, traced)
|
|
52
|
+
except UnsupportedOperation as error:
|
|
53
|
+
raise UnsupportedOperation(f"At FX node {node.name} ({node.target}): {error}") from error
|
|
54
|
+
if call.kind == "conv" and not diagnostic_convolution(node, traced):
|
|
55
|
+
result.append(convolution_weights(node.name, call.module, str(node.target)))
|
|
56
|
+
elif call.kind == "batchnorm":
|
|
57
|
+
source = str(call.options["predecessor"].target) + "+" + str(node.target)
|
|
58
|
+
result.append(convolution_weights(node.name, call.module, source))
|
|
59
|
+
if not result:
|
|
60
|
+
raise ValueError("Model contains no deployable convolutions")
|
|
61
|
+
return result
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def float32_wire_values(values: np.ndarray) -> list[float]:
|
|
65
|
+
"""Short decimal values which round back to the exact source float32s."""
|
|
66
|
+
return [float(format(float(value), ".9g")) for value in values]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def collection_from_records(records: Sequence[Mapping[str, Any]]) -> ee.FeatureCollection:
|
|
70
|
+
return ee.FeatureCollection([ee.Feature(dict(record)) for record in records])
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _split_measured(records: list[dict[str, Any]], max_bytes: int) -> Iterator[tuple[list[dict[str, Any]], int]]:
|
|
74
|
+
actual = len(ee.serializer.toJSON(collection_from_records(records)).encode("utf-8"))
|
|
75
|
+
if actual <= max_bytes:
|
|
76
|
+
yield records, actual
|
|
77
|
+
elif len(records) == 1:
|
|
78
|
+
raise ValueError(f"One row requires {actual} serialized bytes, exceeding {max_bytes}")
|
|
79
|
+
else:
|
|
80
|
+
center = len(records) // 2
|
|
81
|
+
yield from _split_measured(records[:center], max_bytes)
|
|
82
|
+
yield from _split_measured(records[center:], max_bytes)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def upload_chunks(parameters: Sequence[ConvolutionWeights], max_bytes: int = 8_000_000
|
|
86
|
+
) -> Iterator[tuple[list[dict[str, Any]], int]]:
|
|
87
|
+
"""Pack rows conservatively, then verify each actual EE graph byte count."""
|
|
88
|
+
if not 100_000 <= max_bytes <= 8_000_000:
|
|
89
|
+
raise ValueError("max_bytes must be between 100,000 and 8,000,000")
|
|
90
|
+
records = []
|
|
91
|
+
rough_bytes = 0
|
|
92
|
+
# A literal FC is converted into API feature expressions. Reserving 10%
|
|
93
|
+
# and 32 KB covers that overhead; the exact serializer check is decisive.
|
|
94
|
+
target = int(max_bytes * .9) - 32_000
|
|
95
|
+
for record in feature_records(parameters):
|
|
96
|
+
size = len(json.dumps(record, separators=(",", ":")).encode()) + 512
|
|
97
|
+
if records and rough_bytes + size > target:
|
|
98
|
+
yield from _split_measured(records, max_bytes)
|
|
99
|
+
records, rough_bytes = [], 0
|
|
100
|
+
records.append(record)
|
|
101
|
+
rough_bytes += size
|
|
102
|
+
if records:
|
|
103
|
+
yield from _split_measured(records, max_bytes)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _write_manifest(path: Path, manifest: dict[str, Any]) -> None:
|
|
107
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
temporary = path.with_suffix(path.suffix + ".tmp")
|
|
109
|
+
temporary.write_text(json.dumps(manifest, indent=2, allow_nan=False) + "\n")
|
|
110
|
+
temporary.replace(path)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class _RecordedTask:
|
|
114
|
+
"""A task an earlier run started, known only by the ID its manifest recorded: polled
|
|
115
|
+
with ``ee.data.getTaskStatus``."""
|
|
116
|
+
|
|
117
|
+
def __init__(self, task_id: str):
|
|
118
|
+
self.id = task_id
|
|
119
|
+
|
|
120
|
+
def status(self) -> dict[str, Any]:
|
|
121
|
+
return ee.data.getTaskStatus([self.id])[0]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _layer_metadata(manifest: Mapping[str, Any]) -> dict[str, Any]:
|
|
125
|
+
return {key: {k: v for k, v in meta.items() if k != "assets"} for key, meta in manifest["layers"].items()}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _asset_exists(asset_id: str) -> bool:
|
|
129
|
+
try:
|
|
130
|
+
ee.data.getAsset(asset_id)
|
|
131
|
+
except ee.EEException:
|
|
132
|
+
return False
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _resume(fresh: dict[str, Any], manifest_path: Path, iterator) -> tuple[dict[str, Any], list, list]:
|
|
137
|
+
"""The manifest an interrupted ``export_weights`` left, reconciled for these parameters:
|
|
138
|
+
(manifest, tasks to poll, shards to dispatch again). Refused when it belongs to other
|
|
139
|
+
parameters or another chunking, or when a shard FAILED or was CANCELLED."""
|
|
140
|
+
manifest = json.loads(Path(manifest_path).read_text())
|
|
141
|
+
for name in ("format", "asset_namespace", "max_collection_payload_bytes", "parameter_count"):
|
|
142
|
+
if manifest.get(name) != fresh[name]:
|
|
143
|
+
raise ValueError(f"the manifest's {name} differs: it is not an upload of these parameters")
|
|
144
|
+
if _layer_metadata(manifest) != _layer_metadata(fresh):
|
|
145
|
+
raise ValueError("the manifest's layers differ: it is not an upload of these parameters")
|
|
146
|
+
if manifest.get("status") not in ("uploading", "ready"):
|
|
147
|
+
raise ValueError(f"the upload is {manifest.get('status')!r}: a failed shard is not resumed")
|
|
148
|
+
polling, redispatch = [], []
|
|
149
|
+
for shard in manifest["shards"]:
|
|
150
|
+
try:
|
|
151
|
+
records, byte_count = next(iterator)
|
|
152
|
+
except StopIteration:
|
|
153
|
+
raise ValueError("the manifest records more shards than these parameters make") from None
|
|
154
|
+
layers = sorted({record["properties"]["layer"] for record in records})
|
|
155
|
+
expected = {"asset_id": f"{fresh['asset_namespace']}_part_{shard['index']:04d}", "rows": len(records),
|
|
156
|
+
"layers": layers, "collection_payload_bytes": byte_count}
|
|
157
|
+
if any(shard.get(k) != v for k, v in expected.items()):
|
|
158
|
+
raise ValueError(f"shard {shard['index']} differs from the chunking of these parameters")
|
|
159
|
+
state = shard.get("status")
|
|
160
|
+
if state == "COMPLETED":
|
|
161
|
+
continue
|
|
162
|
+
if state in ("FAILED", "CANCELLED"):
|
|
163
|
+
raise ValueError(f"shard {shard['asset_id']} is {state}: it is not resumed")
|
|
164
|
+
if shard.get("task_id"): # started: poll it to the end
|
|
165
|
+
polling.append((_RecordedTask(shard["task_id"]), shard))
|
|
166
|
+
elif _asset_exists(shard["asset_id"]): # recorded "starting", but its export finished
|
|
167
|
+
shard.update(status="COMPLETED", finished_unix=time.time(), resumed="asset found")
|
|
168
|
+
else: # never started: dispatch it again
|
|
169
|
+
redispatch.append((records, byte_count, shard))
|
|
170
|
+
return manifest, polling, redispatch
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def export_weights(parameters: Sequence[ConvolutionWeights], asset_namespace: str,
|
|
174
|
+
manifest_path: Path, *, max_bytes: int = 8_000_000,
|
|
175
|
+
max_active: int = 2, poll_seconds: float = 15,
|
|
176
|
+
on_update: Callable[[dict[str, Any]], None] | None = None,
|
|
177
|
+
resume: bool = False) -> dict[str, Any]:
|
|
178
|
+
"""Upload FC shards, waiting for completion with bounded batch concurrency.
|
|
179
|
+
|
|
180
|
+
``asset_namespace`` is a new unique prefix under projects/.../assets. This
|
|
181
|
+
function never overwrites or deletes assets. It updates its local manifest
|
|
182
|
+
immediately after each dispatched task and completion, so interrupted runs
|
|
183
|
+
remain auditable. A failed shard is reported and prevents a ready manifest.
|
|
184
|
+
|
|
185
|
+
``resume=True`` continues the upload that ``manifest_path`` records, for the same
|
|
186
|
+
parameters and chunking (refused otherwise): it polls the recorded tasks to their
|
|
187
|
+
end; a shard recorded "starting" without a task ID is complete if its asset exists
|
|
188
|
+
(one ``getAsset``) and dispatched again if not; the remaining shards follow from the
|
|
189
|
+
same deterministic chunking; then the manifest is finalized. A manifest that is
|
|
190
|
+
already ready is returned as it is. Without ``resume`` the manifest starts afresh.
|
|
191
|
+
"""
|
|
192
|
+
if max_active not in (1, 2):
|
|
193
|
+
raise ValueError("Asset upload uses at most two simultaneous exports")
|
|
194
|
+
if not 1 <= poll_seconds <= 30:
|
|
195
|
+
raise ValueError("poll_seconds must be between 1 and 30")
|
|
196
|
+
keys = [parameter.key for parameter in parameters]
|
|
197
|
+
if len(keys) != len(set(keys)):
|
|
198
|
+
raise ValueError("Duplicate convolution keys")
|
|
199
|
+
manifest: dict[str, Any] = {
|
|
200
|
+
"format": "native_ee_featurecollection_weights_v2", "status": "uploading",
|
|
201
|
+
"asset_namespace": asset_namespace, "started_unix": time.time(),
|
|
202
|
+
"serialization": "JSON strings of numeric chunks; 9 significant decimal digits for float32; float() restoration",
|
|
203
|
+
"dummy_geometry": "Point(0, 0); EE table exports require non-null geometry",
|
|
204
|
+
"max_collection_payload_bytes": max_bytes, "max_active_tasks": max_active,
|
|
205
|
+
"parameter_count": sum(parameter.weights.size + (0 if parameter.bias is None else parameter.bias.size)
|
|
206
|
+
for parameter in parameters),
|
|
207
|
+
"layers": {parameter.key: {"shape": list(parameter.shape), "dtype": parameter.dtype,
|
|
208
|
+
"bias": parameter.bias is not None, "source": parameter.source,
|
|
209
|
+
"sha256": parameter.sha256, "assets": []}
|
|
210
|
+
for parameter in parameters}, "shards": [],
|
|
211
|
+
}
|
|
212
|
+
iterator = iter(upload_chunks(parameters, max_bytes))
|
|
213
|
+
active: list[tuple[Any, dict[str, Any]]] = []
|
|
214
|
+
pending: list[tuple[list[dict[str, Any]], int, dict[str, Any]]] = []
|
|
215
|
+
exhausted = False
|
|
216
|
+
if resume:
|
|
217
|
+
manifest, active, pending = _resume(manifest, Path(manifest_path), iterator)
|
|
218
|
+
if manifest["status"] == "ready":
|
|
219
|
+
return manifest
|
|
220
|
+
|
|
221
|
+
def save() -> None:
|
|
222
|
+
_write_manifest(manifest_path, manifest)
|
|
223
|
+
if on_update:
|
|
224
|
+
on_update(manifest)
|
|
225
|
+
|
|
226
|
+
def dispatch(records: list[dict[str, Any]], byte_count: int, shard: dict[str, Any] | None) -> None:
|
|
227
|
+
if shard is None: # the next shard of the chunking
|
|
228
|
+
index = len(manifest["shards"])
|
|
229
|
+
asset_id = f"{asset_namespace}_part_{index:04d}"
|
|
230
|
+
layers = sorted({record["properties"]["layer"] for record in records})
|
|
231
|
+
shard = {"index": index, "asset_id": asset_id, "rows": len(records),
|
|
232
|
+
"layers": layers, "collection_payload_bytes": byte_count,
|
|
233
|
+
"status": "starting", "started_unix": time.time()}
|
|
234
|
+
manifest["shards"].append(shard)
|
|
235
|
+
for key in layers:
|
|
236
|
+
manifest["layers"][key]["assets"].append(asset_id)
|
|
237
|
+
else: # a recorded shard that never started
|
|
238
|
+
shard.update(status="starting", started_unix=time.time())
|
|
239
|
+
save()
|
|
240
|
+
task = ee.batch.Export.table.toAsset(
|
|
241
|
+
collection_from_records(records), description=f"native_unet_weights_part_{shard['index']:04d}",
|
|
242
|
+
assetId=shard["asset_id"], overwrite=False)
|
|
243
|
+
try:
|
|
244
|
+
task.start()
|
|
245
|
+
except Exception as error:
|
|
246
|
+
shard.update(status="FAILED", error=f"{type(error).__name__}: {error}")
|
|
247
|
+
manifest.update(status="failed", finished_unix=time.time())
|
|
248
|
+
# Record the failure, but never let the save's callback (a polling cap, say)
|
|
249
|
+
# replace the start() error.
|
|
250
|
+
with contextlib.suppress(Exception):
|
|
251
|
+
save()
|
|
252
|
+
raise
|
|
253
|
+
shard.update(task_id=task.id, status="READY")
|
|
254
|
+
active.append((task, shard))
|
|
255
|
+
save()
|
|
256
|
+
|
|
257
|
+
save()
|
|
258
|
+
while active or pending or not exhausted:
|
|
259
|
+
while (pending or not exhausted) and len(active) < max_active:
|
|
260
|
+
if pending:
|
|
261
|
+
dispatch(*pending.pop(0))
|
|
262
|
+
continue
|
|
263
|
+
try:
|
|
264
|
+
records, byte_count = next(iterator)
|
|
265
|
+
except StopIteration:
|
|
266
|
+
exhausted = True
|
|
267
|
+
break
|
|
268
|
+
dispatch(records, byte_count, None)
|
|
269
|
+
any_finished = False
|
|
270
|
+
for task, shard in list(active):
|
|
271
|
+
status = task.status()
|
|
272
|
+
shard["task_status"] = status
|
|
273
|
+
shard["status"] = status["state"]
|
|
274
|
+
if status["state"] in ("COMPLETED", "FAILED", "CANCELLED"):
|
|
275
|
+
shard["finished_unix"] = time.time()
|
|
276
|
+
active.remove((task, shard))
|
|
277
|
+
any_finished = True
|
|
278
|
+
if status["state"] != "COMPLETED":
|
|
279
|
+
manifest.update(status="failed", finished_unix=time.time())
|
|
280
|
+
save()
|
|
281
|
+
raise RuntimeError(f"Weight shard {shard['asset_id']} failed: {status}")
|
|
282
|
+
save()
|
|
283
|
+
if active and not any_finished:
|
|
284
|
+
time.sleep(poll_seconds)
|
|
285
|
+
manifest.update(status="ready", finished_unix=time.time(),
|
|
286
|
+
collection_payload_bytes=sum(shard["collection_payload_bytes"] for shard in manifest["shards"]))
|
|
287
|
+
save()
|
|
288
|
+
return manifest
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def probe_asset_roundtrip(asset_namespace: str, result_path: Path) -> dict[str, Any]:
|
|
292
|
+
"""Live all-element test, including a standard ResNet18 length-4,608 row."""
|
|
293
|
+
rng = np.random.default_rng(47)
|
|
294
|
+
weights = rng.normal(0, .025, (3, 4608)).astype(np.float32)
|
|
295
|
+
bias = np.array([-.03, .0, .03], dtype=np.float32)
|
|
296
|
+
parameter = ConvolutionWeights("probe", weights, bias, "float32", "synthetic_probe")
|
|
297
|
+
manifest_path = result_path.with_name(result_path.stem + "_manifest.json")
|
|
298
|
+
start = time.perf_counter()
|
|
299
|
+
manifest = export_weights([parameter], asset_namespace, manifest_path, poll_seconds=5)
|
|
300
|
+
provider = FeatureCollectionWeightProvider(manifest)
|
|
301
|
+
actual, actual_bias = provider.arrays("probe")
|
|
302
|
+
expected = ee.Array(weights.astype(float).tolist())
|
|
303
|
+
expected_bias = ee.Array(bias.astype(float).reshape(-1, 1).tolist())
|
|
304
|
+
collection = ee.FeatureCollection(manifest["shards"][0]["asset_id"])
|
|
305
|
+
verification = ee.Dictionary({
|
|
306
|
+
"shape": actual.length(), "feature_count": collection.size(), "row_count": collection.filter(ee.Filter.eq("chunk", 0)).size(),
|
|
307
|
+
"max_weight_absolute_error": actual.subtract(expected).abs().reduce(ee.Reducer.max(), [0, 1]),
|
|
308
|
+
"max_bias_absolute_error": actual_bias.subtract(expected_bias).abs().reduce(ee.Reducer.max(), [0, 1]),
|
|
309
|
+
})
|
|
310
|
+
check_start = time.perf_counter()
|
|
311
|
+
raw = verification.getInfo()
|
|
312
|
+
report = {"status": "pass" if (raw["max_weight_absolute_error"] == [[0]] and raw["max_bias_absolute_error"] == [[0]]
|
|
313
|
+
and raw["shape"] == [3, 4608] and raw["row_count"] == 3) else "fail",
|
|
314
|
+
"manifest_path": str(manifest_path), "asset_ids": [shard["asset_id"] for shard in manifest["shards"]],
|
|
315
|
+
"row_length": 4608, "source_sha256": parameter.sha256, "verification": raw,
|
|
316
|
+
"verification_seconds": time.perf_counter() - check_start,
|
|
317
|
+
"total_seconds": time.perf_counter() - start,
|
|
318
|
+
"verification_request_bytes": len(ee.serializer.toJSON(verification).encode("utf-8")),
|
|
319
|
+
"serialization": manifest["serialization"]}
|
|
320
|
+
_write_manifest(result_path, report)
|
|
321
|
+
return report
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def verify_asset_rows(parameters: Sequence[ConvolutionWeights], manifest: Mapping[str, Any] | str | Path,
|
|
325
|
+
result_path: Path, *, rows_per_layer: int = 3,
|
|
326
|
+
on_update: Callable[[dict[str, Any]], None] | None = None) -> dict[str, Any]:
|
|
327
|
+
"""Check deterministic rows of every server-reconstructed matrix exactly.
|
|
328
|
+
|
|
329
|
+
This is a storage and ordering check, separate from model output parity.
|
|
330
|
+
Only scalar errors, shapes, and timing are returned. The exact source
|
|
331
|
+
fixtures are embedded for selected rows, keeping each query comfortably
|
|
332
|
+
below the request payload cap. It does not claim a full-asset checksum.
|
|
333
|
+
"""
|
|
334
|
+
if rows_per_layer < 1:
|
|
335
|
+
raise ValueError("rows_per_layer must be positive")
|
|
336
|
+
provider = FeatureCollectionWeightProvider(manifest)
|
|
337
|
+
report: dict[str, Any] = {"status": "running", "started_unix": time.time(),
|
|
338
|
+
"comparison": "deterministic sampled rows from every layer; native server reconstruction",
|
|
339
|
+
"layers": []}
|
|
340
|
+
_write_manifest(result_path, report)
|
|
341
|
+
for parameter in parameters:
|
|
342
|
+
metadata = provider.manifest["layers"][parameter.key]
|
|
343
|
+
if parameter.sha256 != metadata["sha256"]:
|
|
344
|
+
raise ValueError(f"Source fingerprint mismatch for {parameter.key}")
|
|
345
|
+
weights, bias = provider.arrays(parameter.key)
|
|
346
|
+
indices = sorted(set(int(value) for value in np.linspace(0, parameter.shape[0] - 1,
|
|
347
|
+
min(rows_per_layer, parameter.shape[0]))))
|
|
348
|
+
row_checks = {}
|
|
349
|
+
for index in indices:
|
|
350
|
+
expected = ee.Array(parameter.weights[index:index + 1].astype(float).tolist())
|
|
351
|
+
difference = weights.slice(0, index, index + 1).subtract(expected).abs()
|
|
352
|
+
row_checks[f"weight_error_row_{index}"] = difference.reduce(ee.Reducer.max(), [0, 1]).get([0, 0])
|
|
353
|
+
if bias is not None:
|
|
354
|
+
row_checks[f"bias_error_row_{index}"] = bias.get([index, 0]).subtract(float(parameter.bias[index])).abs()
|
|
355
|
+
row_checks["shape"] = weights.length()
|
|
356
|
+
request = ee.Dictionary(row_checks)
|
|
357
|
+
started = time.perf_counter()
|
|
358
|
+
raw = request.getInfo()
|
|
359
|
+
errors = [value for key, value in raw.items() if key != "shape"]
|
|
360
|
+
entry = {"key": parameter.key, "rows": indices, "shape": list(parameter.shape),
|
|
361
|
+
"status": "pass" if raw["shape"] == list(parameter.shape) and all(value == 0 for value in errors) else "fail",
|
|
362
|
+
"max_absolute_error": max(errors), "evaluation_seconds": time.perf_counter() - started,
|
|
363
|
+
"request_bytes": len(ee.serializer.toJSON(request).encode()), "server_metrics": raw}
|
|
364
|
+
report["layers"].append(entry)
|
|
365
|
+
_write_manifest(result_path, report)
|
|
366
|
+
if on_update:
|
|
367
|
+
on_update(entry)
|
|
368
|
+
report.update(status="pass" if all(entry["status"] == "pass" for entry in report["layers"]) else "fail",
|
|
369
|
+
finished_unix=time.time())
|
|
370
|
+
_write_manifest(result_path, report)
|
|
371
|
+
return report
|