Skip to content

Commit

Permalink
Added optional query parameter noData
Browse files Browse the repository at this point in the history
  • Loading branch information
tebben committed Aug 8, 2024
1 parent deb3512 commit 39642af
Show file tree
Hide file tree
Showing 15 changed files with 226 additions and 127 deletions.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ Get a quantized mesh for tile index z, x, y. Set the minZoom value to retrieve e

- **cog**: Path or URL to COG file.
- **minZoom** : The min zoomlevel for the terrain. Default (0)
- **noData** : The value to use for NoData in COG. Default (0)
- **resamplingMethod** : Resampling method for COG: 'nearest', 'bilinear', 'cubic', 'cubic_spline', 'lanczos', 'average', 'mode', 'gauss', 'rms'. Default 'none'
- **skipCache** : Set to true to prevent loading tiles from the cache. Default (False)
- **meshingMethod**: The Meshing method to use: 'grid', 'martini', 'delatin'
Expand Down Expand Up @@ -246,7 +247,7 @@ The CTOD service has a very basic tile caching option, tiles can be retrieved an

### Nodata

Nodata values in the COG are automatically set to 0 else it is likely that the meshing will go wrong, for now nodata should be handled in the source data (COG) In a future version we can try to fill up the nodata values based on surrounding pixels.
Nodata values in the COG are automatically set to 0 else it is likely that the meshing will go wrong, for now nodata should be handled in the source data (COG) or pass `noData={value}` to the .terrain request to overwrite the default value `0`

### Used libraries

