Skip to content

geotiff_layers

djangomain.dash_apps.geotiff_layers ¤

GeoTIFF layer utilities for the stations map Dash app.

Attributes¤

_TARGET_MAPBOX_COORDS_CRS = 'EPSG:4326' module-attribute ¤

Provides coordinates in lon/lat rather than Mercator meters.

_TARGET_RASTER_RENDER_CRS = 'EPSG:3857' module-attribute ¤

Provides Mercator meters for rendering raster data in Mapbox.

logger = logging.getLogger(__name__) module-attribute ¤

Classes¤

MapLayerImport ¤

Bases: PermissionsBase

Functions¤
clean() ¤

Validate the uploaded GeoTIFF and its transformed lon/lat bounds.

Source code in importing/models.py
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
def clean(self) -> None:
    """Validate the uploaded GeoTIFF and its transformed lon/lat bounds."""

    if not self.file:
        return

    import rasterio
    from rasterio.warp import transform_bounds

    file_obj = self.file.file
    try:
        with rasterio.MemoryFile(file_obj.read()) as memfile:
            with memfile.open() as dataset:
                if dataset.count == 0:
                    raise ValidationError(
                        {"file": "File contains no raster bands."}
                    )
                if dataset.crs is None:
                    raise ValidationError(
                        {"file": "File has no coordinate reference system (CRS)."}
                    )

                left, bottom, right, top = transform_bounds(
                    dataset.crs,
                    "EPSG:4326",
                    dataset.bounds.left,
                    dataset.bounds.bottom,
                    dataset.bounds.right,
                    dataset.bounds.top,
                )

                if not (-180 <= left <= 180 and -180 <= right <= 180):
                    raise ValidationError(
                        {
                            "file": (
                                "GeoTIFF longitude values are out of range "
                                "[-180, 180]."
                            )
                        }
                    )
                if not (-90 <= bottom <= 90 and -90 <= top <= 90):
                    raise ValidationError(
                        {
                            "file": (
                                "GeoTIFF latitude values are out of range "
                                "[-90, 90]."
                            )
                        }
                    )
    except ValidationError:
        raise
    except Exception as e:
        raise ValidationError(
            {"file": f"Could not open as a valid GeoTIFF or read coordinates: {e}"}
        )
    finally:
        file_obj.seek(0)

Functions¤

_bounds_to_lonlat_coordinates(bounds, src_crs) ¤

Transform a (left, bottom, right, top) bounds tuple into lon/lat corner coordinates ordered for mapbox image layers.

Parameters:

Name Type Description Default
bounds tuple

Sequence of (left, bottom, right, top) in src_crs units.

required
src_crs str

CRS the bounds are expressed in.

required

Returns:

Type Description
list[list[float]]

Corner coordinates ordered [top-left, top-right, bottom-right, bottom-left].

Source code in djangomain/dash_apps/geotiff_layers.py
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
def _bounds_to_lonlat_coordinates(bounds: tuple, src_crs: str) -> list[list[float]]:
    """Transform a (left, bottom, right, top) bounds tuple into lon/lat corner
    coordinates ordered for mapbox image layers.

    Args:
        bounds: Sequence of (left, bottom, right, top) in src_crs units.
        src_crs: CRS the bounds are expressed in.

    Returns:
        Corner coordinates ordered [top-left, top-right,
            bottom-right, bottom-left].
    """
    left, bottom, right, top = bounds

    try:
        left, bottom, right, top = transform_bounds(
            src_crs,
            _TARGET_MAPBOX_COORDS_CRS,
            left,
            bottom,
            right,
            top,
        )
    except Exception as exc:
        raise ValueError(
            "GeoTIFF coordinates could not be transformed to lon/lat."
        ) from exc

    if not (-180 <= left <= 180 and -180 <= right <= 180):
        raise ValueError("GeoTIFF coordinates must be valid lon/lat values.")
    if not (-90 <= bottom <= 90 and -90 <= top <= 90):
        raise ValueError("GeoTIFF coordinates must be valid lon/lat values.")

    return [
        [float(left), float(top)],
        [float(right), float(top)],
        [float(right), float(bottom)],
        [float(left), float(bottom)],
    ]

_build_image_payload(file_path) ¤

Read a georeferenced single-band GeoTIFF and render it for mapbox.

Pixels are warped to Web Mercator (EPSG:3857) so mapbox image placement on a Mercator basemap stays spatially consistent. Corner coordinates are then transformed back to lon/lat for the mapbox API.

Parameters:

Name Type Description Default
file_path str

Absolute path to the GeoTIFF file on disk.

required

Returns:

Type Description
dict[str, Any]

Payload containing image data URI and map coordinates.

