async-geotiff 0.1.0b1__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.
@@ -0,0 +1,10 @@
1
+ """Async GeoTIFF and [Cloud-Optimized GeoTIFF][cogeo] (COG) reader for Python.
2
+
3
+ [cogeo]: https://cogeo.org/
4
+ """
5
+
6
+ from ._geotiff import GeoTIFF
7
+ from ._overview import Overview
8
+ from ._version import __version__
9
+
10
+ __all__ = ["GeoTIFF", "Overview", "__version__"]
async_geotiff/_crs.py ADDED
@@ -0,0 +1,621 @@
1
+ """Parse a pyproj CRS from a GeoKeyDirectory.
2
+
3
+ In the simple case, a GeoTIFF is defined by an EPSG code, which we can pass to
4
+ `pyproj.CRS.from_epsg`.
5
+
6
+ In the complex case, a GeoTIFF is defined by a set of geo keys that specify all
7
+ the parameters of a user-defined CRS. In this case, we need to build a PROJJSON
8
+ dictionary representing the CRS, which we can then pass to `pyproj.CRS.from_json_dict`.
9
+
10
+ Most of the code to construct a custom PROJJSON is generated by Claude. For any files
11
+ where CRS generation fails, we should add a new test case in `test_crs.py`.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import TYPE_CHECKING
17
+
18
+ from pyproj import CRS
19
+
20
+ if TYPE_CHECKING:
21
+ from async_tiff import GeoKeyDirectory
22
+
23
+ MODEL_TYPE_PROJECTED = 1
24
+ """GeoTIFF model type constant for projected coordinate systems."""
25
+
26
+ MODEL_TYPE_GEOGRAPHIC = 2
27
+ """GeoTIFF model type constant for geographic coordinate systems."""
28
+
29
+ USER_DEFINED_CRS = 32767
30
+ """Sentinel value for user-defined CRS."""
31
+
32
+ # The two "public" functions of this module
33
+ __all__ = ["crs_from_geo_keys", "projjson_from_geo_keys"]
34
+
35
+
36
+ def crs_from_geo_keys(gkd: GeoKeyDirectory) -> CRS:
37
+ """Parse a pyproj CRS from a GeoKeyDirectory.
38
+
39
+ Supports both EPSG-coded and user-defined CRSes.
40
+ """
41
+ model_type = gkd.model_type
42
+
43
+ if model_type == MODEL_TYPE_PROJECTED:
44
+ crs = _projected_projection(gkd)
45
+ if isinstance(crs, int):
46
+ return CRS.from_epsg(crs)
47
+
48
+ return CRS.from_json_dict(crs)
49
+
50
+ if model_type == MODEL_TYPE_GEOGRAPHIC:
51
+ crs = _geographic_projection(gkd)
52
+ if isinstance(crs, int):
53
+ return CRS.from_epsg(crs)
54
+
55
+ return CRS.from_json_dict(crs)
56
+
57
+ raise ValueError(f"Unsupported GeoTIFF model type: {model_type}")
58
+
59
+
60
+ def projjson_from_geo_keys(gkd: GeoKeyDirectory) -> dict:
61
+ """Build a PROJJSON dict from a GeoKeyDirectory.
62
+
63
+ For EPSG-coded CRSes, resolves via pyproj and returns the canonical
64
+ PROJJSON.
65
+
66
+ For user-defined CRSes, constructs the PROJJSON from individual
67
+ geo key parameters.
68
+ """
69
+ model_type = gkd.model_type
70
+
71
+ if model_type == MODEL_TYPE_PROJECTED:
72
+ crs = _projected_projection(gkd)
73
+ if isinstance(crs, int):
74
+ return CRS.from_epsg(crs).to_json_dict()
75
+
76
+ return crs
77
+
78
+ if model_type == MODEL_TYPE_GEOGRAPHIC:
79
+ crs = _geographic_projection(gkd)
80
+ if isinstance(crs, int):
81
+ return CRS.from_epsg(crs).to_json_dict()
82
+
83
+ return crs
84
+
85
+ raise ValueError(f"Unsupported GeoTIFF model type: {model_type}")
86
+
87
+
88
+ def _geographic_projection(gkd: GeoKeyDirectory) -> int | dict:
89
+ """Infer the geographic CRS from geo keys.
90
+
91
+ If an EPSG code is present, returns that integer. Otherwise, constructs
92
+ a user-defined geographic CRS PROJJSON dict.
93
+ """
94
+ epsg = gkd.geographic_type
95
+ if epsg is not None and epsg != USER_DEFINED_CRS:
96
+ return epsg
97
+
98
+ return _build_user_defined_geographic_projjson(gkd)
99
+
100
+
101
+ def _projected_projection(gkd: GeoKeyDirectory) -> int | dict:
102
+ """Infer the projected CRS from geo keys.
103
+
104
+ If an EPSG code is present, returns that integer. Otherwise, constructs
105
+ a user-defined projected CRS PROJJSON dict.
106
+ """
107
+ epsg = gkd.projected_type
108
+ if epsg is not None and epsg != USER_DEFINED_CRS:
109
+ return epsg
110
+
111
+ return _build_user_defined_projected_projjson(gkd)
112
+
113
+
114
+ def _build_user_defined_geographic_projjson(gkd: GeoKeyDirectory) -> dict:
115
+ """Build a user-defined geographic CRS PROJJSON dict.
116
+
117
+ Constructs PROJJSON from the ellipsoid, datum, prime meridian, and angular
118
+ unit parameters stored in the GeoKeyDirectory.
119
+ """
120
+ # Build ellipsoid parameters
121
+ ellipsoid = _build_ellipsoid_params(gkd)
122
+
123
+ # Build prime meridian
124
+ pm_name = "Greenwich"
125
+ pm_longitude = 0.0
126
+ if (
127
+ gkd.geog_prime_meridian is not None
128
+ and gkd.geog_prime_meridian != USER_DEFINED_CRS
129
+ ):
130
+ # Known prime meridian by EPSG code
131
+ pm_name = f"EPSG:{gkd.geog_prime_meridian}"
132
+ elif gkd.geog_prime_meridian_long is not None:
133
+ pm_longitude = gkd.geog_prime_meridian_long
134
+ pm_name = "User-defined"
135
+
136
+ # Build datum
137
+ datum_json: dict
138
+ if (
139
+ gkd.geog_geodetic_datum is not None
140
+ and gkd.geog_geodetic_datum != USER_DEFINED_CRS
141
+ ):
142
+ datum_json = {
143
+ "type": "GeodeticReferenceFrame",
144
+ "name": f"Unknown datum based upon EPSG {gkd.geog_geodetic_datum} ellipsoid", # noqa: E501
145
+ }
146
+ else:
147
+ datum_json = {
148
+ "type": "GeodeticReferenceFrame",
149
+ "name": gkd.geog_citation or "User-defined",
150
+ "ellipsoid": ellipsoid,
151
+ "prime_meridian": {
152
+ "name": pm_name,
153
+ "longitude": pm_longitude,
154
+ },
155
+ }
156
+
157
+ return {
158
+ "type": "GeographicCRS",
159
+ "$schema": "https://proj.org/schemas/v0.7/projjson.schema.json",
160
+ "name": gkd.geog_citation or "User-defined",
161
+ "datum": datum_json,
162
+ "coordinate_system": _geographic_cs(gkd),
163
+ }
164
+
165
+
166
+ def _build_user_defined_projected_projjson(gkd: GeoKeyDirectory) -> dict:
167
+ """Build a user-defined projected CRS PROJJSON dict.
168
+
169
+ Constructs PROJJSON from the geographic base CRS and projection parameters
170
+ stored in the GeoKeyDirectory.
171
+ """
172
+ # Build the base geographic CRS as PROJJSON
173
+ base_crs = _geographic_projection(gkd)
174
+ if isinstance(base_crs, int):
175
+ base_crs_json = CRS.from_epsg(base_crs).to_json_dict()
176
+ else:
177
+ base_crs_json = base_crs
178
+
179
+ # Build the coordinate operation (projection)
180
+ conversion = _build_conversion(gkd)
181
+
182
+ # Build the coordinate system for the projected CRS
183
+ cs = _projected_cs(gkd)
184
+
185
+ return {
186
+ "type": "ProjectedCRS",
187
+ "$schema": "https://proj.org/schemas/v0.7/projjson.schema.json",
188
+ "name": gkd.proj_citation or "User-defined",
189
+ "base_crs": base_crs_json,
190
+ "conversion": conversion,
191
+ "coordinate_system": cs,
192
+ }
193
+
194
+
195
+ def _build_ellipsoid_params(gkd: GeoKeyDirectory) -> dict:
196
+ """Build ellipsoid JSON parameters from geo keys."""
197
+ if gkd.geog_ellipsoid is not None and gkd.geog_ellipsoid != USER_DEFINED_CRS:
198
+ # Known ellipsoid by EPSG code — use parameters from geo keys if present
199
+ ellipsoid: dict = {"name": f"EPSG ellipsoid {gkd.geog_ellipsoid}"}
200
+
201
+ if gkd.geog_semi_major_axis is not None:
202
+ ellipsoid["semi_major_axis"] = gkd.geog_semi_major_axis
203
+ if gkd.geog_inv_flattening is not None:
204
+ ellipsoid["inverse_flattening"] = gkd.geog_inv_flattening
205
+ elif gkd.geog_semi_minor_axis is not None:
206
+ ellipsoid["semi_minor_axis"] = gkd.geog_semi_minor_axis
207
+
208
+ return ellipsoid
209
+
210
+ # Fully user-defined ellipsoid
211
+ semi_major = gkd.geog_semi_major_axis
212
+ if semi_major is None:
213
+ raise ValueError("User-defined ellipsoid requires geog_semi_major_axis")
214
+
215
+ user_ellipsoid: dict = {
216
+ "name": "User-defined",
217
+ "semi_major_axis": semi_major,
218
+ }
219
+
220
+ if gkd.geog_inv_flattening is not None:
221
+ user_ellipsoid["inverse_flattening"] = gkd.geog_inv_flattening
222
+ elif gkd.geog_semi_minor_axis is not None:
223
+ user_ellipsoid["semi_minor_axis"] = gkd.geog_semi_minor_axis
224
+ else:
225
+ raise ValueError(
226
+ "User-defined ellipsoid requires geog_inv_flattening or geog_semi_minor_axis", # noqa: E501
227
+ )
228
+
229
+ return user_ellipsoid
230
+
231
+
232
+ # GeoTIFF coordinate transformation type codes (GeoKey 3075)
233
+ # Ref: http://geotiff.maptools.org/spec/geotiff6.html#6.3.3.3
234
+ CT_TRANSVERSE_MERCATOR = 1
235
+ CT_TRANSVERSE_MERCATOR_SOUTH = 2
236
+ CT_OBLIQUE_MERCATOR = 3
237
+ CT_OBLIQUE_MERCATOR_LABORDE = 4
238
+ CT_OBLIQUE_MERCATOR_ROSENMUND = 5
239
+ CT_OBLIQUE_MERCATOR_SPHERICAL = 6
240
+ CT_MERCATOR = 7
241
+ CT_LAMBERT_CONFORMAL_CONIC_2SP = 8
242
+ CT_LAMBERT_CONFORMAL_CONIC_1SP = 9
243
+ CT_LAMBERT_AZIMUTHAL_EQUAL_AREA = 10
244
+ CT_ALBERS_EQUAL_AREA = 11
245
+ CT_AZIMUTHAL_EQUIDISTANT = 12
246
+ CT_STEREOGRAPHIC = 14
247
+ CT_POLAR_STEREOGRAPHIC = 15
248
+ CT_OBLIQUE_STEREOGRAPHIC = 16
249
+ CT_EQUIRECTANGULAR = 17
250
+ CT_CASSINI_SOLDNER = 18
251
+ CT_ORTHOGRAPHIC = 21
252
+ CT_POLYCONIC = 22
253
+ CT_SINUSOIDAL = 24
254
+ CT_NEW_ZEALAND_MAP_GRID = 26
255
+ CT_TRANSVERSE_MERCATOR_SOUTH_ORIENTED = 27
256
+
257
+
258
+ def _build_conversion(gkd: GeoKeyDirectory) -> dict: # noqa: C901, PLR0912, PLR0915
259
+ """Build a PROJ JSON conversion (coordinate operation) from geo keys."""
260
+ ct = gkd.proj_coord_trans
261
+ if ct is None:
262
+ raise ValueError("User-defined projected CRS requires proj_coord_trans")
263
+
264
+ # Helper to build a projection parameter with unit
265
+ def _angular(name: str, value: float | None, default: float = 0.0) -> dict:
266
+ return {
267
+ "name": name,
268
+ "value": value if value is not None else default,
269
+ "unit": "degree",
270
+ }
271
+
272
+ def _linear(name: str, value: float | None, default: float = 0.0) -> dict:
273
+ return {
274
+ "name": name,
275
+ "value": value if value is not None else default,
276
+ "unit": "metre",
277
+ }
278
+
279
+ def _scale(name: str, value: float | None, default: float = 1.0) -> dict:
280
+ return {
281
+ "name": name,
282
+ "value": value if value is not None else default,
283
+ "unit": "unity",
284
+ }
285
+
286
+ name = "User-defined"
287
+ method: dict
288
+ parameters: list[dict]
289
+
290
+ if ct == CT_TRANSVERSE_MERCATOR:
291
+ name = "Transverse Mercator"
292
+ method = {"name": "Transverse Mercator"}
293
+ parameters = [
294
+ _angular("Latitude of natural origin", gkd.proj_nat_origin_lat),
295
+ _angular("Longitude of natural origin", gkd.proj_nat_origin_long),
296
+ _scale("Scale factor at natural origin", gkd.proj_scale_at_nat_origin),
297
+ _linear("False easting", gkd.proj_false_easting),
298
+ _linear("False northing", gkd.proj_false_northing),
299
+ ]
300
+
301
+ elif ct == CT_TRANSVERSE_MERCATOR_SOUTH:
302
+ name = "Transverse Mercator (South Orientated)"
303
+ method = {"name": "Transverse Mercator (South Orientated)"}
304
+ parameters = [
305
+ _angular("Latitude of natural origin", gkd.proj_nat_origin_lat),
306
+ _angular("Longitude of natural origin", gkd.proj_nat_origin_long),
307
+ _scale("Scale factor at natural origin", gkd.proj_scale_at_nat_origin),
308
+ _linear("False easting", gkd.proj_false_easting),
309
+ _linear("False northing", gkd.proj_false_northing),
310
+ ]
311
+
312
+ elif ct in (
313
+ CT_OBLIQUE_MERCATOR,
314
+ CT_OBLIQUE_MERCATOR_LABORDE,
315
+ CT_OBLIQUE_MERCATOR_ROSENMUND,
316
+ CT_OBLIQUE_MERCATOR_SPHERICAL,
317
+ ):
318
+ name = "Hotine Oblique Mercator (variant B)"
319
+ method = {"name": "Hotine Oblique Mercator (variant B)"}
320
+ parameters = [
321
+ _angular("Latitude of projection centre", gkd.proj_center_lat),
322
+ _angular("Longitude of projection centre", gkd.proj_center_long),
323
+ _angular("Azimuth of initial line", gkd.proj_azimuth_angle),
324
+ _angular("Angle from Rectified to Skew Grid", gkd.proj_azimuth_angle),
325
+ _scale("Scale factor on initial line", gkd.proj_scale_at_center),
326
+ _linear("Easting at projection centre", gkd.proj_center_easting),
327
+ _linear("Northing at projection centre", gkd.proj_center_northing),
328
+ ]
329
+
330
+ elif ct == CT_MERCATOR:
331
+ name = "Mercator (variant A)"
332
+ method = {"name": "Mercator (variant A)"}
333
+ parameters = [
334
+ _angular("Latitude of natural origin", gkd.proj_nat_origin_lat),
335
+ _angular("Longitude of natural origin", gkd.proj_nat_origin_long),
336
+ _scale("Scale factor at natural origin", gkd.proj_scale_at_nat_origin),
337
+ _linear("False easting", gkd.proj_false_easting),
338
+ _linear("False northing", gkd.proj_false_northing),
339
+ ]
340
+
341
+ elif ct == CT_LAMBERT_CONFORMAL_CONIC_2SP:
342
+ name = "Lambert Conic Conformal (2SP)"
343
+ method = {"name": "Lambert Conic Conformal (2SP)"}
344
+ parameters = [
345
+ _angular(
346
+ "Latitude of false origin",
347
+ gkd.proj_false_origin_lat or gkd.proj_nat_origin_lat,
348
+ ),
349
+ _angular(
350
+ "Longitude of false origin",
351
+ gkd.proj_false_origin_long or gkd.proj_nat_origin_long,
352
+ ),
353
+ _angular("Latitude of 1st standard parallel", gkd.proj_std_parallel1),
354
+ _angular("Latitude of 2nd standard parallel", gkd.proj_std_parallel2),
355
+ _linear(
356
+ "Easting at false origin",
357
+ gkd.proj_false_origin_easting or gkd.proj_false_easting,
358
+ ),
359
+ _linear(
360
+ "Northing at false origin",
361
+ gkd.proj_false_origin_northing or gkd.proj_false_northing,
362
+ ),
363
+ ]
364
+
365
+ elif ct == CT_LAMBERT_CONFORMAL_CONIC_1SP:
366
+ name = "Lambert Conic Conformal (1SP)"
367
+ method = {"name": "Lambert Conic Conformal (1SP)"}
368
+ parameters = [
369
+ _angular("Latitude of natural origin", gkd.proj_nat_origin_lat),
370
+ _angular("Longitude of natural origin", gkd.proj_nat_origin_long),
371
+ _scale("Scale factor at natural origin", gkd.proj_scale_at_nat_origin),
372
+ _linear("False easting", gkd.proj_false_easting),
373
+ _linear("False northing", gkd.proj_false_northing),
374
+ ]
375
+
376
+ elif ct == CT_LAMBERT_AZIMUTHAL_EQUAL_AREA:
377
+ name = "Lambert Azimuthal Equal Area"
378
+ method = {"name": "Lambert Azimuthal Equal Area"}
379
+ parameters = [
380
+ _angular("Latitude of natural origin", gkd.proj_center_lat),
381
+ _angular("Longitude of natural origin", gkd.proj_center_long),
382
+ _linear("False easting", gkd.proj_false_easting),
383
+ _linear("False northing", gkd.proj_false_northing),
384
+ ]
385
+
386
+ elif ct == CT_ALBERS_EQUAL_AREA:
387
+ name = "Albers Equal Area"
388
+ method = {"name": "Albers Equal Area"}
389
+ parameters = [
390
+ _angular(
391
+ "Latitude of false origin",
392
+ gkd.proj_false_origin_lat or gkd.proj_nat_origin_lat,
393
+ ),
394
+ _angular(
395
+ "Longitude of false origin",
396
+ gkd.proj_false_origin_long or gkd.proj_nat_origin_long,
397
+ ),
398
+ _angular("Latitude of 1st standard parallel", gkd.proj_std_parallel1),
399
+ _angular("Latitude of 2nd standard parallel", gkd.proj_std_parallel2),
400
+ _linear(
401
+ "Easting at false origin",
402
+ gkd.proj_false_origin_easting or gkd.proj_false_easting,
403
+ ),
404
+ _linear(
405
+ "Northing at false origin",
406
+ gkd.proj_false_origin_northing or gkd.proj_false_northing,
407
+ ),
408
+ ]
409
+
410
+ elif ct == CT_AZIMUTHAL_EQUIDISTANT:
411
+ name = "Modified Azimuthal Equidistant"
412
+ method = {"name": "Modified Azimuthal Equidistant"}
413
+ parameters = [
414
+ _angular("Latitude of natural origin", gkd.proj_center_lat),
415
+ _angular("Longitude of natural origin", gkd.proj_center_long),
416
+ _linear("False easting", gkd.proj_false_easting),
417
+ _linear("False northing", gkd.proj_false_northing),
418
+ ]
419
+
420
+ elif ct == CT_STEREOGRAPHIC:
421
+ name = "Stereographic"
422
+ method = {"name": "Stereographic"}
423
+ parameters = [
424
+ _angular("Latitude of natural origin", gkd.proj_center_lat),
425
+ _angular("Longitude of natural origin", gkd.proj_center_long),
426
+ _scale("Scale factor at natural origin", gkd.proj_scale_at_center),
427
+ _linear("False easting", gkd.proj_false_easting),
428
+ _linear("False northing", gkd.proj_false_northing),
429
+ ]
430
+
431
+ elif ct == CT_POLAR_STEREOGRAPHIC:
432
+ name = "Polar Stereographic (variant B)"
433
+ method = {"name": "Polar Stereographic (variant B)"}
434
+ parameters = [
435
+ _angular(
436
+ "Latitude of standard parallel",
437
+ gkd.proj_nat_origin_lat or gkd.proj_std_parallel1,
438
+ ),
439
+ _angular(
440
+ "Longitude of origin",
441
+ gkd.proj_straight_vert_pole_long or gkd.proj_nat_origin_long,
442
+ ),
443
+ _linear("False easting", gkd.proj_false_easting),
444
+ _linear("False northing", gkd.proj_false_northing),
445
+ ]
446
+
447
+ elif ct == CT_OBLIQUE_STEREOGRAPHIC:
448
+ name = "Oblique Stereographic"
449
+ method = {"name": "Oblique Stereographic"}
450
+ parameters = [
451
+ _angular("Latitude of natural origin", gkd.proj_center_lat),
452
+ _angular("Longitude of natural origin", gkd.proj_center_long),
453
+ _scale("Scale factor at natural origin", gkd.proj_scale_at_center),
454
+ _linear("False easting", gkd.proj_false_easting),
455
+ _linear("False northing", gkd.proj_false_northing),
456
+ ]
457
+
458
+ elif ct == CT_EQUIRECTANGULAR:
459
+ name = "Equidistant Cylindrical"
460
+ method = {"name": "Equidistant Cylindrical"}
461
+ parameters = [
462
+ _angular(
463
+ "Latitude of 1st standard parallel",
464
+ gkd.proj_std_parallel1 or gkd.proj_center_lat,
465
+ ),
466
+ _angular("Longitude of natural origin", gkd.proj_center_long),
467
+ _linear("False easting", gkd.proj_false_easting),
468
+ _linear("False northing", gkd.proj_false_northing),
469
+ ]
470
+
471
+ elif ct == CT_CASSINI_SOLDNER:
472
+ name = "Cassini-Soldner"
473
+ method = {"name": "Cassini-Soldner"}
474
+ parameters = [
475
+ _angular("Latitude of natural origin", gkd.proj_nat_origin_lat),
476
+ _angular("Longitude of natural origin", gkd.proj_nat_origin_long),
477
+ _linear("False easting", gkd.proj_false_easting),
478
+ _linear("False northing", gkd.proj_false_northing),
479
+ ]
480
+
481
+ elif ct == CT_POLYCONIC:
482
+ name = "American Polyconic"
483
+ method = {"name": "American Polyconic"}
484
+ parameters = [
485
+ _angular("Latitude of natural origin", gkd.proj_nat_origin_lat),
486
+ _angular("Longitude of natural origin", gkd.proj_nat_origin_long),
487
+ _linear("False easting", gkd.proj_false_easting),
488
+ _linear("False northing", gkd.proj_false_northing),
489
+ ]
490
+
491
+ elif ct == CT_SINUSOIDAL:
492
+ name = "Sinusoidal"
493
+ method = {"name": "Sinusoidal"}
494
+ parameters = [
495
+ _angular("Longitude of natural origin", gkd.proj_center_long),
496
+ _linear("False easting", gkd.proj_false_easting),
497
+ _linear("False northing", gkd.proj_false_northing),
498
+ ]
499
+
500
+ elif ct == CT_ORTHOGRAPHIC:
501
+ name = "Orthographic"
502
+ method = {"name": "Orthographic"}
503
+ parameters = [
504
+ _angular("Latitude of natural origin", gkd.proj_center_lat),
505
+ _angular("Longitude of natural origin", gkd.proj_center_long),
506
+ _linear("False easting", gkd.proj_false_easting),
507
+ _linear("False northing", gkd.proj_false_northing),
508
+ ]
509
+
510
+ elif ct == CT_NEW_ZEALAND_MAP_GRID:
511
+ name = "New Zealand Map Grid"
512
+ method = {"name": "New Zealand Map Grid"}
513
+ parameters = [
514
+ _angular("Latitude of natural origin", gkd.proj_nat_origin_lat),
515
+ _angular("Longitude of natural origin", gkd.proj_nat_origin_long),
516
+ _linear("False easting", gkd.proj_false_easting),
517
+ _linear("False northing", gkd.proj_false_northing),
518
+ ]
519
+
520
+ elif ct == CT_TRANSVERSE_MERCATOR_SOUTH_ORIENTED:
521
+ name = "Transverse Mercator (South Orientated)"
522
+ method = {"name": "Transverse Mercator (South Orientated)"}
523
+ parameters = [
524
+ _angular("Latitude of natural origin", gkd.proj_nat_origin_lat),
525
+ _angular("Longitude of natural origin", gkd.proj_nat_origin_long),
526
+ _scale("Scale factor at natural origin", gkd.proj_scale_at_nat_origin),
527
+ _linear("False easting", gkd.proj_false_easting),
528
+ _linear("False northing", gkd.proj_false_northing),
529
+ ]
530
+
531
+ else:
532
+ raise ValueError(f"Unsupported coordinate transformation type: {ct}")
533
+
534
+ return {
535
+ "name": name,
536
+ "method": method,
537
+ "parameters": parameters,
538
+ }
539
+
540
+
541
+ ANGULAR_UNIT_RADIAN = 9101
542
+ """EPSG code for radian angular unit."""
543
+
544
+ ANGULAR_UNIT_DEGREE = 9102
545
+ """EPSG code for degree angular unit."""
546
+
547
+ ANGULAR_UNIT_GRAD = 9105
548
+ """EPSG code for grad angular unit."""
549
+
550
+ ANGULAR_UNIT: dict[int | None, str] = {
551
+ ANGULAR_UNIT_DEGREE: "degree",
552
+ ANGULAR_UNIT_RADIAN: "radian",
553
+ ANGULAR_UNIT_GRAD: "grad",
554
+ }
555
+
556
+
557
+ def _geographic_cs(gkd: GeoKeyDirectory) -> dict:
558
+ """Build a geographic coordinate system JSON dict from geo keys."""
559
+ angular_unit = ANGULAR_UNIT.get(
560
+ gkd.geog_angular_units,
561
+ "degree",
562
+ )
563
+
564
+ return {
565
+ "subtype": "ellipsoidal",
566
+ "axis": [
567
+ {
568
+ "name": "Latitude",
569
+ "abbreviation": "lat",
570
+ "direction": "north",
571
+ "unit": angular_unit,
572
+ },
573
+ {
574
+ "name": "Longitude",
575
+ "abbreviation": "lon",
576
+ "direction": "east",
577
+ "unit": angular_unit,
578
+ },
579
+ ],
580
+ }
581
+
582
+
583
+ LINEAR_UNIT_METRE = 9001
584
+ LINEAR_UNIT_FOOT = 9002
585
+ LINEAR_UNIT_US_SURVEY_FOOT = 9003
586
+
587
+ LINEAR_UNIT: dict[int | None, str | dict[str, str | float]] = {
588
+ LINEAR_UNIT_METRE: "metre",
589
+ LINEAR_UNIT_FOOT: "foot",
590
+ LINEAR_UNIT_US_SURVEY_FOOT: {
591
+ "type": "LinearUnit",
592
+ "name": "US survey foot",
593
+ "conversion_factor": 0.30480060960121924,
594
+ },
595
+ }
596
+
597
+
598
+ def _projected_cs(gkd: GeoKeyDirectory) -> dict:
599
+ """Build a projected coordinate system JSON dict from geo keys."""
600
+ linear_unit: str | dict[str, str | float] = LINEAR_UNIT.get(
601
+ gkd.proj_linear_units,
602
+ "metre",
603
+ )
604
+
605
+ return {
606
+ "subtype": "Cartesian",
607
+ "axis": [
608
+ {
609
+ "name": "Easting",
610
+ "abbreviation": "E",
611
+ "direction": "east",
612
+ "unit": linear_unit,
613
+ },
614
+ {
615
+ "name": "Northing",
616
+ "abbreviation": "N",
617
+ "direction": "north",
618
+ "unit": linear_unit,
619
+ },
620
+ ],
621
+ }
@@ -0,0 +1,405 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from functools import cached_property
5
+ from typing import TYPE_CHECKING, Literal, Self
6
+
7
+ from affine import Affine
8
+ from async_tiff import TIFF
9
+ from async_tiff.enums import PhotometricInterpretation
10
+
11
+ from async_geotiff._crs import crs_from_geo_keys
12
+ from async_geotiff._overview import Overview
13
+ from async_geotiff.enums import Compression, Interleaving
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Callable
17
+
18
+ import pyproj
19
+ from async_tiff import GeoKeyDirectory, ImageFileDirectory, ObspecInput
20
+ from async_tiff.store import ObjectStore # type: ignore # noqa: PGH003
21
+
22
+
23
+ @dataclass(frozen=True, init=False, kw_only=True, repr=False)
24
+ class GeoTIFF:
25
+ """A class representing a GeoTIFF image."""
26
+
27
+ _tiff: TIFF
28
+ """The underlying async-tiff TIFF instance that we wrap.
29
+ """
30
+
31
+ _primary_ifd: ImageFileDirectory = field(init=False)
32
+ """The primary (first) IFD of the GeoTIFF.
33
+
34
+ Some tags, like most geo tags, only exist on the primary IFD.
35
+ """
36
+
37
+ _gkd: GeoKeyDirectory = field(init=False)
38
+ """The GeoKeyDirectory of the primary IFD.
39
+ """
40
+
41
+ _overviews: list[Overview] = field(init=False)
42
+ """A list of overviews for the GeoTIFF.
43
+ """
44
+
45
+ def __init__(self, tiff: TIFF) -> None:
46
+ """Create a GeoTIFF from an existing TIFF instance."""
47
+ first_ifd = tiff.ifds[0]
48
+ gkd = first_ifd.geo_key_directory
49
+
50
+ # Validate that this is indeed a GeoTIFF
51
+ if gkd is None:
52
+ raise ValueError("TIFF does not contain GeoTIFF keys")
53
+
54
+ if len(tiff.ifds) == 0:
55
+ raise ValueError("TIFF does not contain any IFDs")
56
+
57
+ # We use object.__setattr__ because the dataclass is frozen
58
+ object.__setattr__(self, "_tiff", tiff)
59
+ object.__setattr__(self, "_primary_ifd", first_ifd)
60
+ object.__setattr__(self, "_gkd", gkd)
61
+
62
+ # Skip the first IFD, since it's the primary image
63
+ ifd_idx = 1
64
+ overviews: list[Overview] = []
65
+ while True:
66
+ try:
67
+ data_ifd = (ifd_idx, tiff.ifds[ifd_idx])
68
+ except IndexError:
69
+ # No more IFDs
70
+ break
71
+
72
+ ifd_idx += 1
73
+
74
+ mask_ifd = None
75
+ next_ifd = None
76
+ try:
77
+ next_ifd = tiff.ifds[ifd_idx]
78
+ except IndexError:
79
+ # No more IFDs
80
+ pass
81
+ finally:
82
+ if next_ifd is not None and is_mask_ifd(next_ifd):
83
+ mask_ifd = (ifd_idx, next_ifd)
84
+ ifd_idx += 1
85
+
86
+ ovr = Overview._create( # noqa: SLF001
87
+ geotiff=self,
88
+ gkd=gkd,
89
+ ifd=data_ifd,
90
+ mask_ifd=mask_ifd,
91
+ )
92
+ overviews.append(ovr)
93
+
94
+ object.__setattr__(self, "_overviews", overviews)
95
+
96
+ @classmethod
97
+ async def open(
98
+ cls,
99
+ path: str,
100
+ *,
101
+ store: ObjectStore | ObspecInput,
102
+ prefetch: int = 32768,
103
+ multiplier: float = 2.0,
104
+ ) -> Self:
105
+ """Open a new GeoTIFF.
106
+
107
+ Args:
108
+ path: The path within the store to read from.
109
+ store: The backend to use for data fetching.
110
+ prefetch: The number of initial bytes to read up front.
111
+ multiplier: The multiplier to use for readahead size growth. Must be
112
+ greater than 1.0. For example, for a value of `2.0`, the first metadata
113
+ read will be of size `prefetch`, and then the next read will be of size
114
+ `prefetch * 2`.
115
+
116
+ Returns:
117
+ A TIFF instance.
118
+
119
+ """
120
+ tiff = await TIFF.open(
121
+ path=path,
122
+ store=store,
123
+ prefetch=prefetch,
124
+ multiplier=multiplier,
125
+ )
126
+ return cls(tiff)
127
+
128
+ @property
129
+ def block_shapes(self) -> list[tuple[int, int]]:
130
+ """An ordered list of block shapes for each bands.
131
+
132
+ Shapes are tuples and have the same ordering as the dataset's shape:
133
+
134
+ - (count of image rows, count of image columns).
135
+ """
136
+ raise NotImplementedError
137
+
138
+ def block_size(self, bidx: int, i: int, j: int) -> int:
139
+ """Return the size in bytes of a particular block.
140
+
141
+ Args:
142
+ bidx: Band index, starting with 1.
143
+ i: Row index of the block, starting with 0.
144
+ j: Column index of the block, starting with 0.
145
+
146
+ """
147
+ raise NotImplementedError
148
+
149
+ @cached_property
150
+ def bounds(self) -> tuple[float, float, float, float]:
151
+ """Return the bounds of the dataset in the units of its CRS.
152
+
153
+ Returns:
154
+ (lower left x, lower left y, upper right x, upper right y)
155
+
156
+ """
157
+ transform = self.transform
158
+
159
+ # Hopefully types will be fixed with affine 3.0
160
+ (left, top) = transform * (0, 0) # type: ignore # noqa: PGH003
161
+ (right, bottom) = transform * (self.width, self.height) # type: ignore # noqa: PGH003
162
+
163
+ return (left, bottom, right, top)
164
+
165
+ @property
166
+ def colorinterp(self) -> list[str]:
167
+ """The color interpretation of each band in index order."""
168
+ # TODO: we should return an enum here. The enum should match rasterio.
169
+ # https://github.com/developmentseed/async-geotiff/issues/12
170
+ raise NotImplementedError
171
+
172
+ def colormap(self, bidx: int) -> dict[int, tuple[int, int, int]]:
173
+ """Return a dict containing the colormap for a band.
174
+
175
+ Args:
176
+ bidx: The 1-based index of the band whose colormap will be returned.
177
+
178
+ Returns:
179
+ Mapping of color index value (starting at 0) to RGBA color as a
180
+ 4-element tuple.
181
+
182
+ Raises:
183
+ ValueError
184
+ If no colormap is found for the specified band (NULL color table).
185
+ IndexError
186
+ If no band exists for the provided index.
187
+
188
+ """
189
+ raise NotImplementedError
190
+
191
+ @property
192
+ def compression(self) -> Compression:
193
+ """The compression algorithm used for the dataset."""
194
+ # TODO: should return an enum. The enum should match rasterio.
195
+ # https://github.com/developmentseed/async-geotiff/issues/12
196
+ # Also, is there ever a case where overviews have a different compression from
197
+ # the base image?
198
+ # Should we diverge from rasterio and not have this as a property returning a
199
+ # single string?
200
+ raise NotImplementedError
201
+
202
+ @property
203
+ def count(self) -> int:
204
+ """The number of raster bands in the full image."""
205
+ raise NotImplementedError
206
+
207
+ @cached_property
208
+ def crs(self) -> pyproj.CRS:
209
+ """The dataset's coordinate reference system."""
210
+ return crs_from_geo_keys(self._gkd)
211
+
212
+ @property
213
+ def dtypes(self) -> list[str]:
214
+ """The data types of each band in index order."""
215
+ # TODO: not sure what the return type should be. Perhaps we should define a
216
+ # `DataType` enum?
217
+ # https://github.com/developmentseed/async-geotiff/issues/20
218
+ raise NotImplementedError
219
+
220
+ @property
221
+ def height(self) -> int:
222
+ """The height (number of rows) of the full image."""
223
+ return self._primary_ifd.image_height
224
+
225
+ def index(
226
+ self,
227
+ x: float,
228
+ y: float,
229
+ op: Callable[[float, float], tuple[int, int]] | None = None,
230
+ ) -> tuple[int, int]:
231
+ """Get the (row, col) index of the pixel containing (x, y).
232
+
233
+ Args:
234
+ x: x value in coordinate reference system
235
+ y: y value in coordinate reference system
236
+ op: function, optional (default: numpy.floor)
237
+ Function to convert fractional pixels to whole numbers
238
+ (floor, ceiling, round)
239
+
240
+ Returns:
241
+ (row index, col index)
242
+
243
+ """
244
+ raise NotImplementedError
245
+
246
+ def indexes(self) -> list[int]:
247
+ """Return the 1-based indexes of each band in the dataset.
248
+
249
+ For a 3-band dataset, this property will be [1, 2, 3].
250
+ """
251
+ return list(range(1, self._primary_ifd.samples_per_pixel + 1))
252
+
253
+ @property
254
+ def interleaving(self) -> Interleaving:
255
+ """The interleaving scheme of the dataset."""
256
+ # TODO: Should return an enum.
257
+ # https://rasterio.readthedocs.io/en/stable/api/rasterio.enums.html#rasterio.enums.Interleaving
258
+ raise NotImplementedError
259
+
260
+ @property
261
+ def is_tiled(self) -> bool:
262
+ """Check if the dataset is tiled."""
263
+ raise NotImplementedError
264
+
265
+ @property
266
+ def nodata(self) -> float | None:
267
+ """The dataset's single nodata value."""
268
+ nodata = self._primary_ifd.gdal_nodata
269
+ if nodata is None:
270
+ return None
271
+
272
+ return float(nodata)
273
+
274
+ @property
275
+ def overviews(self) -> list[Overview]:
276
+ """A list of overview levels for the dataset."""
277
+ return self._overviews
278
+
279
+ @property
280
+ def photometric(self) -> PhotometricInterpretation | None:
281
+ """The photometric interpretation of the dataset."""
282
+ # TODO: should return enum
283
+ # https://rasterio.readthedocs.io/en/stable/api/rasterio.enums.html#rasterio.enums.PhotometricInterp
284
+ raise NotImplementedError
285
+
286
+ @cached_property
287
+ def res(self) -> tuple[float, float]:
288
+ """Return the (width, height) of pixels in the units of its CRS."""
289
+ transform = self.transform
290
+ return (transform.a, -transform.e)
291
+
292
+ @property
293
+ def shape(self) -> tuple[int, int]:
294
+ """Get the shape (height, width) of the full image."""
295
+ return (self.height, self.width)
296
+
297
+ @cached_property
298
+ def transform(self) -> Affine:
299
+ """Return the dataset's georeferencing transformation matrix.
300
+
301
+ This transform maps pixel row/column coordinates to coordinates in the dataset's
302
+ CRS.
303
+ """
304
+ if (tie_points := self._primary_ifd.model_tiepoint) and (
305
+ model_scale := self._primary_ifd.model_pixel_scale
306
+ ):
307
+ x_origin = tie_points[3]
308
+ y_origin = tie_points[4]
309
+ x_resolution = model_scale[0]
310
+ y_resolution = -model_scale[1]
311
+
312
+ return Affine(x_resolution, 0, x_origin, 0, y_resolution, y_origin)
313
+
314
+ if model_transformation := self._primary_ifd.model_transformation:
315
+ # ModelTransformation is a 4x4 matrix in row-major order
316
+ # [0 1 2 3 ] [a b 0 c]
317
+ # [4 5 6 7 ] = [d e 0 f]
318
+ # [8 9 10 11] [0 0 1 0]
319
+ # [12 13 14 15] [0 0 0 1]
320
+ x_origin = model_transformation[3]
321
+ y_origin = model_transformation[7]
322
+ row_rotation = model_transformation[1]
323
+ col_rotation = model_transformation[4]
324
+
325
+ # TODO: confirm these are correct
326
+ # Why does geotiff.js square and then square-root them?
327
+ # https://github.com/developmentseed/async-geotiff/issues/7
328
+ x_resolution = model_transformation[0]
329
+ y_resolution = -model_transformation[5]
330
+
331
+ return Affine(
332
+ model_transformation[0],
333
+ row_rotation,
334
+ x_origin,
335
+ col_rotation,
336
+ model_transformation[5],
337
+ y_origin,
338
+ )
339
+
340
+ raise ValueError("The image does not have an affine transformation.")
341
+
342
+ @property
343
+ def width(self) -> int:
344
+ """The width (number of columns) of the full image."""
345
+ return self._primary_ifd.image_width
346
+
347
+ def xy(
348
+ self,
349
+ row: int,
350
+ col: int,
351
+ offset: Literal["center", "ul", "ur", "ll", "lr"] | str = "center",
352
+ ) -> tuple[float, float]:
353
+ """Get the coordinates x, y of a pixel at row, col.
354
+
355
+ The pixel's center is returned by default, but a corner can be returned
356
+ by setting `offset` to one of `"ul"`, `"ur"`, `"ll"`, `"lr"`.
357
+
358
+ Args:
359
+ row: Pixel row.
360
+ col: Pixel column.
361
+ offset: Determines if the returned coordinates are for the center of the
362
+ pixel or for a corner.
363
+
364
+ """
365
+ transform = self.transform
366
+
367
+ if offset == "center":
368
+ c = col + 0.5
369
+ r = row + 0.5
370
+ elif offset == "ul":
371
+ c = col
372
+ r = row
373
+ elif offset == "ur":
374
+ c = col + 1
375
+ r = row
376
+ elif offset == "ll":
377
+ c = col
378
+ r = row + 1
379
+ elif offset == "lr":
380
+ c = col + 1
381
+ r = row + 1
382
+ else:
383
+ raise ValueError(f"Invalid offset value: {offset}")
384
+
385
+ return transform * (c, r)
386
+
387
+
388
+ def has_geokeys(ifd: ImageFileDirectory) -> bool:
389
+ """Check if an IFD has GeoTIFF keys.
390
+
391
+ Args:
392
+ ifd: The IFD to check.
393
+
394
+ """
395
+ return ifd.geo_key_directory is not None
396
+
397
+
398
+ def is_mask_ifd(ifd: ImageFileDirectory) -> bool:
399
+ """Check if an IFD is a mask IFD."""
400
+ return (
401
+ ifd.compression == Compression.deflate
402
+ and ifd.new_subfile_type is not None
403
+ and ifd.new_subfile_type & 4 != 0
404
+ and ifd.photometric_interpretation == PhotometricInterpretation.TransparencyMask
405
+ )
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from functools import cached_property
5
+ from typing import TYPE_CHECKING
6
+
7
+ from affine import Affine
8
+
9
+ if TYPE_CHECKING:
10
+ from async_tiff import GeoKeyDirectory, ImageFileDirectory
11
+
12
+ from async_geotiff import GeoTIFF
13
+
14
+
15
+ @dataclass(init=False, frozen=True, kw_only=True, eq=False, repr=False)
16
+ class Overview:
17
+ """An overview level of a Cloud-Optimized GeoTIFF image."""
18
+
19
+ _geotiff: GeoTIFF
20
+ """A reference to the parent GeoTIFF object.
21
+ """
22
+
23
+ _gkd: GeoKeyDirectory
24
+ """The GeoKeyDirectory of the primary IFD.
25
+ """
26
+
27
+ _ifd: tuple[int, ImageFileDirectory]
28
+ """The IFD for this overview level.
29
+
30
+ (positional index of the IFD in the TIFF file, IFD object)
31
+ """
32
+
33
+ _mask_ifd: tuple[int, ImageFileDirectory] | None
34
+ """The IFD for the mask associated with this overview level, if any.
35
+
36
+ (positional index of the IFD in the TIFF file, IFD object)
37
+ """
38
+
39
+ @classmethod
40
+ def _create(
41
+ cls,
42
+ *,
43
+ geotiff: GeoTIFF,
44
+ gkd: GeoKeyDirectory,
45
+ ifd: tuple[int, ImageFileDirectory],
46
+ mask_ifd: tuple[int, ImageFileDirectory] | None,
47
+ ) -> Overview:
48
+ instance = cls.__new__(cls)
49
+
50
+ # We use object.__setattr__ because the dataclass is frozen
51
+ object.__setattr__(instance, "_geotiff", geotiff)
52
+ object.__setattr__(instance, "_gkd", gkd)
53
+ object.__setattr__(instance, "_ifd", ifd)
54
+ object.__setattr__(instance, "_mask_ifd", mask_ifd)
55
+
56
+ return instance
57
+
58
+ @property
59
+ def height(self) -> int:
60
+ """The height of the overview in pixels."""
61
+ return self._ifd[1].image_height
62
+
63
+ @cached_property
64
+ def transform(self) -> Affine:
65
+ """The affine transform mapping pixel coordinates to geographic coordinates.
66
+
67
+ Returns:
68
+ Affine: The affine transform.
69
+
70
+ """
71
+ full_transform = self._geotiff.transform
72
+
73
+ overview_width = self._ifd[1].image_width
74
+ full_width = self._geotiff.width
75
+ overview_height = self._ifd[1].image_height
76
+ full_height = self._geotiff.height
77
+
78
+ scale_x = full_width / overview_width
79
+ scale_y = full_height / overview_height
80
+
81
+ return full_transform * Affine.scale(scale_x, scale_y)
82
+
83
+ @property
84
+ def width(self) -> int:
85
+ """The width of the overview in pixels."""
86
+ return self._ifd[1].image_width
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("async-geotiff")
5
+ except PackageNotFoundError:
6
+ __version__ = "uninstalled"
async_geotiff/enums.py ADDED
@@ -0,0 +1,59 @@
1
+ """Enums used by async_geotiff."""
2
+
3
+ from enum import Enum, IntEnum
4
+
5
+ # ruff: noqa: D101
6
+
7
+
8
+ # https://github.com/rasterio/rasterio/blob/2d79e5f3a00e919ecaa9573adba34a78274ce48c/rasterio/enums.py#L153-L174
9
+ class Compression(Enum):
10
+ """Available compression algorithms for GeoTIFFs.
11
+
12
+ Note that compression options for EXR, MRF, etc are not included
13
+ in this enum.
14
+ """
15
+
16
+ jpeg = "JPEG"
17
+ lzw = "LZW"
18
+ packbits = "PACKBITS"
19
+ deflate = "DEFLATE"
20
+ ccittrle = "CCITTRLE"
21
+ ccittfax3 = "CCITTFAX3"
22
+ ccittfax4 = "CCITTFAX4"
23
+ lzma = "LZMA"
24
+ none = "NONE"
25
+ zstd = "ZSTD"
26
+ lerc = "LERC"
27
+ lerc_deflate = "LERC_DEFLATE"
28
+ lerc_zstd = "LERC_ZSTD"
29
+ webp = "WEBP"
30
+ jpeg2000 = "JPEG2000"
31
+
32
+
33
+ # https://github.com/rasterio/rasterio/blob/2d79e5f3a00e919ecaa9573adba34a78274ce48c/rasterio/enums.py#L177-L182
34
+ class Interleaving(Enum):
35
+ pixel = "PIXEL"
36
+ line = "LINE"
37
+ band = "BAND"
38
+ #: tile requires GDAL 3.11+
39
+ tile = "TILE"
40
+
41
+
42
+ # https://github.com/rasterio/rasterio/blob/2d79e5f3a00e919ecaa9573adba34a78274ce48c/rasterio/enums.py#L185-L189
43
+ class MaskFlags(IntEnum):
44
+ all_valid = 1
45
+ per_dataset = 2
46
+ alpha = 4
47
+ nodata = 8
48
+
49
+
50
+ # https://github.com/rasterio/rasterio/blob/2d79e5f3a00e919ecaa9573adba34a78274ce48c/rasterio/enums.py#L192-L200
51
+ class PhotometricInterp(Enum):
52
+ black = "MINISBLACK"
53
+ white = "MINISWHITE"
54
+ rgb = "RGB"
55
+ cmyk = "CMYK"
56
+ ycbcr = "YCbCr"
57
+ cielab = "CIELAB"
58
+ icclab = "ICCLAB"
59
+ itulab = "ITULAB"
async_geotiff/py.typed ADDED
File without changes
async_geotiff/tms.py ADDED
@@ -0,0 +1,141 @@
1
+ """Generate a Tile Matrix Set from a GeoTIFF file, using morecantile."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from typing import TYPE_CHECKING
7
+ from uuid import uuid4
8
+
9
+ from morecantile.commons import BoundingBox
10
+ from morecantile.models import (
11
+ CRS,
12
+ CRSWKT,
13
+ CRSUri,
14
+ TileMatrix,
15
+ TileMatrixSet,
16
+ TMSBoundingBox,
17
+ )
18
+ from morecantile.utils import meters_per_unit
19
+ from pydantic import AnyUrl
20
+
21
+ if TYPE_CHECKING:
22
+ from typing import Literal
23
+
24
+ import pyproj
25
+
26
+ from async_geotiff import GeoTIFF
27
+
28
+ _SCREEN_PIXEL_SIZE = 0.28e-3
29
+
30
+ __all__ = ["generate_tms"]
31
+
32
+
33
+ def generate_tms(
34
+ geotiff: GeoTIFF,
35
+ *,
36
+ id: str = str(uuid4()), # noqa: A002
37
+ ) -> TileMatrixSet:
38
+ """Generate a Tile Matrix Set from a GeoTIFF file.
39
+
40
+ Args:
41
+ geotiff: The GeoTIFF file to generate the TMS from.
42
+
43
+ Keyword Args:
44
+ id: The ID to assign to the Tile Matrix Set.
45
+
46
+ """
47
+ bounds = geotiff.bounds
48
+ crs = geotiff.crs
49
+ tr = geotiff.transform
50
+ blockxsize = geotiff._primary_ifd.tile_width # noqa: SLF001
51
+ blockysize = geotiff._primary_ifd.tile_height # noqa: SLF001
52
+
53
+ if blockxsize is None or blockysize is None:
54
+ raise ValueError("GeoTIFF must be tiled to generate a TMS.")
55
+
56
+ mpu = meters_per_unit(crs)
57
+
58
+ corner_of_origin: Literal["bottomLeft", "topLeft"] = (
59
+ "bottomLeft" if tr.e > 0 else "topLeft"
60
+ )
61
+
62
+ tile_matrices: list[TileMatrix] = []
63
+
64
+ for idx, overview in enumerate(reversed(geotiff.overviews)):
65
+ overview_tr = overview.transform
66
+ blockxsize = overview._ifd[1].tile_width # noqa: SLF001
67
+ blockysize = overview._ifd[1].tile_height # noqa: SLF001
68
+
69
+ if blockxsize is None or blockysize is None:
70
+ raise ValueError("GeoTIFF overviews must be tiled to generate a TMS.")
71
+
72
+ tile_matrices.append(
73
+ TileMatrix(
74
+ id=str(idx),
75
+ scaleDenominator=overview_tr.a * mpu / _SCREEN_PIXEL_SIZE,
76
+ cellSize=overview_tr.a,
77
+ cornerOfOrigin=corner_of_origin,
78
+ pointOfOrigin=(overview_tr.c, overview_tr.f),
79
+ tileWidth=blockxsize,
80
+ tileHeight=blockysize,
81
+ matrixWidth=math.ceil(overview.width / blockxsize),
82
+ matrixHeight=math.ceil(overview.height / blockysize),
83
+ ),
84
+ )
85
+
86
+ # Add the full-resolution level last
87
+ tile_matrices.append(
88
+ TileMatrix(
89
+ id=str(len(geotiff.overviews)),
90
+ scaleDenominator=tr.a * mpu / _SCREEN_PIXEL_SIZE,
91
+ cellSize=tr.a,
92
+ cornerOfOrigin=corner_of_origin,
93
+ pointOfOrigin=(tr.c, tr.f),
94
+ tileWidth=blockxsize,
95
+ tileHeight=blockysize,
96
+ matrixWidth=math.ceil(geotiff.width / blockxsize),
97
+ matrixHeight=math.ceil(geotiff.height / blockysize),
98
+ ),
99
+ )
100
+
101
+ bbox = BoundingBox(*bounds)
102
+ tms_crs = _parse_crs(crs)
103
+
104
+ return TileMatrixSet(
105
+ title="Generated TMS",
106
+ id=id,
107
+ crs=tms_crs,
108
+ boundingBox=TMSBoundingBox(
109
+ lowerLeft=(bbox.left, bbox.bottom),
110
+ upperRight=(bbox.right, bbox.top),
111
+ crs=tms_crs,
112
+ ),
113
+ tileMatrices=tile_matrices,
114
+ )
115
+
116
+
117
+ def _parse_crs(
118
+ crs: pyproj.CRS,
119
+ ) -> CRS:
120
+ """Parse a pyproj CRS into a morecantile CRSUri or CRSWKT.
121
+
122
+ Args:
123
+ crs: The pyproj CRS to parse.
124
+
125
+ """
126
+ if authority_code := crs.to_authority(min_confidence=20):
127
+ authority, code = authority_code
128
+ version = "0"
129
+ # if we have a version number in the authority, split it out
130
+ if "_" in authority:
131
+ authority, version = authority.split("_")
132
+
133
+ return CRS(
134
+ CRSUri(
135
+ uri=AnyUrl(
136
+ f"http://www.opengis.net/def/crs/{authority}/{version}/{code}",
137
+ ),
138
+ ),
139
+ )
140
+
141
+ return CRS(CRSWKT(wkt=crs.to_json_dict()))
@@ -0,0 +1,35 @@
1
+ Metadata-Version: 2.3
2
+ Name: async-geotiff
3
+ Version: 0.1.0b1
4
+ Summary: Async GeoTIFF reader for Python
5
+ Author: Kyle Barron
6
+ Author-email: Kyle Barron <kyle@developmentseed.org>
7
+ Requires-Dist: affine>=2.4.0
8
+ Requires-Dist: async-tiff>=0.4.0
9
+ Requires-Dist: numpy>2
10
+ Requires-Dist: pyproj>=3.7.2
11
+ Requires-Dist: morecantile>=7.0,<8.0 ; extra == 'morecantile'
12
+ Requires-Python: >=3.11
13
+ Provides-Extra: morecantile
14
+ Description-Content-Type: text/markdown
15
+
16
+ # async-geotiff
17
+
18
+ Async GeoTIFF and [Cloud-Optimized GeoTIFF][cogeo] (COG) reader for Python, wrapping [`async-tiff`].
19
+
20
+ [`async-tiff`]: https://github.com/developmentseed/async-tiff
21
+ [cogeo]: https://cogeo.org/
22
+
23
+ ## Project Goals:
24
+
25
+ - Support only for GeoTIFF and Cloud-Optimized GeoTIFF (COG) formats
26
+ - Support for reading only, no writing support
27
+ - Full type hinting.
28
+ - API similar to rasterio where possible.
29
+ - We won't support the full rasterio API, but we'll try to when it's possible to implement rasterio APIs with straightforward maintenance requirements.
30
+ - For methods where we do intentionally try to match with rasterio, the tests should match against rasterio.
31
+ - Initially, we'll try to support a core set of GeoTIFF formats. Obscure GeoTIFF files may not be supported.
32
+
33
+ ## References
34
+
35
+ - aiocogeo: https://github.com/geospatial-jeff/aiocogeo
@@ -0,0 +1,11 @@
1
+ async_geotiff/__init__.py,sha256=Z_g9Z04lgDNeIOI4Z3lXn74G_7MOntwcPgjWBYLIoUU,259
2
+ async_geotiff/_crs.py,sha256=upyEGDiuHyWniTD--blzujYHlXBvfHLCSPzgekvO26Q,22105
3
+ async_geotiff/_geotiff.py,sha256=hEC0hR5qTB0DGEe4Cvoaz_56D-zfZeT9UR4lmjJD77w,13123
4
+ async_geotiff/_overview.py,sha256=dsdbKpix3aebjtibfJSgLUbqSAY92tlMMcg3fVLFlfY,2423
5
+ async_geotiff/_version.py,sha256=WOBh553JMD6VV-k_R8km_U4yt5mzyrWhi4OeTSXNoJI,171
6
+ async_geotiff/enums.py,sha256=j-_K1UtI0tBzZ945auvhWZdktO1bgZp7Nf-K9c208IQ,1493
7
+ async_geotiff/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ async_geotiff/tms.py,sha256=55nXenfss_KX7I5bmcMwtTHZWzNYAwSqhfjqSQju7OI,3922
9
+ async_geotiff-0.1.0b1.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
10
+ async_geotiff-0.1.0b1.dist-info/METADATA,sha256=gvy7Kywt1fjDiFl-HWxfmHHi3knCrHrC5MPzzoshMUc,1300
11
+ async_geotiff-0.1.0b1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.8.24
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any