Expand Down
10 changes: 7 additions & 3 deletions ctod/core/cog/cog_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ def __init__(
y,
cog_processor: CogProcessor,
cog_reader_pool: CogReaderPool,
no_data: int,
resampling_method=None,
generate_normals=False,
):
Expand All @@ -35,10 +36,13 @@ def __init__(
self.y = y
self.cog_processor = cog_processor
self.cog_reader_pool = cog_reader_pool
self.no_data = no_data
self.resampling_method = resampling_method
self.generate_normals = generate_normals
self.key = generate_cog_cache_key(cog, cog_processor.get_name(), z, x, y)
self.tile_bounds = utils.get_tile_bounds(self.tms, self.x, self.y, self.z)
self.key = generate_cog_cache_key(
cog, cog_processor.get_name(), z, x, y)
self.tile_bounds = utils.get_tile_bounds(
self.tms, self.x, self.y, self.z)
self.is_out_of_bounds = False
self.data = None
self.processed_data = None
Expand Down Expand Up @@ -75,7 +79,7 @@ async def download_tile_async(self, executor: ThreadPoolExecutor):
def _download(self, reader: CogReader, loop):
kwargs = self.cog_processor.get_reader_kwargs()
dowloaded_data = reader.download_tile(
self.x, self.y, self.z, loop, self.resampling_method, **kwargs
self.x, self.y, self.z, loop, self.no_data, self.resampling_method, **kwargs
)

if dowloaded_data is not None:
Expand Down
23 changes: 15 additions & 8 deletions ctod/core/cog/reader/cog_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ def download_tile(
y: int,
z: int,
loop: AbstractEventLoop,
nodata: int,
resampling_method: str = None,
**kwargs: Any,
) -> ImageData:
Expand All @@ -69,12 +70,14 @@ def download_tile(
if z < self.safe_level:
if not self.unsafe:
logging.warning(
f"Skipping unsafe tile {self.cog} {z,x,y}, generate more overviews or use --unsafe to load anyway"
f"""Skipping unsafe tile {self.cog} {
z, x, y}, generate more overviews or use --unsafe to load anyway"""
)
return None
else:
logging.warning(
f"Loading unsafe tile {self.cog} {z,x,y}, consider generating more overviews"
f"""Loading unsafe tile {self.cog} {
z, x, y}, consider generating more overviews"""
)

if resampling_method is not None:
Expand All @@ -91,10 +94,11 @@ def download_tile(
image_data = self.rio_reader.tile(
tile_z=z, tile_x=x, tile_y=y, align_bounds_with_dataset=True, **kwargs
)
# For now set nodata to 0 if nodata is present in the metadata
# handle this better later

# Set nodata value
if self.nodata_value is not None:
image_data.data[image_data.data == self.nodata_value] = float(0)
image_data.data[image_data.data ==
self.nodata_value] = float(nodata)

return image_data

Expand Down Expand Up @@ -129,14 +133,16 @@ def _set_safe_level(self):
dataset_width = self.rio_reader.dataset.width
dataset_wgs_width = dataset_bounds.right - dataset_bounds.left
pixels_per_wgs = dataset_width / dataset_wgs_width
pixels_per_tile_downsampled = 256 * max(self.rio_reader.dataset.overviews(1))
pixels_per_tile_downsampled = 256 * \
max(self.rio_reader.dataset.overviews(1))

for z in range(0, 24):
tile_bounds = self.tms.xy_bounds(Tile(x=0, y=0, z=z))
tile_wgs = tile_bounds.right - tile_bounds.left
tile_wgs_clipped = min(tile_wgs, dataset_wgs_width)
tile_pixels_needed = tile_wgs_clipped * pixels_per_wgs
needed_tiles = math.ceil(tile_pixels_needed / pixels_per_tile_downsampled)
needed_tiles = math.ceil(
tile_pixels_needed / pixels_per_tile_downsampled)

if needed_tiles <= 4:
self.safe_level = z
Expand All @@ -147,7 +153,8 @@ def _set_nodata_value(self):

reader_info = self.rio_reader.info()
self.nodata_value = (
reader_info.nodata_value if hasattr(reader_info, "nodata_value") else None
reader_info.nodata_value if hasattr(
reader_info, "nodata_value") else None
)

def __del__(self):
Expand Down
57 changes: 33 additions & 24 deletions ctod/core/cog/reader/cog_reader_mosaic.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,34 +13,36 @@ class CogReaderMosaic:
"""A reader for a Cloud Optimized GeoTIFF. This class is used to pool readers to
avoid opening and closing the same file many times.
"""

def __init__(self, pool, config: Any, cog: str, tms: TileMatrixSet, unsafe: bool = False):
self.pool = pool
self.config = config
self.cog = cog
self.tms = tms
self.unsafe = unsafe
self.last_used = time.time()

def close(self):
"""Close the reader."""

# CogReaderMosaic doesn't have a reader itself, therefore it doesn't need to close
pass

def tiler(self, src_path: str, *args, **kwargs) -> ImageData:
future = asyncio.run_coroutine_threadsafe(self.pool.get_reader(src_path, self.tms), args[3])
future = asyncio.run_coroutine_threadsafe(
self.pool.get_reader(src_path, self.tms), args[3])
reader = future.result()

data = reader.download_tile(args[0], args[1], args[2], args[3], **kwargs)

data = reader.download_tile(
args[0], args[1], args[2], args[3], **kwargs)
reader.return_reader()

if not data:
raise TileOutsideBounds

return data
def download_tile(self, x: int, y: int, z: int, loop: asyncio.AbstractEventLoop, resampling_method: str = None, **kwargs: Any) -> ImageData:

def download_tile(self, x: int, y: int, z: int, loop: asyncio.AbstractEventLoop, no_data: int, resampling_method: str = None, **kwargs: Any) -> ImageData:
"""Retrieve an image from a Cloud Optimized GeoTIFF based on a tile index.
Args:
Expand All @@ -55,32 +57,39 @@ def download_tile(self, x: int, y: int, z: int, loop: asyncio.AbstractEventLoop,
Returns:
ImageData: Image data from the Cloud Optimized GeoTIFF.
"""

tile_bounds = self.tms.xy_bounds(Tile(x=x, y=y, z=z))
datasets = self._get_intersecting_datasets(tile_bounds)

if len(datasets) == 0:
return None

if not self._tile_intersects(tile_bounds, self.config["extent"]) or len(datasets) == 0:
return None

if not self.unsafe and len(datasets) > 10:
logging.warning(f"Too many datasets intersecting with requested tile {z,x,y}, {len(datasets)}")
logging.warning(f"""Too many datasets intersecting with requested tile {
z, x, y}, {len(datasets)}""")
return None

if resampling_method is not None:
kwargs["resampling_method"] = resampling_method

try:
img, _ = mosaic_reader(datasets, self.tiler, x, y, z, loop, **kwargs)
img, _ = mosaic_reader(datasets, self.tiler,
x, y, z, loop, **kwargs)

# Set nodata value
if self.nodata_value is not None:
img.data[img.data == self.nodata_value] = float(no_data)

return img
except Exception as e:
return None
return None

def return_reader(self):
"""Done with the reader, return it to the pool."""

self.last_used = time.time()
self.pool.return_reader(self)

Expand All @@ -89,9 +98,9 @@ def _get_intersecting_datasets(self, tile_bounds: BoundingBox) -> list:
for dataset in self.config["datasets"]:
if self._tile_intersects(tile_bounds, dataset["extent"]):
intersecting_datasets.append(dataset["path"])

return intersecting_datasets

def _tile_intersects(self, tile_bounds: BoundingBox, dataset_bounds: list) -> bool:
"""Check if a tile intersects with a dataset.
Instead of check if inside we check if something is outside and
Expand All @@ -104,9 +113,9 @@ def _tile_intersects(self, tile_bounds: BoundingBox, dataset_bounds: list) -> bo
Returns:
bool: True if bounds intersect, False otherwise
"""

if (tile_bounds.left > dataset_bounds[2] or tile_bounds.right < dataset_bounds[0] or
tile_bounds.bottom > dataset_bounds[3] or tile_bounds.top < dataset_bounds[1]):
tile_bounds.bottom > dataset_bounds[3] or tile_bounds.top < dataset_bounds[1]):
return False

return True
30 changes: 17 additions & 13 deletions ctod/core/factory/terrain_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ async def handle_request(
terrain_request: TerrainRequest,
cog_reader_pool: CogReaderPool,
processor,
no_data: int,
) -> asyncio.Future:
"""Handle a TerrainRequest
Expand All @@ -46,14 +47,15 @@ async def handle_request(
Returns:
asyncio.Future: Future which will be set when the terrain is ready
"""

async with self.lock:
# add terrain_request to terrain_requests cache
self.terrain_requests[terrain_request.key] = terrain_request

# loop over wanted files and add to processing queue if needed
processing_queue_keys = set(item.key for item in self.processing_queue._queue)

# loop over wanted files and add to processing queue if needed
processing_queue_keys = set(
item.key for item in self.processing_queue._queue)

for wanted_file in terrain_request.wanted_files:
# Check if the cog request is not already in the cache or processing queue or open requests
if (
Expand All @@ -70,6 +72,7 @@ async def handle_request(
wanted_file.y,
processor,
cog_reader_pool,
no_data,
wanted_file.resampling_method,
wanted_file.generate_normals,
)
Expand All @@ -85,7 +88,7 @@ async def handle_request(
asyncio.create_task(self.cache_changed())

return await terrain_request.wait()

def start_periodic_check(self, interval: int = 5):
"""Start a task to periodically check the cache for expired items"""

Expand All @@ -107,7 +110,7 @@ async def _process_queue(self):

while not self.processing_queue.empty():
cog_request = await self.processing_queue.get()

self.open_requests.add(cog_request.key)
asyncio.create_task(self._process_cog_request(cog_request))
del cog_request
Expand All @@ -129,7 +132,7 @@ async def _process_cog_request(self, cog_request):

async def cache_changed(self, keys: list = None):
"""Triggered by the cache when a new item was added"""

# When checking if a cog request is already in cache, open requests
# when a new requests comes in we need to have it somewhere so we don't directly
# remove a key from the open_requests until we have it in the cache
Expand All @@ -138,7 +141,7 @@ async def cache_changed(self, keys: list = None):
self.open_requests = self.open_requests - set(keys)

# If already processing the list set rerun to True
# We don't want to queue since process_terrain_request should pick up
# We don't want to queue since process_terrain_request should pick up
# everything that is available for processing
if self.processing_terrain_requests:
self.processing_terrain_requests_rerun = True
Expand All @@ -149,7 +152,7 @@ async def cache_changed(self, keys: list = None):
async def _process_terrain_requests(self):
"""Check and run process on terrain requests when ready for processing"""

try:
try:
# Convert to use O(n) complexity instead of O(n^2) with set intersection
cache_keys = set(self.cache.keys)
terrain_keys = list(self.terrain_requests.items())
Expand Down Expand Up @@ -218,7 +221,7 @@ async def _cleanup(self):

def _get_executor(self):
"""Get the ThreadPoolExecutor"""

if self.executor is None:
self.executor = ThreadPoolExecutor(max_workers=20)

Expand All @@ -230,14 +233,15 @@ def _try_reset_executor(self):
This is to try to free up memory when idle but seems to have no effect
but has no negative impact either.
"""

if self.executor and self.executor._work_queue.empty():
self.executor.shutdown(wait=False)
self.executor = None

def _print_debug_info(self):
"""Print debug info about the factory and it's state"""

logging.info(
f"Factory: terrain reqs: {len(self.terrain_requests)}, cache size: {len(self.cache.keys)}, open requests: {len(self.open_requests)}, queue size: {self.processing_queue.qsize()}"
f"""Factory: terrain reqs: {len(self.terrain_requests)}, cache size: {len(self.cache.keys)}, open requests: {
len(self.open_requests)}, queue size: {self.processing_queue.qsize()}"""
)
15 changes: 8 additions & 7 deletions ctod/core/terrain/empty_tile.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,31 +9,32 @@
from morecantile import TileMatrixSet
from quantized_mesh_encoder.ecef import to_ecef

def generate_empty_tile(tms: TileMatrixSet, z: int, x: int, y: int) -> bytes:

def generate_empty_tile(tms: TileMatrixSet, z: int, x: int, y: int, no_data: int) -> bytes:
"""Generate an empty terrain tile for a tile index with geodetic surface normals
Args:
tms (TileMatrixSet): Tile matrix set to use
z (int): z tile index
x (int): x tile index
y (int): y tile index
no_data (int): no data value
Returns:
bytes: quantized mesh tile
"""

grid_vertices, grid_triangles = generate_grid(256, 256, 20, 20)
vertices_3d = np.column_stack((grid_vertices, np.zeros(grid_vertices.shape[0])))

vertices_3d = np.column_stack(
(grid_vertices, np.full(grid_vertices.shape[0], no_data)))
vertices_new = np.array(vertices_3d, dtype=np.float64)
triangles_new = np.array(grid_triangles, dtype=np.uint16)

bounds = utils.get_tile_bounds(tms, x, y, z)
rescaled_vertices = rescale_positions(vertices_new, bounds, flip_y=False)
cartesian = to_ecef(rescaled_vertices)

normals = generate_geodetic_normals(cartesian, triangles_new)
quantized = quantize(rescaled_vertices, triangles_new, normals)

return quantized

Loading

0 comments on commit 39642af

Please sign in to comment.