Source code in djangomain/dash_apps/geotiff_layers.py
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
def _build_image_payload(file_path: str) -> dict[str, Any]:
    """Read a georeferenced single-band GeoTIFF and render it for mapbox.

    Pixels are warped to Web Mercator (EPSG:3857) so mapbox image placement on
    a Mercator basemap stays spatially consistent. Corner coordinates are then
    transformed back to lon/lat for the mapbox API.

    Args:
        file_path: Absolute path to the GeoTIFF file on disk.

    Returns:
        Payload containing image data URI and map
            coordinates.
    """
    with Reader(input=file_path, options={}) as src:
        dataset = src.dataset
        if dataset is None:
            raise ValueError("GeoTIFF dataset could not be opened.")
        img = src.preview(
            dst_crs=_TARGET_RASTER_RENDER_CRS,
        )
        coordinates = _bounds_to_lonlat_coordinates(
            dataset.bounds,
            src.crs,
        )

        band_stats = next(iter(src.statistics().values()))
        img.rescale(in_range=((band_stats.min, band_stats.max),))
        # This rescales the image pixel values to the full 0-255 range based on the min and max of the data, # noqa: E501
        # which can help improve contrast when rendering the image. Discussion bellow
        # https://github.com/developmentseed/titiler/discussions/494

        png_bytes = img.render(img_format="PNG", colormap=cmap.get("viridis"))

    encoded = base64.b64encode(png_bytes).decode("ascii")
    return {
        "image": f"data:image/png;base64,{encoded}",
        "coordinates": coordinates,
    }

available_map_layers_by_id(user) ¤

Return user-viewable GeoTIFF layers keyed by dash dropdown id.

Parameters:

Name Type Description Default
user Any | None

Django user used to resolve object-level permissions.

required

Returns:

Type Description
dict[str, dict[str, str]]

Layer metadata keyed by maplayer id. Returns an empty dict when the user is None or lookup fails.

Source code in djangomain/dash_apps/geotiff_layers.py
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
def available_map_layers_by_id(user: Any | None) -> dict[str, dict[str, str]]:
    """Return user-viewable GeoTIFF layers keyed by dash dropdown id.

    Args:
        user: Django user used to resolve object-level permissions.

    Returns:
        Layer metadata keyed by maplayer id. Returns an empty dict when the
            user is None or lookup fails.
    """
    if user is None:
        return {}

    try:
        queryset = get_objects_for_user(
            user,
            "importing.view_maplayerimport",
            klass=MapLayerImport,
        )
    except Exception as e:
        logger.error("Error occurred while fetching available map layers: %s", e)
        return {}

    layer_index = {}
    for layer in queryset.order_by("name", "pk"):
        layer_id = f"maplayer-{layer.pk}"
        layer_index[layer_id] = {
            "id": layer_id,
            "name": str(layer.name),
            "file_path": str(layer.file.path),
        }

    return layer_index

build_mapbox_layers(layers_raw, user) ¤

Build mapbox layout layers for currently visible spatial layers.

GeoTIFF sources are resolved server-side from currently authorised MapLayerImport objects and never from client store values.

Parameters:

Name Type Description Default
layers_raw list

Trusted spatial layer payload from server-side callback state.

required
user Any

Django user used for per-object authorization.

required

Returns:

Type Description
list[dict[str, Any]]

Mapbox image layer dictionaries for visible and authorized GeoTIFF layers.

Source code in djangomain/dash_apps/geotiff_layers.py
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
def build_mapbox_layers(layers_raw: list, user: Any) -> list[dict[str, Any]]:
    """Build mapbox layout layers for currently visible spatial layers.

    GeoTIFF sources are resolved server-side from currently authorised
    MapLayerImport objects and never from client store values.

    Args:
        layers_raw: Trusted spatial layer payload from server-side callback
            state.
        user: Django user used for per-object authorization.

    Returns:
        Mapbox image layer dictionaries for visible
            and authorized GeoTIFF layers.
    """
    map_layers = []
    available_layers = available_map_layers_by_id(user)

    for layer in layers_raw:
        if not layer["visible"]:
            continue

        resolved_layer = available_layers.get(layer["id"])
        if not resolved_layer:
            continue

        try:
            mtime = os.path.getmtime(resolved_layer["file_path"])
            payload = load_geotiff_payload(resolved_layer["file_path"], mtime)
        except (OSError, ValueError) as exc:
            logger.warning("Skipping map layer %s: %s", layer["id"], exc)
            continue

        map_layers.append(
            {
                "type": "raster",
                "sourcetype": "image",
                "source": payload["image"],
                "coordinates": payload["coordinates"],
                "opacity": 0.75,
                "below": "traces",
            }
        )

    return map_layers

load_geotiff_payload(file_path, _mtime) cached ¤

Load and cache GeoTIFF payload from disk.

The mtime parameter is not used directly but is part of the cache key, allowing the cache to invalidate when files are modified on disk.

Parameters:

Name Type Description Default
file_path str

Absolute path to the GeoTIFF file on disk.

required
_mtime float

File modification time used to invalidate cache when file changes.

required

Returns:

Type Description
dict[str, Any]

Payload containing image data URI and map coordinates.

Source code in djangomain/dash_apps/geotiff_layers.py
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
@lru_cache(maxsize=32)
def load_geotiff_payload(file_path: str, _mtime: float) -> dict[str, Any]:
    """Load and cache GeoTIFF payload from disk.

    The mtime parameter is not used directly but is part of the cache key,
    allowing the cache to invalidate when files are modified on disk.

    Args:
        file_path: Absolute path to the GeoTIFF file on disk.
        _mtime: File modification time used to invalidate cache when file
            changes.

    Returns:
        Payload containing image data URI and map
            coordinates.
    """
    return _build_image_payload(file_path)