Skip to content

Commit 9429eeb

Browse files
authored
Merge pull request #123 from stac-utils/bbox=geometry
- Added check to verify that bbox matches item's polygon geometry
2 parents 2a9eab9 + cea6722 commit 9429eeb

File tree

5 files changed

+137
-1
lines changed

5 files changed

+137
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ The format is (loosely) based on [Keep a Changelog](http://keepachangelog.com/)
99
### Added
1010

1111
- Added sponsors and supporters section with logos ([#122](https://github.com/stac-utils/stac-check/pull/122))
12+
- Added check to verify that bbox matches item's polygon geometry ([#123](https://github.com/stac-utils/stac-check/pull/123))
1213
- Added configuration documentation to README ([#124](https://github.com/stac-utils/stac-check/pull/124))
1314

1415
### Updated

stac_check/lint.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -450,10 +450,68 @@ def check_geometry_null(self) -> bool:
450450
bool: A boolean indicating whether the geometry property is null (True) or not (False).
451451
"""
452452
if "geometry" in self.data:
453-
return self.data["geometry"] is None
453+
return self.data.get("geometry") is None
454454
else:
455455
return False
456456

457+
def check_bbox_matches_geometry(self) -> bool:
458+
"""Checks if the bbox of a STAC item matches its geometry.
459+
460+
This function verifies that the bounding box (bbox) accurately represents
461+
the minimum bounding rectangle of the item's geometry. It only applies to
462+
items with non-null geometry of type Polygon or MultiPolygon.
463+
464+
Returns:
465+
bool: True if the bbox matches the geometry or if the check is not applicable
466+
(e.g., null geometry or non-polygon type). False if there's a mismatch.
467+
"""
468+
# Skip check if geometry is null or bbox is not present
469+
if (
470+
"geometry" not in self.data
471+
or self.data.get("geometry") is None
472+
or "bbox" not in self.data
473+
or self.data.get("bbox") is None
474+
):
475+
return True
476+
477+
geometry = self.data.get("geometry")
478+
bbox = self.data.get("bbox")
479+
480+
# Only process Polygon and MultiPolygon geometries
481+
geom_type = geometry.get("type")
482+
if geom_type not in ["Polygon", "MultiPolygon"]:
483+
return True
484+
485+
# Extract coordinates based on geometry type
486+
coordinates = []
487+
if geom_type == "Polygon":
488+
# For Polygon, use the exterior ring (first element)
489+
if len(geometry.get("coordinates", [])) > 0:
490+
coordinates = geometry.get("coordinates")[0]
491+
elif geom_type == "MultiPolygon":
492+
# For MultiPolygon, collect all coordinates from all polygons
493+
for polygon in geometry.get("coordinates", []):
494+
if len(polygon) > 0:
495+
coordinates.extend(polygon[0])
496+
497+
# If no valid coordinates, skip check
498+
if not coordinates:
499+
return True
500+
501+
# Calculate min/max from coordinates
502+
lons = [coord[0] for coord in coordinates]
503+
lats = [coord[1] for coord in coordinates]
504+
505+
calc_bbox = [min(lons), min(lats), max(lons), max(lats)]
506+
507+
# Allow for small floating point differences (epsilon)
508+
epsilon = 1e-8
509+
for i in range(4):
510+
if abs(bbox[i] - calc_bbox[i]) > epsilon:
511+
return False
512+
513+
return True
514+
457515
def check_searchable_identifiers(self) -> bool:
458516
"""Checks if the identifiers of a STAC item are searchable, i.e.,
459517
they only contain lowercase letters, numbers, hyphens, and underscores.
@@ -616,6 +674,14 @@ def create_best_practices_dict(self) -> Dict:
616674
msg_1 = "All items should have a geometry field. STAC is not meant for non-spatial data"
617675
best_practices_dict["null_geometry"] = [msg_1]
618676

677+
# best practices - check if bbox matches geometry
678+
if (
679+
not self.check_bbox_matches_geometry()
680+
and config.get("check_bbox_geometry_match", True) == True
681+
):
682+
msg_1 = "The bbox field does not match the bounds of the geometry. The bbox should be the minimum bounding rectangle of the geometry."
683+
best_practices_dict["bbox_geometry_mismatch"] = [msg_1]
684+
619685
# check to see if there are too many links
620686
if (
621687
self.check_bloated_links(max_links=max_links)

stac_check/stac-check.config.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ linting:
1515
check_unlocated: true
1616
# best practices - recommend items have a geometry
1717
check_geometry: true
18+
# best practices - check if bbox matches the bounds of the geometry
19+
check_bbox_geometry_match: true
1820
# check to see if there are too many links
1921
bloated_links: true
2022
# best practices - check for bloated metadata in properties

tests/test.config.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ linting:
1515
check_unlocated: true
1616
# best practices - recommend items have a geometry
1717
check_geometry: true
18+
# best practices - check if bbox matches the bounds of the geometry
19+
check_bbox_geometry_match: true
1820
# check to see if there are too many links
1921
bloated_links: true
2022
# best practices - check for bloated metadata in properties

tests/test_lint.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,71 @@ def test_unlocated_item():
278278
assert linter.check_geometry_null() == True
279279

280280

281+
def test_bbox_matches_geometry():
282+
# Test with matching bbox and geometry
283+
file = "sample_files/1.0.0/core-item.json"
284+
linter = Linter(file)
285+
assert linter.check_bbox_matches_geometry() == True
286+
287+
# Test with mismatched bbox and geometry
288+
mismatched_item = {
289+
"stac_version": "1.0.0",
290+
"stac_extensions": [],
291+
"type": "Feature",
292+
"id": "test-item",
293+
"bbox": [100.0, 0.0, 105.0, 1.0], # Deliberately wrong bbox
294+
"geometry": {
295+
"type": "Polygon",
296+
"coordinates": [
297+
[
298+
[172.91173669923782, 1.3438851951615003],
299+
[172.95469614953714, 1.3438851951615003],
300+
[172.95469614953714, 1.3690476620161975],
301+
[172.91173669923782, 1.3690476620161975],
302+
[172.91173669923782, 1.3438851951615003],
303+
]
304+
],
305+
},
306+
"properties": {"datetime": "2020-12-11T22:38:32.125Z"},
307+
}
308+
linter = Linter(mismatched_item)
309+
assert linter.check_bbox_matches_geometry() == False
310+
311+
# Test with null geometry (should return True as check is not applicable)
312+
null_geom_item = {
313+
"stac_version": "1.0.0",
314+
"type": "Feature",
315+
"id": "test-item-null-geom",
316+
"bbox": [100.0, 0.0, 105.0, 1.0],
317+
"geometry": None,
318+
"properties": {"datetime": "2020-12-11T22:38:32.125Z"},
319+
}
320+
linter = Linter(null_geom_item)
321+
assert linter.check_bbox_matches_geometry() == True
322+
323+
# Test with missing bbox (should return True as check is not applicable)
324+
no_bbox_item = {
325+
"stac_version": "1.0.0",
326+
"type": "Feature",
327+
"id": "test-item-no-bbox",
328+
"geometry": {
329+
"type": "Polygon",
330+
"coordinates": [
331+
[
332+
[172.91173669923782, 1.3438851951615003],
333+
[172.95469614953714, 1.3438851951615003],
334+
[172.95469614953714, 1.3690476620161975],
335+
[172.91173669923782, 1.3690476620161975],
336+
[172.91173669923782, 1.3438851951615003],
337+
]
338+
],
339+
},
340+
"properties": {"datetime": "2020-12-11T22:38:32.125Z"},
341+
}
342+
linter = Linter(no_bbox_item)
343+
assert linter.check_bbox_matches_geometry() == True
344+
345+
281346
def test_bloated_item():
282347
file = "sample_files/1.0.0/core-item-bloated.json"
283348
linter = Linter(file)

0 commit comments

Comments
 (0)