Skip to content

Commit 34c0308

Browse files
Merge pull request #191 from developmentseed/feature/add-bottom-left-support
add support for bottomLeft cornerOfOrigin
2 parents 7eca853 + 0a08d60 commit 34c0308

File tree

3 files changed

+150
-14
lines changed

3 files changed

+150
-14
lines changed

CHANGES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
* make sure that `morecantile.defaults.TileMatrixSets.get(name)` returns a copy of the TMS object
7575
* add `MORECANTILE_DEFAULT_GEOGRAPHIC_CRS` environment variable to control the default Geographic CRS
7676
* add `TileMatrixSet.set_geographic_crs(crs: pyproj.CRS)` method to overwrite the geographic CRS
77+
* add support for `BottomLeft` cornerOfOrigin TileMatrices
7778
* add python 3.14 support
7879

7980
## 6.2.0 (2024-12-19)

morecantile/models.py

Lines changed: 56 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -677,17 +677,19 @@ def custom(
677677
ordered_axes: Optional[List[str]] = None,
678678
screen_pixel_size: float = 0.28e-3,
679679
decimation_base: int = 2,
680+
corner_of_origin: Literal["topLeft", "bottomLeft"] = "topLeft",
681+
point_of_origin: List[float] = None,
680682
**kwargs: Any,
681683
):
682684
"""
683685
Construct a custom TileMatrixSet.
684686
685687
Attributes
686688
----------
687-
crs: pyproj.CRS
688-
Tile Matrix Set coordinate reference system
689689
extent: list
690690
Bounding box of the Tile Matrix Set, (left, bottom, right, top).
691+
crs: pyproj.CRS
692+
Tile Matrix Set coordinate reference system
691693
tile_width: int
692694
Width of each tile of this tile matrix in pixels (default is 256).
693695
tile_height: int
@@ -713,6 +715,10 @@ def custom(
713715
Rendering pixel size. 0.28 mm was the actual pixel size of a common display from 2005 and considered as standard by OGC.
714716
decimation_base: int, optional
715717
How tiles are divided at each zoom level (default is 2). Must be greater than 1.
718+
corner_of_origin: str, optional
719+
Corner of origin for the TMS, either 'topLeft' or 'bottomLeft'
720+
point_of_origin: list, optional
721+
Point of origin for the TMS, (x, y) coordinates in the TMS CRS.
716722
kwargs: Any
717723
Attributes to forward to the TileMatrixSet
718724
@@ -738,8 +744,20 @@ def custom(
738744
)
739745

740746
bbox = BoundingBox(*extent)
741-
x_origin = bbox.left if not is_inverted else bbox.top
742-
y_origin = bbox.top if not is_inverted else bbox.left
747+
if not point_of_origin:
748+
if corner_of_origin == "topLeft":
749+
x_origin = bbox.left if not is_inverted else bbox.top
750+
y_origin = bbox.top if not is_inverted else bbox.left
751+
point_of_origin = [x_origin, y_origin]
752+
elif corner_of_origin == "bottomLeft":
753+
x_origin = bbox.left if not is_inverted else bbox.bottom
754+
y_origin = bbox.bottom if not is_inverted else bbox.left
755+
point_of_origin = [x_origin, y_origin]
756+
else:
757+
raise ValueError(
758+
f"Invalid `corner_of_origin` value: {corner_of_origin}, must be either 'topLeft' or 'bottomLeft'"
759+
)
760+
743761
width = abs(bbox.right - bbox.left)
744762
height = abs(bbox.top - bbox.bottom)
745763
mpu = meters_per_unit(crs)
@@ -758,7 +776,8 @@ def custom(
758776
"id": str(zoom),
759777
"scaleDenominator": res * mpu / screen_pixel_size,
760778
"cellSize": res,
761-
"pointOfOrigin": [x_origin, y_origin],
779+
"cornerOfOrigin": corner_of_origin,
780+
"pointOfOrigin": point_of_origin,
762781
"tileWidth": tile_width,
763782
"tileHeight": tile_height,
764783
"matrixWidth": matrix_scale[0] * decimation_base**zoom,
@@ -830,6 +849,7 @@ def matrix(self, zoom: int) -> TileMatrix:
830849
id=str(int(tile_matrix.id) + 1),
831850
scaleDenominator=tile_matrix.scaleDenominator / factor,
832851
cellSize=tile_matrix.cellSize / factor,
852+
cornerOfOrigin=tile_matrix.cornerOfOrigin,
833853
pointOfOrigin=tile_matrix.pointOfOrigin,
834854
tileWidth=tile_matrix.tileWidth,
835855
tileHeight=tile_matrix.tileHeight,
@@ -981,8 +1001,14 @@ def _tile(
9811001
if not math.isinf(xcoord)
9821002
else 0
9831003
)
1004+
1005+
coord = (
1006+
(origin_y - ycoord)
1007+
if matrix.cornerOfOrigin == "topLeft"
1008+
else (ycoord - origin_y)
1009+
)
9841010
ytile = (
985-
math.floor((origin_y - ycoord) / float(matrix.cellSize * matrix.tileHeight))
1011+
math.floor(coord / float(matrix.cellSize * matrix.tileHeight))
9861012
if not math.isinf(ycoord)
9871013
else 0
9881014
)
@@ -1088,10 +1114,16 @@ def _ul(self, *tile: Tile) -> Coords:
10881114
if matrix.variableMatrixWidths is not None
10891115
else 1
10901116
)
1091-
return Coords(
1092-
origin_x + math.floor(t.x / cf) * matrix.cellSize * cf * matrix.tileWidth,
1093-
origin_y - t.y * matrix.cellSize * matrix.tileHeight,
1117+
x_coord = (
1118+
origin_x + math.floor(t.x / cf) * matrix.cellSize * cf * matrix.tileWidth
10941119
)
1120+
y_coord = (
1121+
origin_y - t.y * matrix.cellSize * matrix.tileHeight
1122+
if matrix.cornerOfOrigin == "topLeft"
1123+
else origin_y + t.y * matrix.cellSize * matrix.tileHeight
1124+
)
1125+
1126+
return Coords(x_coord, y_coord)
10951127

10961128
def _lr(self, *tile: Tile) -> Coords:
10971129
"""
@@ -1116,12 +1148,18 @@ def _lr(self, *tile: Tile) -> Coords:
11161148
if matrix.variableMatrixWidths is not None
11171149
else 1
11181150
)
1119-
return Coords(
1151+
x_coord = (
11201152
origin_x
1121-
+ (math.floor(t.x / cf) + 1) * matrix.cellSize * cf * matrix.tileWidth,
1122-
origin_y - (t.y + 1) * matrix.cellSize * matrix.tileHeight,
1153+
+ (math.floor(t.x / cf) + 1) * matrix.cellSize * cf * matrix.tileWidth
1154+
)
1155+
y_coord = (
1156+
origin_y - (t.y + 1) * matrix.cellSize * matrix.tileHeight
1157+
if matrix.cornerOfOrigin == "topLeft"
1158+
else origin_y + (t.y + 1) * matrix.cellSize * matrix.tileHeight
11231159
)
11241160

1161+
return Coords(x_coord, y_coord)
1162+
11251163
def xy_bounds(self, *tile: Tile) -> BoundingBox:
11261164
"""
11271165
Return the bounding box of the tile in TMS coordinate reference system.
@@ -1147,12 +1185,16 @@ def xy_bounds(self, *tile: Tile) -> BoundingBox:
11471185
)
11481186

11491187
left = origin_x + math.floor(t.x / cf) * matrix.cellSize * cf * matrix.tileWidth
1150-
top = origin_y - t.y * matrix.cellSize * matrix.tileHeight
11511188
right = (
11521189
origin_x
11531190
+ (math.floor(t.x / cf) + 1) * matrix.cellSize * cf * matrix.tileWidth
11541191
)
1155-
bottom = origin_y - (t.y + 1) * matrix.cellSize * matrix.tileHeight
1192+
if matrix.cornerOfOrigin == "topLeft":
1193+
top = origin_y - t.y * matrix.cellSize * matrix.tileHeight
1194+
bottom = origin_y - (t.y + 1) * matrix.cellSize * matrix.tileHeight
1195+
else:
1196+
bottom = origin_y + t.y * matrix.cellSize * matrix.tileHeight
1197+
top = origin_y + (t.y + 1) * matrix.cellSize * matrix.tileHeight
11561198

11571199
return BoundingBox(left, bottom, right, top)
11581200

tests/test_models.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -775,6 +775,99 @@ def test_geographic_crs(name, is_wgs84):
775775
assert (tms.geographic_crs == pyproj.CRS.from_epsg(4326)) == is_wgs84
776776

777777

778+
def test_bottomleft_origin():
779+
"""Create TMS with BottomLeft Origin."""
780+
wmTopLeft = morecantile.tms.get("WebMercatorQuad")
781+
782+
crs = pyproj.CRS.from_epsg(3857)
783+
extent = (
784+
-20037508.342789244,
785+
-20037508.342789244,
786+
20037508.342789244,
787+
20037508.342789244,
788+
)
789+
corner_of_origin = "bottomLeft"
790+
791+
tms = TileMatrixSet.custom(
792+
extent,
793+
crs,
794+
matrix_scale=[1, 1],
795+
minzoom=0,
796+
maxzoom=24,
797+
id="WebMercatorQuadBottomLeft",
798+
ordered_axes=["X", "Y"],
799+
corner_of_origin=corner_of_origin,
800+
)
801+
assert tms.matrix(0).pointOfOrigin == (-20037508.342789244, -20037508.342789244)
802+
assert tms._matrix_origin(tms.matrix(0)) == (
803+
-20037508.342789244,
804+
-20037508.342789244,
805+
)
806+
assert tms.xy_bounds(0, 0, 0) == wmTopLeft.xy_bounds(0, 0, 0)
807+
assert tms.bounds(0, 0, 0) == wmTopLeft.bounds(0, 0, 0)
808+
809+
assert tms.xy_bounds(0, 0, 1).left == -20037508.342789244
810+
assert tms.xy_bounds(0, 0, 1).bottom == -20037508.342789244
811+
assert tms.xy_bounds(1, 1, 1).top == 20037508.342789244
812+
assert tms.xy_bounds(1, 1, 1).right == 20037508.342789244
813+
814+
assert tms.tile(-180, -85, 0) == morecantile.Tile(x=0, y=0, z=0)
815+
assert tms.tile(-180, -85, 1) == morecantile.Tile(x=0, y=0, z=1)
816+
assert tms.tile(-180, 85, 1) == morecantile.Tile(x=0, y=1, z=1)
817+
818+
bounds = tms.xy_bounds(486, tms.matrix(10).matrixHeight - 1 - 332, 10)
819+
expected = wmTopLeft.xy_bounds(486, 332, 10)
820+
for a, b in zip(expected, bounds):
821+
assert round(a - b, 6) == pytest.approx(0)
822+
823+
824+
@pytest.mark.parametrize(
825+
("topLeft_Tile", "bottomLeft_Tile"),
826+
[
827+
(morecantile.Tile(10, 10, 10), morecantile.Tile(10, 1013, 10)),
828+
(morecantile.Tile(10, 1013, 10), morecantile.Tile(10, 10, 10)),
829+
# Check the Origin points
830+
(morecantile.Tile(0, 0, 10), morecantile.Tile(0, 1023, 10)),
831+
(morecantile.Tile(0, 1023, 10), morecantile.Tile(0, 0, 10)),
832+
# Check the end points
833+
(morecantile.Tile(1023, 0, 10), morecantile.Tile(1023, 1023, 10)),
834+
(morecantile.Tile(1023, 1023, 10), morecantile.Tile(1023, 0, 10)),
835+
# Zoom=0
836+
(morecantile.Tile(0, 0, 0), morecantile.Tile(0, 0, 0)),
837+
# zoom=1 on both edges of the zoom level
838+
(morecantile.Tile(0, 0, 1), morecantile.Tile(0, 1, 1)),
839+
(morecantile.Tile(0, 1, 1), morecantile.Tile(0, 0, 1)),
840+
# zoom=14 near the middle
841+
(
842+
morecantile.Tile(x=3413, y=6202, z=14),
843+
morecantile.Tile(x=3413, y=10181, z=14),
844+
),
845+
],
846+
)
847+
def test_topLeft_BottomLeft_bounds_equal_bounds(topLeft_Tile, bottomLeft_Tile):
848+
tmsTop = morecantile.tms.get("WebMercatorQuad")
849+
tmsBottom = TileMatrixSet.custom(
850+
(
851+
-20037508.342789244,
852+
-20037508.342789244,
853+
20037508.342789244,
854+
20037508.342789244,
855+
),
856+
pyproj.CRS.from_epsg(3857),
857+
matrix_scale=[1, 1],
858+
minzoom=0,
859+
maxzoom=24,
860+
id="WebMercatorQuadBottomLeft",
861+
ordered_axes=["X", "Y"],
862+
corner_of_origin="bottomLeft",
863+
)
864+
865+
bounds = tmsTop.xy_bounds(topLeft_Tile)
866+
bounds2 = tmsBottom.xy_bounds(bottomLeft_Tile)
867+
for a, b in zip(bounds, bounds2):
868+
assert round(a - b, 6) == 0
869+
870+
778871
def test_webmercator_bounds():
779872
"""Test WebMercatorQuad bounds.
780873

0 commit comments

Comments
 (0)