openrelief 0.1.0__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.
- openrelief/__init__.py +22 -0
- openrelief/backend.py +66 -0
- openrelief/cli.py +112 -0
- openrelief/color.py +42 -0
- openrelief/config.py +89 -0
- openrelief/core.py +253 -0
- openrelief/py.typed +0 -0
- openrelief/raster.py +183 -0
- openrelief/terrain.py +87 -0
- openrelief-0.1.0.dist-info/METADATA +208 -0
- openrelief-0.1.0.dist-info/RECORD +16 -0
- openrelief-0.1.0.dist-info/WHEEL +5 -0
- openrelief-0.1.0.dist-info/entry_points.txt +2 -0
- openrelief-0.1.0.dist-info/licenses/LICENSE +28 -0
- openrelief-0.1.0.dist-info/licenses/NOTICE +50 -0
- openrelief-0.1.0.dist-info/top_level.txt +1 -0
openrelief/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""rrim - Red Relief Image Map generation from Digital Elevation Models.
|
|
2
|
+
|
|
3
|
+
Public API
|
|
4
|
+
----------
|
|
5
|
+
from openrelief import ReliefConfig, relief_from_dems, relief_array
|
|
6
|
+
|
|
7
|
+
`relief_from_dems` is the high-level entry point: give it one or more DEM files
|
|
8
|
+
and it builds a seamless RRIM mosaic GeoTIFF (and optional PNG preview),
|
|
9
|
+
processing windows in parallel and using a GPU automatically when available.
|
|
10
|
+
"""
|
|
11
|
+
from .config import ReliefConfig
|
|
12
|
+
from .backend import gpu_available
|
|
13
|
+
from .core import relief_from_dems, relief_array
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
__all__ = [
|
|
17
|
+
"ReliefConfig",
|
|
18
|
+
"relief_from_dems",
|
|
19
|
+
"relief_array",
|
|
20
|
+
"gpu_available",
|
|
21
|
+
"__version__",
|
|
22
|
+
]
|
openrelief/backend.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Array-backend selection: NumPy (CPU) or CuPy (GPU).
|
|
2
|
+
|
|
3
|
+
The rest of the package is written against a generic array module ``xp`` so the
|
|
4
|
+
exact same kernels run on CPU and GPU. GPU is used automatically when CuPy is
|
|
5
|
+
installed *and* a CUDA device is visible, unless the caller forces a backend.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from typing import Any, Tuple
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
_FORCE = os.environ.get("OPENRELIEF_BACKEND", "").lower() # "cpu", "gpu", or ""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def gpu_available() -> bool:
|
|
18
|
+
"""True if CuPy is importable and at least one CUDA device is present."""
|
|
19
|
+
if _FORCE == "cpu":
|
|
20
|
+
return False
|
|
21
|
+
try:
|
|
22
|
+
import cupy as cp # noqa: F401
|
|
23
|
+
|
|
24
|
+
return cp.cuda.runtime.getDeviceCount() > 0
|
|
25
|
+
except Exception:
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_backend(use_gpu: bool | None = None) -> Tuple[Any, bool]:
|
|
30
|
+
"""Return ``(xp, is_gpu)``.
|
|
31
|
+
|
|
32
|
+
Parameters
|
|
33
|
+
----------
|
|
34
|
+
use_gpu : bool | None
|
|
35
|
+
None -> auto-detect (GPU if available)
|
|
36
|
+
True -> require GPU (raises if unavailable)
|
|
37
|
+
False -> force CPU / NumPy
|
|
38
|
+
"""
|
|
39
|
+
if _FORCE == "gpu":
|
|
40
|
+
use_gpu = True
|
|
41
|
+
elif _FORCE == "cpu":
|
|
42
|
+
use_gpu = False
|
|
43
|
+
|
|
44
|
+
if use_gpu is None:
|
|
45
|
+
use_gpu = gpu_available()
|
|
46
|
+
|
|
47
|
+
if use_gpu:
|
|
48
|
+
try:
|
|
49
|
+
import cupy as cp
|
|
50
|
+
|
|
51
|
+
if cp.cuda.runtime.getDeviceCount() == 0:
|
|
52
|
+
raise RuntimeError("No CUDA device visible")
|
|
53
|
+
return cp, True
|
|
54
|
+
except Exception as exc: # pragma: no cover - depends on hardware
|
|
55
|
+
if _FORCE == "gpu" or use_gpu is True:
|
|
56
|
+
raise RuntimeError(f"GPU backend requested but unavailable: {exc}")
|
|
57
|
+
return np, False
|
|
58
|
+
return np, False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def to_cpu(arr: Any) -> "np.ndarray":
|
|
62
|
+
"""Bring an array back to host memory as a NumPy array."""
|
|
63
|
+
get = getattr(arr, "get", None)
|
|
64
|
+
if callable(get):
|
|
65
|
+
return get()
|
|
66
|
+
return np.asarray(arr)
|
openrelief/cli.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Command-line interface: rrim INPUT... -o OUTPUT [options]"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from .config import ReliefConfig
|
|
8
|
+
from .backend import gpu_available
|
|
9
|
+
from .core import relief_from_dems
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
13
|
+
d = ReliefConfig()
|
|
14
|
+
p = argparse.ArgumentParser(
|
|
15
|
+
prog="openrelief",
|
|
16
|
+
description="Generate a Red Relief Image Map (RRIM) from DEM(s). "
|
|
17
|
+
"Accepts a file, a folder, a glob, or several paths; "
|
|
18
|
+
"multiple tiles are mosaicked seamlessly.",
|
|
19
|
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
20
|
+
)
|
|
21
|
+
p.add_argument("inputs", nargs="+",
|
|
22
|
+
help="DEM file(s), a directory of DEMs, or a glob pattern.")
|
|
23
|
+
p.add_argument("-o", "--output", required=True,
|
|
24
|
+
help="Output RGB GeoTIFF path.")
|
|
25
|
+
p.add_argument("--preview", default=None,
|
|
26
|
+
help="PNG preview path (default: OUTPUT with .png suffix).")
|
|
27
|
+
p.add_argument("--no-preview", action="store_true",
|
|
28
|
+
help="Skip the PNG preview.")
|
|
29
|
+
|
|
30
|
+
g = p.add_argument_group("openness")
|
|
31
|
+
g.add_argument("--radius", type=int, default=d.openness_radius,
|
|
32
|
+
help="Openness search length in pixels.")
|
|
33
|
+
g.add_argument("--directions", type=int, default=d.n_directions,
|
|
34
|
+
choices=[4, 8, 16], help="Azimuth directions for openness.")
|
|
35
|
+
g.add_argument("--openness-range", type=float, default=d.openness_range,
|
|
36
|
+
help="Differential openness (deg) mapped to black..white.")
|
|
37
|
+
g.add_argument("--openness-gamma", type=float, default=d.openness_gamma)
|
|
38
|
+
|
|
39
|
+
g = p.add_argument_group("slope / colour")
|
|
40
|
+
g.add_argument("--slope-max", type=float, default=d.slope_max,
|
|
41
|
+
help="Slope (deg) mapped to full red.")
|
|
42
|
+
g.add_argument("--slope-gamma", type=float, default=d.slope_gamma)
|
|
43
|
+
g.add_argument("--opacity", type=float, default=d.opacity,
|
|
44
|
+
help="Openness layer opacity over the slope layer (0..1).")
|
|
45
|
+
g.add_argument("--z-factor", type=float, default=d.z_factor,
|
|
46
|
+
help="Elevation unit -> ground unit multiplier "
|
|
47
|
+
"(e.g. 0.3048 if Z is feet and XY metres).")
|
|
48
|
+
|
|
49
|
+
g = p.add_argument_group("performance")
|
|
50
|
+
g.add_argument("--window", type=int, default=d.window_size,
|
|
51
|
+
help="Processing window size in pixels (when not --auto-window).")
|
|
52
|
+
g.add_argument("--jobs", type=int, default=d.n_jobs,
|
|
53
|
+
help="CPU worker processes (-1 = all cores).")
|
|
54
|
+
g.add_argument("--auto-window", action="store_true",
|
|
55
|
+
help="Size windows from the core count instead of --window.")
|
|
56
|
+
g.add_argument("--tiles-per-core", type=int, default=d.tiles_per_core,
|
|
57
|
+
help="With --auto-window, tiles per core (1 = one tile per "
|
|
58
|
+
"processor; higher = better load balancing).")
|
|
59
|
+
mx = g.add_mutually_exclusive_group()
|
|
60
|
+
mx.add_argument("--gpu", action="store_true", help="Force GPU (CuPy).")
|
|
61
|
+
mx.add_argument("--cpu", action="store_true", help="Force CPU.")
|
|
62
|
+
|
|
63
|
+
g = p.add_argument_group("output")
|
|
64
|
+
g.add_argument("--compress", default=d.compress,
|
|
65
|
+
choices=["deflate", "lzw", "zstd", "none"])
|
|
66
|
+
g.add_argument("--preview-size", type=int, default=d.preview_max_size,
|
|
67
|
+
help="Longest side of the PNG preview in pixels.")
|
|
68
|
+
g.add_argument("-q", "--quiet", action="store_true")
|
|
69
|
+
p.add_argument("--check-gpu", action="store_true",
|
|
70
|
+
help="Report whether a GPU backend is available and exit.")
|
|
71
|
+
return p
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def main(argv=None) -> int:
|
|
75
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
76
|
+
if "--check-gpu" in argv:
|
|
77
|
+
print("GPU available:", gpu_available())
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
args = build_parser().parse_args(argv)
|
|
81
|
+
|
|
82
|
+
use_gpu = True if args.gpu else (False if args.cpu else None)
|
|
83
|
+
cfg = ReliefConfig(
|
|
84
|
+
openness_radius=args.radius,
|
|
85
|
+
n_directions=args.directions,
|
|
86
|
+
openness_range=args.openness_range,
|
|
87
|
+
openness_gamma=args.openness_gamma,
|
|
88
|
+
slope_max=args.slope_max,
|
|
89
|
+
slope_gamma=args.slope_gamma,
|
|
90
|
+
opacity=args.opacity,
|
|
91
|
+
z_factor=args.z_factor,
|
|
92
|
+
window_size=args.window,
|
|
93
|
+
n_jobs=args.jobs,
|
|
94
|
+
auto_window=args.auto_window,
|
|
95
|
+
tiles_per_core=args.tiles_per_core,
|
|
96
|
+
use_gpu=use_gpu,
|
|
97
|
+
compress=args.compress,
|
|
98
|
+
make_preview=not args.no_preview,
|
|
99
|
+
preview_max_size=args.preview_size,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
relief_from_dems(args.inputs, args.output, cfg,
|
|
104
|
+
preview=args.preview, verbose=not args.quiet)
|
|
105
|
+
except Exception as exc:
|
|
106
|
+
print(f"openrelief: error: {exc}", file=sys.stderr)
|
|
107
|
+
return 1
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
raise SystemExit(main())
|
openrelief/color.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Compose the RRIM RGB image from slope and openness layers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .config import ReliefConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def compose_rgb(slope, pos_open, neg_open, cfg: ReliefConfig, xp: Any):
|
|
10
|
+
"""Return a uint8 array of shape (3, H, W) = the RRIM (R, G, B).
|
|
11
|
+
|
|
12
|
+
Red/slope layer : flat = white (255,255,255), steep = red (255,0,0).
|
|
13
|
+
Grey/openness : differential openness (pos-neg)/2, ridge bright / valley dark.
|
|
14
|
+
Blend : grey layer multiplied onto red layer at ``opacity``.
|
|
15
|
+
"""
|
|
16
|
+
# --- slope -> red saturation -------------------------------------------
|
|
17
|
+
s = xp.clip(slope / cfg.slope_max, 0.0, 1.0)
|
|
18
|
+
if cfg.slope_gamma != 1.0:
|
|
19
|
+
s = s ** cfg.slope_gamma
|
|
20
|
+
r = xp.full_like(slope, 255.0)
|
|
21
|
+
gb = 255.0 * (1.0 - s) # green & blue channels fade out with slope
|
|
22
|
+
|
|
23
|
+
# --- differential openness -> brightness -------------------------------
|
|
24
|
+
diff = 0.5 * (pos_open - neg_open)
|
|
25
|
+
rng = cfg.openness_range
|
|
26
|
+
g = xp.clip((diff + rng) / (2.0 * rng), 0.0, 1.0)
|
|
27
|
+
if cfg.openness_gamma != 1.0:
|
|
28
|
+
g = g ** cfg.openness_gamma
|
|
29
|
+
|
|
30
|
+
# --- multiply blend with opacity ---------------------------------------
|
|
31
|
+
a = cfg.opacity
|
|
32
|
+
factor = (1.0 - a) + a * g # in [1-a, 1]
|
|
33
|
+
|
|
34
|
+
R = r * factor
|
|
35
|
+
G = gb * factor
|
|
36
|
+
B = gb * factor
|
|
37
|
+
|
|
38
|
+
# No-data (NaN anywhere) -> black; clip and cast.
|
|
39
|
+
rgb = xp.stack([R, G, B], axis=0)
|
|
40
|
+
rgb = xp.where(xp.isfinite(rgb), rgb, 0.0)
|
|
41
|
+
rgb = xp.clip(rgb + 0.5, 0.0, 255.0).astype(xp.uint8)
|
|
42
|
+
return rgb
|
openrelief/config.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Configuration for RRIM generation.
|
|
2
|
+
|
|
3
|
+
Defaults reproduce the classic Asia Air Survey "Red Relief Image Map" look
|
|
4
|
+
(Chiba, Kaneta & Suzuki, 2008) built on topographic openness
|
|
5
|
+
(Yokoyama, Shirasawa & Pike, 2002):
|
|
6
|
+
|
|
7
|
+
* slope -> red saturation (flat = white, steep = red)
|
|
8
|
+
* differential -> brightness (ridge = light, valley = dark)
|
|
9
|
+
openness
|
|
10
|
+
* combined with a 50% "multiply" blend.
|
|
11
|
+
|
|
12
|
+
Every visual knob is exposed so the output can be tuned for a given terrain.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, asdict
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ReliefConfig:
|
|
22
|
+
# --- Openness -----------------------------------------------------------
|
|
23
|
+
openness_radius: int = 30
|
|
24
|
+
"""Search length L for openness, in pixels. Larger = broader relief,
|
|
25
|
+
more compute (cost is ~ O(8 * radius))."""
|
|
26
|
+
|
|
27
|
+
n_directions: int = 8
|
|
28
|
+
"""Number of azimuth directions sampled for openness (8 is standard)."""
|
|
29
|
+
|
|
30
|
+
# --- Elevation / units --------------------------------------------------
|
|
31
|
+
z_factor: float = 1.0
|
|
32
|
+
"""Multiplier applied to elevation values so they match the horizontal
|
|
33
|
+
ground unit of the DEM (e.g. set 0.3048 if Z is in feet but XY in metres,
|
|
34
|
+
or 3.28084 for the reverse). 1.0 means Z and XY already share a unit."""
|
|
35
|
+
|
|
36
|
+
# --- Slope -> red -------------------------------------------------------
|
|
37
|
+
slope_max: float = 50.0
|
|
38
|
+
"""Slope (degrees) that maps to fully saturated red. Steeper is clipped."""
|
|
39
|
+
|
|
40
|
+
slope_gamma: float = 1.0
|
|
41
|
+
"""Gamma applied to the normalised slope before colouring (<1 boosts gentle
|
|
42
|
+
slopes, >1 suppresses them)."""
|
|
43
|
+
|
|
44
|
+
# --- Differential openness -> brightness --------------------------------
|
|
45
|
+
openness_range: float = 30.0
|
|
46
|
+
"""Differential openness (degrees) mapped to the black..white range.
|
|
47
|
+
Brightness = clip(((pos-neg)/2 + range) / (2*range), 0, 1).
|
|
48
|
+
Using a fixed value keeps a multi-tile mosaic seam-free."""
|
|
49
|
+
|
|
50
|
+
openness_gamma: float = 1.0
|
|
51
|
+
"""Gamma applied to the normalised brightness layer."""
|
|
52
|
+
|
|
53
|
+
# --- Compositing --------------------------------------------------------
|
|
54
|
+
opacity: float = 0.5
|
|
55
|
+
"""Opacity of the grey openness layer multiplied onto the red slope layer
|
|
56
|
+
(0 = pure slope colour, 1 = pure openness shading). 0.5 is the AAS default."""
|
|
57
|
+
|
|
58
|
+
# --- Pipeline / performance --------------------------------------------
|
|
59
|
+
window_size: int = 2048
|
|
60
|
+
"""Side length (pixels) of the processing windows. Each window is handled
|
|
61
|
+
independently, enabling parallelism and bounded memory use."""
|
|
62
|
+
|
|
63
|
+
n_jobs: int = -1
|
|
64
|
+
"""Parallel worker processes for CPU windows (-1 = all cores). Ignored on
|
|
65
|
+
GPU, where windows are streamed sequentially to one device."""
|
|
66
|
+
|
|
67
|
+
auto_window: bool = False
|
|
68
|
+
"""If True, ignore ``window_size`` and size the windows from the worker
|
|
69
|
+
count so there are about ``n_jobs * tiles_per_core`` tiles in total."""
|
|
70
|
+
|
|
71
|
+
tiles_per_core: int = 4
|
|
72
|
+
"""Oversubscription factor for ``auto_window``. 4 keeps every core fed while
|
|
73
|
+
bounding memory; set to 1 for exactly one tile per processor (less balanced,
|
|
74
|
+
larger per-worker memory). Higher values balance better but add halo
|
|
75
|
+
overhead."""
|
|
76
|
+
|
|
77
|
+
use_gpu: Optional[bool] = None
|
|
78
|
+
"""None = auto (GPU if CuPy + CUDA present); True = force GPU; False = CPU."""
|
|
79
|
+
|
|
80
|
+
# --- Output -------------------------------------------------------------
|
|
81
|
+
compress: str = "deflate"
|
|
82
|
+
"""GeoTIFF compression (deflate, lzw, zstd, none)."""
|
|
83
|
+
|
|
84
|
+
make_preview: bool = True
|
|
85
|
+
preview_max_size: int = 4000
|
|
86
|
+
"""Longest side (pixels) of the PNG quick-look preview."""
|
|
87
|
+
|
|
88
|
+
def to_dict(self) -> dict:
|
|
89
|
+
return asdict(self)
|
openrelief/core.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""High-level orchestration: DEM(s) -> seamless RRIM mosaic GeoTIFF + preview.
|
|
2
|
+
|
|
3
|
+
Works with any DEM rasterio can read (GeoTIFF, USGS .dem, IMG, ASC, VRT, ...),
|
|
4
|
+
any CRS, and any pixel/elevation unit. Nothing about a particular dataset is
|
|
5
|
+
hard-coded: CRS, no-data, pixel size, dtype and extent are all read from the
|
|
6
|
+
source. Horizontal distances are derived in ground units (metres) so the same
|
|
7
|
+
algorithm is correct for projected and geographic DEMs alike.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import glob
|
|
12
|
+
import math
|
|
13
|
+
import os
|
|
14
|
+
import tempfile
|
|
15
|
+
import time
|
|
16
|
+
from typing import List, Optional, Sequence, Tuple, Union
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
import rasterio
|
|
20
|
+
|
|
21
|
+
from .backend import get_backend, to_cpu, gpu_available
|
|
22
|
+
from .config import ReliefConfig
|
|
23
|
+
from . import terrain, color, raster
|
|
24
|
+
|
|
25
|
+
PathLike = Union[str, os.PathLike]
|
|
26
|
+
|
|
27
|
+
_DEM_EXTS = (".tif", ".tiff", ".dem", ".img", ".asc", ".vrt", ".bil",
|
|
28
|
+
".flt", ".dt0", ".dt1", ".dt2", ".hgt", ".nc", ".grd")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# --------------------------------------------------------------------------- #
|
|
32
|
+
# Input resolution & geodesy helpers
|
|
33
|
+
# --------------------------------------------------------------------------- #
|
|
34
|
+
def _collect_inputs(inputs: Union[PathLike, Sequence[PathLike]]) -> List[str]:
|
|
35
|
+
"""Normalise a file, directory, glob, or list into a sorted file list."""
|
|
36
|
+
if isinstance(inputs, (str, os.PathLike)):
|
|
37
|
+
inputs = [inputs]
|
|
38
|
+
files: List[str] = []
|
|
39
|
+
for item in inputs:
|
|
40
|
+
item = os.fspath(item)
|
|
41
|
+
if os.path.isdir(item):
|
|
42
|
+
for f in sorted(os.listdir(item)):
|
|
43
|
+
if f.lower().endswith(_DEM_EXTS):
|
|
44
|
+
files.append(os.path.join(item, f))
|
|
45
|
+
elif any(ch in item for ch in "*?[") and not os.path.exists(item):
|
|
46
|
+
files.extend(sorted(glob.glob(item)))
|
|
47
|
+
else:
|
|
48
|
+
files.append(item)
|
|
49
|
+
if not files:
|
|
50
|
+
raise FileNotFoundError(f"No DEM files found in: {inputs}")
|
|
51
|
+
return files
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def ground_resolution(ds) -> Tuple[float, float]:
|
|
55
|
+
"""Pixel size in ground metres, regardless of CRS.
|
|
56
|
+
|
|
57
|
+
* projected CRS -> native pixel size scaled by the linear unit (m/ft/...);
|
|
58
|
+
* geographic CRS -> degrees converted to metres at the dataset centre.
|
|
59
|
+
"""
|
|
60
|
+
t = ds.transform
|
|
61
|
+
rx = abs(t.a)
|
|
62
|
+
ry = abs(t.e)
|
|
63
|
+
crs = ds.crs
|
|
64
|
+
if crs is None:
|
|
65
|
+
return rx, ry # unknown -> assume already metres
|
|
66
|
+
if crs.is_geographic:
|
|
67
|
+
lat = math.radians((ds.bounds.top + ds.bounds.bottom) / 2.0)
|
|
68
|
+
m_per_deg_lat = 111132.92 - 559.82 * math.cos(2 * lat) + 1.175 * math.cos(4 * lat)
|
|
69
|
+
m_per_deg_lon = 111412.84 * math.cos(lat) - 93.5 * math.cos(3 * lat)
|
|
70
|
+
return rx * m_per_deg_lon, ry * m_per_deg_lat
|
|
71
|
+
# projected: scale by linear unit (metres per CRS unit)
|
|
72
|
+
try:
|
|
73
|
+
factor = crs.linear_units_factor[1]
|
|
74
|
+
except Exception:
|
|
75
|
+
factor = 1.0
|
|
76
|
+
return rx * factor, ry * factor
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _choose_window(width: int, height: int, cfg: ReliefConfig,
|
|
80
|
+
is_gpu: bool) -> int:
|
|
81
|
+
"""Pick a window side length in pixels.
|
|
82
|
+
|
|
83
|
+
With ``cfg.auto_window`` (CPU only) the window is derived from the worker
|
|
84
|
+
count so the raster splits into roughly ``n_jobs * tiles_per_core`` square
|
|
85
|
+
tiles -- set ``tiles_per_core=1`` for one tile per processor. Otherwise the
|
|
86
|
+
explicit ``cfg.window_size`` is used.
|
|
87
|
+
"""
|
|
88
|
+
if not cfg.auto_window or is_gpu:
|
|
89
|
+
return cfg.window_size
|
|
90
|
+
n_jobs = cfg.n_jobs if cfg.n_jobs and cfg.n_jobs > 0 else (os.cpu_count() or 1)
|
|
91
|
+
target_tiles = max(1, n_jobs * max(1, cfg.tiles_per_core))
|
|
92
|
+
win = int(math.ceil(math.sqrt((width * height) / target_tiles)))
|
|
93
|
+
# round to a multiple of 256 for tidy I/O, keep within sane bounds
|
|
94
|
+
win = max(256, int(round(win / 256.0)) * 256)
|
|
95
|
+
return min(win, max(width, height))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# --------------------------------------------------------------------------- #
|
|
99
|
+
# Pure array kernel (public API)
|
|
100
|
+
# --------------------------------------------------------------------------- #
|
|
101
|
+
def relief_array(z, res_x: float, res_y: float, cfg: Optional[ReliefConfig] = None,
|
|
102
|
+
xp=None):
|
|
103
|
+
"""Compute an RRIM RGB array (3, H, W) uint8 from a 2-D elevation array.
|
|
104
|
+
|
|
105
|
+
``z`` may contain NaN for no-data. ``res_x``/``res_y`` are ground sample
|
|
106
|
+
distances in the same unit as the (z_factor-scaled) elevations.
|
|
107
|
+
"""
|
|
108
|
+
cfg = cfg or ReliefConfig()
|
|
109
|
+
if xp is None:
|
|
110
|
+
xp, _ = get_backend(cfg.use_gpu)
|
|
111
|
+
|
|
112
|
+
z = xp.asarray(z, dtype=xp.float32)
|
|
113
|
+
if cfg.z_factor != 1.0:
|
|
114
|
+
z = z * cfg.z_factor
|
|
115
|
+
|
|
116
|
+
slope = terrain.slope_degrees(z, res_x, res_y, xp)
|
|
117
|
+
pos, neg = terrain.openness(z, res_x, res_y, cfg.openness_radius, xp,
|
|
118
|
+
cfg.n_directions)
|
|
119
|
+
rgb = color.compose_rgb(slope, pos, neg, cfg, xp)
|
|
120
|
+
return to_cpu(rgb)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# --------------------------------------------------------------------------- #
|
|
124
|
+
# Per-window worker (top-level so it pickles for multiprocessing)
|
|
125
|
+
# --------------------------------------------------------------------------- #
|
|
126
|
+
def _process_tile(src_path: str, tile: raster.Tile, cfg: ReliefConfig,
|
|
127
|
+
res_x: float, res_y: float, use_gpu: bool):
|
|
128
|
+
xp, _ = get_backend(use_gpu)
|
|
129
|
+
with rasterio.open(src_path) as ds:
|
|
130
|
+
arr = ds.read(1, window=tile.read_window).astype("float32")
|
|
131
|
+
nodata = ds.nodata
|
|
132
|
+
if nodata is not None:
|
|
133
|
+
arr[arr == np.float32(nodata)] = np.nan
|
|
134
|
+
arr[~np.isfinite(arr)] = np.nan
|
|
135
|
+
|
|
136
|
+
# Skip windows that hold no data (common in sparse mosaics of scattered
|
|
137
|
+
# tiles): the output stays black without paying for the openness scan.
|
|
138
|
+
if not np.isfinite(arr).any():
|
|
139
|
+
inner = np.zeros((3, tile.height, tile.width), dtype=np.uint8)
|
|
140
|
+
return (tile.col_off, tile.row_off, tile.width, tile.height), inner
|
|
141
|
+
|
|
142
|
+
rgb = relief_array(arr, res_x, res_y, cfg, xp=xp) # full padded window
|
|
143
|
+
r0, r1 = tile.inner_slice[0].start, tile.inner_slice[0].stop
|
|
144
|
+
c0, c1 = tile.inner_slice[1].start, tile.inner_slice[1].stop
|
|
145
|
+
inner = rgb[:, r0:r1, c0:c1]
|
|
146
|
+
return (tile.col_off, tile.row_off, tile.width, tile.height), inner
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# --------------------------------------------------------------------------- #
|
|
150
|
+
# Main entry point
|
|
151
|
+
# --------------------------------------------------------------------------- #
|
|
152
|
+
def relief_from_dems(inputs: Union[PathLike, Sequence[PathLike]],
|
|
153
|
+
output: PathLike,
|
|
154
|
+
cfg: Optional[ReliefConfig] = None,
|
|
155
|
+
preview: Optional[PathLike] = None,
|
|
156
|
+
verbose: bool = True) -> dict:
|
|
157
|
+
"""Generate an RRIM GeoTIFF (and PNG preview) from one or more DEMs.
|
|
158
|
+
|
|
159
|
+
Parameters
|
|
160
|
+
----------
|
|
161
|
+
inputs : DEM file, directory of DEMs, glob, or list of paths.
|
|
162
|
+
output : destination RGB GeoTIFF path.
|
|
163
|
+
cfg : ReliefConfig (defaults reproduce the Asia Air Survey style).
|
|
164
|
+
preview : PNG path; defaults to ``output`` with a .png suffix when
|
|
165
|
+
``cfg.make_preview`` is True.
|
|
166
|
+
|
|
167
|
+
Returns a small summary dict.
|
|
168
|
+
"""
|
|
169
|
+
cfg = cfg or ReliefConfig()
|
|
170
|
+
output = os.fspath(output)
|
|
171
|
+
files = _collect_inputs(inputs)
|
|
172
|
+
|
|
173
|
+
xp, is_gpu = get_backend(cfg.use_gpu)
|
|
174
|
+
if verbose:
|
|
175
|
+
dev = "GPU (CuPy)" if is_gpu else f"CPU ({cfg.n_jobs if cfg.n_jobs>0 else os.cpu_count()} workers)"
|
|
176
|
+
print(f"[openrelief] {len(files)} input(s) -> {output}")
|
|
177
|
+
print(f"[openrelief] backend: {dev}")
|
|
178
|
+
|
|
179
|
+
workdir = tempfile.mkdtemp(prefix="openrelief_")
|
|
180
|
+
src_path, is_tmp_vrt = raster.open_source(files, workdir)
|
|
181
|
+
|
|
182
|
+
t_start = time.time()
|
|
183
|
+
with rasterio.open(src_path) as ds:
|
|
184
|
+
profile = ds.profile
|
|
185
|
+
width, height = ds.width, ds.height
|
|
186
|
+
res_x, res_y = ground_resolution(ds)
|
|
187
|
+
if verbose:
|
|
188
|
+
print(f"[openrelief] mosaic: {width} x {height} px, "
|
|
189
|
+
f"ground res ~{res_x:.3f} x {res_y:.3f} m, "
|
|
190
|
+
f"openness radius {cfg.openness_radius} px")
|
|
191
|
+
|
|
192
|
+
halo = cfg.openness_radius + 1
|
|
193
|
+
window = _choose_window(width, height, cfg, is_gpu)
|
|
194
|
+
tiles = raster.make_tiles(width, height, window, halo)
|
|
195
|
+
if verbose:
|
|
196
|
+
mode = "auto" if (cfg.auto_window and not is_gpu) else "fixed"
|
|
197
|
+
print(f"[openrelief] {len(tiles)} window(s) of {window} px ({mode}) "
|
|
198
|
+
f"(+{halo} px halo)")
|
|
199
|
+
|
|
200
|
+
os.makedirs(os.path.dirname(os.path.abspath(output)), exist_ok=True)
|
|
201
|
+
dst = raster.create_rgb_geotiff(output, profile, cfg.compress)
|
|
202
|
+
try:
|
|
203
|
+
done = 0
|
|
204
|
+
if is_gpu:
|
|
205
|
+
# One CUDA device: stream windows sequentially.
|
|
206
|
+
for tile in tiles:
|
|
207
|
+
(co, ro, w, h), inner = _process_tile(
|
|
208
|
+
src_path, tile, cfg, res_x, res_y, True)
|
|
209
|
+
dst.write(inner, window=rasterio.windows.Window(co, ro, w, h))
|
|
210
|
+
done += 1
|
|
211
|
+
if verbose and done % 10 == 0:
|
|
212
|
+
print(f"[openrelief] {done}/{len(tiles)} windows", flush=True)
|
|
213
|
+
else:
|
|
214
|
+
from joblib import Parallel, delayed
|
|
215
|
+
results = Parallel(n_jobs=cfg.n_jobs, backend="loky",
|
|
216
|
+
return_as="generator_unordered")(
|
|
217
|
+
delayed(_process_tile)(src_path, tile, cfg, res_x, res_y, False)
|
|
218
|
+
for tile in tiles)
|
|
219
|
+
for (co, ro, w, h), inner in results:
|
|
220
|
+
dst.write(inner, window=rasterio.windows.Window(co, ro, w, h))
|
|
221
|
+
done += 1
|
|
222
|
+
if verbose and done % 10 == 0:
|
|
223
|
+
print(f"[openrelief] {done}/{len(tiles)} windows", flush=True)
|
|
224
|
+
finally:
|
|
225
|
+
dst.close()
|
|
226
|
+
|
|
227
|
+
elapsed = time.time() - t_start
|
|
228
|
+
if verbose:
|
|
229
|
+
print(f"[openrelief] wrote {output} in {elapsed:.1f}s")
|
|
230
|
+
|
|
231
|
+
preview_path = None
|
|
232
|
+
if cfg.make_preview:
|
|
233
|
+
preview_path = os.fspath(preview) if preview else os.path.splitext(output)[0] + ".png"
|
|
234
|
+
raster.write_preview(output, preview_path, cfg.preview_max_size)
|
|
235
|
+
if verbose:
|
|
236
|
+
print(f"[openrelief] preview -> {preview_path}")
|
|
237
|
+
|
|
238
|
+
if is_tmp_vrt and os.path.exists(src_path):
|
|
239
|
+
try:
|
|
240
|
+
os.remove(src_path)
|
|
241
|
+
except OSError:
|
|
242
|
+
pass
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
"output": output,
|
|
246
|
+
"preview": preview_path,
|
|
247
|
+
"inputs": files,
|
|
248
|
+
"width": width,
|
|
249
|
+
"height": height,
|
|
250
|
+
"ground_res": (res_x, res_y),
|
|
251
|
+
"backend": "gpu" if is_gpu else "cpu",
|
|
252
|
+
"seconds": elapsed,
|
|
253
|
+
}
|
openrelief/py.typed
ADDED
|
File without changes
|
openrelief/raster.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Raster I/O: virtual mosaicking, windowing, GeoTIFF output, PNG preview.
|
|
2
|
+
|
|
3
|
+
A multi-tile DEM is exposed as one seamless dataset through a GDAL .vrt that we
|
|
4
|
+
write directly as XML -- this needs only rasterio (no gdalbuildvrt binary).
|
|
5
|
+
Windows are then read from the VRT with a halo so openness never sees a tile
|
|
6
|
+
edge, eliminating mosaic seams.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import xml.etree.ElementTree as ET
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Iterator, List, Optional, Sequence, Tuple
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import rasterio
|
|
17
|
+
from rasterio.windows import Window
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# --------------------------------------------------------------------------- #
|
|
21
|
+
# Virtual mosaic
|
|
22
|
+
# --------------------------------------------------------------------------- #
|
|
23
|
+
def build_vrt(dem_paths: Sequence[str], vrt_path: str) -> str:
|
|
24
|
+
"""Write a GDAL VRT mosaicking ``dem_paths`` into one virtual raster.
|
|
25
|
+
|
|
26
|
+
Assumes north-up, non-rotated tiles sharing CRS and pixel size (true for a
|
|
27
|
+
standard DEM tile set). Returns ``vrt_path``.
|
|
28
|
+
"""
|
|
29
|
+
if not dem_paths:
|
|
30
|
+
raise ValueError("No DEM files supplied.")
|
|
31
|
+
|
|
32
|
+
metas = []
|
|
33
|
+
for p in dem_paths:
|
|
34
|
+
with rasterio.open(p) as ds:
|
|
35
|
+
t = ds.transform
|
|
36
|
+
if t.b != 0 or t.d != 0:
|
|
37
|
+
raise ValueError(f"{p}: rotated rasters are not supported.")
|
|
38
|
+
metas.append({
|
|
39
|
+
"path": os.path.abspath(p),
|
|
40
|
+
"w": ds.width, "h": ds.height,
|
|
41
|
+
"left": t.c, "top": t.f,
|
|
42
|
+
"rx": t.a, "ry": t.e, # ry is negative (north-up)
|
|
43
|
+
"dtype": ds.dtypes[0],
|
|
44
|
+
"nodata": ds.nodata,
|
|
45
|
+
"crs": ds.crs,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
rx = metas[0]["rx"]
|
|
49
|
+
ry = metas[0]["ry"]
|
|
50
|
+
dtype = metas[0]["dtype"]
|
|
51
|
+
nodata = metas[0]["nodata"]
|
|
52
|
+
crs = metas[0]["crs"]
|
|
53
|
+
for m in metas:
|
|
54
|
+
if abs(m["rx"] - rx) > 1e-6 or abs(m["ry"] - ry) > 1e-6:
|
|
55
|
+
raise ValueError("All DEM tiles must share the same pixel size.")
|
|
56
|
+
|
|
57
|
+
minx = min(m["left"] for m in metas)
|
|
58
|
+
maxy = max(m["top"] for m in metas)
|
|
59
|
+
maxx = max(m["left"] + m["w"] * m["rx"] for m in metas)
|
|
60
|
+
miny = min(m["top"] + m["h"] * m["ry"] for m in metas)
|
|
61
|
+
|
|
62
|
+
width = int(round((maxx - minx) / rx))
|
|
63
|
+
height = int(round((maxy - miny) / -ry))
|
|
64
|
+
|
|
65
|
+
dt_map = {"float32": "Float32", "float64": "Float64", "int16": "Int16",
|
|
66
|
+
"int32": "Int32", "uint16": "UInt16", "uint8": "Byte"}
|
|
67
|
+
gdal_dt = dt_map.get(str(dtype), "Float32")
|
|
68
|
+
|
|
69
|
+
ds_el = ET.Element("VRTDataset", rasterXSize=str(width), rasterYSize=str(height))
|
|
70
|
+
if crs is not None:
|
|
71
|
+
ET.SubElement(ds_el, "SRS").text = crs.to_wkt()
|
|
72
|
+
ET.SubElement(ds_el, "GeoTransform").text = (
|
|
73
|
+
f"{minx:.10f}, {rx:.10f}, 0.0, {maxy:.10f}, 0.0, {ry:.10f}")
|
|
74
|
+
|
|
75
|
+
band = ET.SubElement(ds_el, "VRTRasterBand", dataType=gdal_dt, band="1")
|
|
76
|
+
if nodata is not None:
|
|
77
|
+
ET.SubElement(band, "NoDataValue").text = repr(nodata)
|
|
78
|
+
|
|
79
|
+
for m in metas:
|
|
80
|
+
dst_xoff = int(round((m["left"] - minx) / rx))
|
|
81
|
+
dst_yoff = int(round((maxy - m["top"]) / -ry))
|
|
82
|
+
src = ET.SubElement(band, "ComplexSource")
|
|
83
|
+
sp = ET.SubElement(src, "SourceFilename", relativeToVRT="0")
|
|
84
|
+
sp.text = m["path"]
|
|
85
|
+
ET.SubElement(src, "SourceBand").text = "1"
|
|
86
|
+
ET.SubElement(src, "SrcRect", xOff="0", yOff="0",
|
|
87
|
+
xSize=str(m["w"]), ySize=str(m["h"]))
|
|
88
|
+
ET.SubElement(src, "DstRect", xOff=str(dst_xoff), yOff=str(dst_yoff),
|
|
89
|
+
xSize=str(m["w"]), ySize=str(m["h"]))
|
|
90
|
+
if m["nodata"] is not None:
|
|
91
|
+
ET.SubElement(src, "NODATA").text = repr(m["nodata"])
|
|
92
|
+
|
|
93
|
+
ET.ElementTree(ds_el).write(vrt_path, encoding="utf-8", xml_declaration=False)
|
|
94
|
+
return vrt_path
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def open_source(dem_paths: Sequence[str], workdir: str) -> Tuple[str, bool]:
|
|
98
|
+
"""Return (path_to_open, is_temp_vrt). One file -> itself; many -> a VRT."""
|
|
99
|
+
dem_paths = list(dem_paths)
|
|
100
|
+
if len(dem_paths) == 1:
|
|
101
|
+
return dem_paths[0], False
|
|
102
|
+
vrt = os.path.join(workdir, "_rrim_mosaic.vrt")
|
|
103
|
+
build_vrt(dem_paths, vrt)
|
|
104
|
+
return vrt, True
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# --------------------------------------------------------------------------- #
|
|
108
|
+
# Windowing
|
|
109
|
+
# --------------------------------------------------------------------------- #
|
|
110
|
+
@dataclass
|
|
111
|
+
class Tile:
|
|
112
|
+
"""A processing window plus its halo. Coordinates are in source pixels."""
|
|
113
|
+
# interior (output) region
|
|
114
|
+
col_off: int
|
|
115
|
+
row_off: int
|
|
116
|
+
width: int
|
|
117
|
+
height: int
|
|
118
|
+
# padded read region (interior + halo, clipped to raster)
|
|
119
|
+
read_col_off: int
|
|
120
|
+
read_row_off: int
|
|
121
|
+
read_width: int
|
|
122
|
+
read_height: int
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def read_window(self) -> Window:
|
|
126
|
+
return Window(self.read_col_off, self.read_row_off,
|
|
127
|
+
self.read_width, self.read_height)
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def write_window(self) -> Window:
|
|
131
|
+
return Window(self.col_off, self.row_off, self.width, self.height)
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def inner_slice(self) -> Tuple[slice, slice]:
|
|
135
|
+
"""Slice that extracts the interior from a processed padded array."""
|
|
136
|
+
r0 = self.row_off - self.read_row_off
|
|
137
|
+
c0 = self.col_off - self.read_col_off
|
|
138
|
+
return (slice(r0, r0 + self.height), slice(c0, c0 + self.width))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def make_tiles(width: int, height: int, window: int, halo: int) -> List[Tile]:
|
|
142
|
+
tiles: List[Tile] = []
|
|
143
|
+
for row in range(0, height, window):
|
|
144
|
+
h = min(window, height - row)
|
|
145
|
+
rr0 = max(0, row - halo)
|
|
146
|
+
rr1 = min(height, row + h + halo)
|
|
147
|
+
for col in range(0, width, window):
|
|
148
|
+
w = min(window, width - col)
|
|
149
|
+
cc0 = max(0, col - halo)
|
|
150
|
+
cc1 = min(width, col + w + halo)
|
|
151
|
+
tiles.append(Tile(col, row, w, h,
|
|
152
|
+
cc0, rr0, cc1 - cc0, rr1 - rr0))
|
|
153
|
+
return tiles
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# --------------------------------------------------------------------------- #
|
|
157
|
+
# Output
|
|
158
|
+
# --------------------------------------------------------------------------- #
|
|
159
|
+
def create_rgb_geotiff(path: str, src_profile: dict, compress: str = "deflate"):
|
|
160
|
+
profile = dict(src_profile)
|
|
161
|
+
profile.update(
|
|
162
|
+
driver="GTiff", count=3, dtype="uint8", nodata=None,
|
|
163
|
+
tiled=True, blockxsize=512, blockysize=512,
|
|
164
|
+
compress=(None if compress.lower() == "none" else compress),
|
|
165
|
+
photometric="RGB", BIGTIFF="IF_SAFER",
|
|
166
|
+
)
|
|
167
|
+
profile.pop("nbits", None)
|
|
168
|
+
return rasterio.open(path, "w", **profile)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def write_preview(geotiff_path: str, png_path: str, max_size: int = 4000) -> str:
|
|
172
|
+
"""Downsample the RGB GeoTIFF to a PNG quick-look."""
|
|
173
|
+
from PIL import Image
|
|
174
|
+
|
|
175
|
+
with rasterio.open(geotiff_path) as ds:
|
|
176
|
+
scale = max(1, int(np.ceil(max(ds.width, ds.height) / max_size)))
|
|
177
|
+
out_w = max(1, ds.width // scale)
|
|
178
|
+
out_h = max(1, ds.height // scale)
|
|
179
|
+
data = ds.read(out_shape=(3, out_h, out_w),
|
|
180
|
+
resampling=rasterio.enums.Resampling.average)
|
|
181
|
+
img = np.transpose(data, (1, 2, 0)).astype("uint8")
|
|
182
|
+
Image.fromarray(img, mode="RGB").save(png_path)
|
|
183
|
+
return png_path
|
openrelief/terrain.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Backend-agnostic terrain kernels: slope and topographic openness.
|
|
2
|
+
|
|
3
|
+
Both functions accept an array module ``xp`` (numpy or cupy) and operate on a
|
|
4
|
+
single 2-D elevation tile (already scaled by ``z_factor``). NaN marks no-data
|
|
5
|
+
and is propagated/ignored throughout.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
from typing import Any, Tuple
|
|
11
|
+
|
|
12
|
+
# 8 compass directions as (dy, dx) unit steps.
|
|
13
|
+
_DIRS8 = [
|
|
14
|
+
(0, 1), (1, 1), (1, 0), (1, -1),
|
|
15
|
+
(0, -1), (-1, -1), (-1, 0), (-1, 1),
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _directions(n: int):
|
|
20
|
+
if n == 8:
|
|
21
|
+
return _DIRS8
|
|
22
|
+
if n == 4:
|
|
23
|
+
return [(0, 1), (1, 0), (0, -1), (-1, 0)]
|
|
24
|
+
if n == 16:
|
|
25
|
+
# 8 principal + 8 "knight"-style intermediates for finer azimuth cover
|
|
26
|
+
base = list(_DIRS8)
|
|
27
|
+
extra = [(1, 2), (2, 1), (2, -1), (1, -2),
|
|
28
|
+
(-1, -2), (-2, -1), (-2, 1), (-1, 2)]
|
|
29
|
+
return base + extra
|
|
30
|
+
return _DIRS8
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _shift(z, sy: int, sx: int, xp: Any):
|
|
34
|
+
"""Return an array s where s[i, j] = z[i + sy, j + sx], NaN out of bounds."""
|
|
35
|
+
h, w = z.shape
|
|
36
|
+
out = xp.full_like(z, xp.nan)
|
|
37
|
+
di0 = max(0, -sy)
|
|
38
|
+
di1 = min(h, h - sy)
|
|
39
|
+
dj0 = max(0, -sx)
|
|
40
|
+
dj1 = min(w, w - sx)
|
|
41
|
+
if di0 < di1 and dj0 < dj1:
|
|
42
|
+
out[di0:di1, dj0:dj1] = z[di0 + sy:di1 + sy, dj0 + sx:dj1 + sx]
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def slope_degrees(z, res_x: float, res_y: float, xp: Any):
|
|
47
|
+
"""Slope magnitude in degrees via central differences (Horn-style 2-cell)."""
|
|
48
|
+
dzdx = (_shift(z, 0, 1, xp) - _shift(z, 0, -1, xp)) / (2.0 * res_x)
|
|
49
|
+
dzdy = (_shift(z, 1, 0, xp) - _shift(z, -1, 0, xp)) / (2.0 * res_y)
|
|
50
|
+
return xp.degrees(xp.arctan(xp.sqrt(dzdx * dzdx + dzdy * dzdy)))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def openness(z, res_x: float, res_y: float, radius: int, xp: Any,
|
|
54
|
+
n_directions: int = 8) -> Tuple[Any, Any]:
|
|
55
|
+
"""Positive and negative topographic openness (degrees).
|
|
56
|
+
|
|
57
|
+
Yokoyama et al. (2002):
|
|
58
|
+
positive openness = mean_dir( 90 - max_L(elevation_angle) )
|
|
59
|
+
negative openness = mean_dir( 90 + min_L(elevation_angle) )
|
|
60
|
+
where elevation_angle is the angle (deg) from horizontal to the cell at
|
|
61
|
+
distance L along the azimuth (positive looking up).
|
|
62
|
+
|
|
63
|
+
Ridges yield high positive / low negative openness; valleys the reverse.
|
|
64
|
+
"""
|
|
65
|
+
dirs = _directions(n_directions)
|
|
66
|
+
pos_sum = xp.zeros_like(z)
|
|
67
|
+
neg_sum = xp.zeros_like(z)
|
|
68
|
+
|
|
69
|
+
for dy, dx in dirs:
|
|
70
|
+
step = math.hypot(dx * res_x, dy * res_y) # ground distance per step
|
|
71
|
+
max_ang = xp.full_like(z, -xp.inf) # steepest upward angle
|
|
72
|
+
min_ang = xp.full_like(z, xp.inf) # steepest downward angle
|
|
73
|
+
for k in range(1, radius + 1):
|
|
74
|
+
zs = _shift(z, dy * k, dx * k, xp)
|
|
75
|
+
ang = xp.degrees(xp.arctan((zs - z) / (step * k)))
|
|
76
|
+
# fmax/fmin ignore NaN, so out-of-bounds / no-data cells drop out.
|
|
77
|
+
max_ang = xp.fmax(max_ang, ang)
|
|
78
|
+
min_ang = xp.fmin(min_ang, ang)
|
|
79
|
+
# Cells that never saw a valid neighbour in this direction stay at
|
|
80
|
+
# +/-inf; mark them NaN so they don't poison the mean or the blend.
|
|
81
|
+
max_ang = xp.where(xp.isfinite(max_ang), max_ang, xp.nan)
|
|
82
|
+
min_ang = xp.where(xp.isfinite(min_ang), min_ang, xp.nan)
|
|
83
|
+
pos_sum = pos_sum + (90.0 - max_ang)
|
|
84
|
+
neg_sum = neg_sum + (90.0 + min_ang)
|
|
85
|
+
|
|
86
|
+
n = float(len(dirs))
|
|
87
|
+
return pos_sum / n, neg_sum / n
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: openrelief
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Red-shaded terrain relief (the Red Relief Image Map / RRIM technique) from Digital Elevation Models, with parallel CPU and optional GPU acceleration.
|
|
5
|
+
Author-email: Naveen <nvnsudharsan@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/nvnsudharsan/openrelief
|
|
8
|
+
Project-URL: Repository, https://github.com/nvnsudharsan/openrelief
|
|
9
|
+
Project-URL: Issues, https://github.com/nvnsudharsan/openrelief/issues
|
|
10
|
+
Keywords: red relief,RRIM,DEM,topographic openness,terrain,hillshade,lidar,GIS,raster,visualization
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
License-File: NOTICE
|
|
26
|
+
Requires-Dist: numpy>=1.21
|
|
27
|
+
Requires-Dist: rasterio>=1.3
|
|
28
|
+
Requires-Dist: scipy>=1.7
|
|
29
|
+
Requires-Dist: joblib>=1.4
|
|
30
|
+
Requires-Dist: Pillow>=9.0
|
|
31
|
+
Provides-Extra: gpu
|
|
32
|
+
Requires-Dist: cupy-cuda12x; extra == "gpu"
|
|
33
|
+
Provides-Extra: dev
|
|
34
|
+
Requires-Dist: pytest; extra == "dev"
|
|
35
|
+
Requires-Dist: build; extra == "dev"
|
|
36
|
+
Requires-Dist: twine; extra == "dev"
|
|
37
|
+
Dynamic: license-file
|
|
38
|
+
|
|
39
|
+
# openrelief — red-shaded terrain relief from DEMs
|
|
40
|
+
|
|
41
|
+
`openrelief` turns any Digital Elevation Model into a **red-shaded relief image**
|
|
42
|
+
that combines topographic slope with positive/negative **topographic openness** —
|
|
43
|
+
the visualization widely known as the *Red Relief Image Map* (RRIM). Give it a
|
|
44
|
+
DEM (or a folder/glob of DEM tiles) and it produces a seamless georeferenced RGB
|
|
45
|
+
GeoTIFF plus a PNG preview.
|
|
46
|
+
|
|
47
|
+
> **Note on naming and IP.** This is an independent, open-source reimplementation
|
|
48
|
+
> of a published method. The package is intentionally named `openrelief` and
|
|
49
|
+
> avoids the *RRIM* / *Red Relief Image Map* trademarks as branding. The RRIM
|
|
50
|
+
> method was developed and patented by Asia Air Survey Co., Ltd.; those patents
|
|
51
|
+
> appear to have expired, but you should verify this yourself before publishing
|
|
52
|
+
> or commercializing. See [NOTICE](NOTICE) for attribution, citation, trademark
|
|
53
|
+
> and patent details, and [LICENSE](LICENSE) for the code license.
|
|
54
|
+
|
|
55
|
+
* **Any DEM** rasterio can read: GeoTIFF, USGS `.dem`, ERDAS `.img`, ArcInfo
|
|
56
|
+
`.asc`, `.bil/.flt`, SRTM `.hgt`, `.vrt`, … — CRS, no-data, pixel size and
|
|
57
|
+
elevation unit are all read from the file, nothing is hard-coded.
|
|
58
|
+
* **Seamless mosaics** — multiple tiles are stitched through an in-memory VRT and
|
|
59
|
+
processed in overlapping windows (a halo equal to the openness radius), so
|
|
60
|
+
there are no seams at tile boundaries.
|
|
61
|
+
* **Parallel** across windows on the CPU (joblib) and **GPU-accelerated**
|
|
62
|
+
automatically when [CuPy](https://cupy.dev) + a CUDA device are present.
|
|
63
|
+
* **Fully tunable** — every visual knob (openness radius, slope clip, gammas,
|
|
64
|
+
blend opacity, …) is exposed, with defaults matching the classic look.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## How the image is built
|
|
69
|
+
|
|
70
|
+
For every cell the package computes:
|
|
71
|
+
|
|
72
|
+
1. **Slope** (degrees) → red saturation. Flat = white `(255,255,255)`, steep =
|
|
73
|
+
pure red `(255,0,0)`.
|
|
74
|
+
2. **Topographic openness** (Yokoyama et al., 2002) in `--directions` azimuths
|
|
75
|
+
out to `--radius` pixels: positive openness (high on ridges) and negative
|
|
76
|
+
openness (high in valleys).
|
|
77
|
+
3. **Differential openness** `(positive − negative) / 2` → brightness. Ridges
|
|
78
|
+
render light, valleys dark — this gives the floating-3D relief.
|
|
79
|
+
4. The grey openness layer is **multiply-blended** onto the red slope layer at
|
|
80
|
+
`--opacity` (0.5 by default). Method reference: Chiba, Kaneta & Suzuki (2008).
|
|
81
|
+
|
|
82
|
+
Horizontal distances are converted to **ground metres** automatically (linear
|
|
83
|
+
unit for projected CRSs; degrees→metres at the scene latitude for geographic
|
|
84
|
+
CRSs), so the relief is geometrically correct for any projection. If your
|
|
85
|
+
elevation unit differs from the horizontal unit, set `--z-factor` (e.g. `0.3048`
|
|
86
|
+
for feet-Z over metre-XY).
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Install
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
pip install -e . # from this folder
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Core deps: `numpy`, `rasterio`, `scipy`, `joblib`, `Pillow`.
|
|
97
|
+
|
|
98
|
+
**GPU (optional):** install the CuPy wheel for your CUDA toolkit, e.g.
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
pip install cupy-cuda12x # CUDA 12.x (or cupy-cuda11x)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
When CuPy and a GPU are present the GPU is used automatically. Check with:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
openrelief --check-gpu
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Command line
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
# Single DEM
|
|
116
|
+
openrelief dem.tif -o relief.tif
|
|
117
|
+
|
|
118
|
+
# A whole folder of tiles -> one seamless mosaic + preview
|
|
119
|
+
openrelief /path/to/dems/ -o area_relief.tif
|
|
120
|
+
|
|
121
|
+
# A glob, tuned for flat terrain, forced GPU
|
|
122
|
+
openrelief "tiles/*.dem" -o relief.tif --slope-max 15 --openness-range 12 --radius 40 --gpu
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Useful options (`openrelief -h` for all):
|
|
126
|
+
|
|
127
|
+
| Option | Meaning | Default |
|
|
128
|
+
|---|---|---|
|
|
129
|
+
| `--radius` | openness search length (px) | 30 |
|
|
130
|
+
| `--directions` | azimuths sampled (4/8/16) | 8 |
|
|
131
|
+
| `--openness-range` | diff-openness deg → black..white | 30 |
|
|
132
|
+
| `--slope-max` | slope deg → full red | 50 |
|
|
133
|
+
| `--slope-gamma` / `--openness-gamma` | tone curves | 1.0 |
|
|
134
|
+
| `--opacity` | openness layer blend (0..1) | 0.5 |
|
|
135
|
+
| `--z-factor` | elevation→ground unit multiplier | 1.0 |
|
|
136
|
+
| `--window` | processing window (px) | 2048 |
|
|
137
|
+
| `--jobs` | CPU workers (-1 = all cores) | -1 |
|
|
138
|
+
| `--auto-window` | size windows from the core count | off |
|
|
139
|
+
| `--tiles-per-core` | with `--auto-window`, tiles/core (1 = one tile per processor) | 4 |
|
|
140
|
+
| `--gpu` / `--cpu` | force a backend | auto |
|
|
141
|
+
| `--compress` | deflate/lzw/zstd/none | deflate |
|
|
142
|
+
| `--no-preview` / `--preview-size` | PNG quick-look | on / 4000 |
|
|
143
|
+
|
|
144
|
+
**Tuning tip:** flatter terrain wants smaller `--slope-max` and
|
|
145
|
+
`--openness-range` (more contrast); rugged terrain wants larger values. A larger
|
|
146
|
+
`--radius` broadens the relief but costs ~linearly more compute.
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## Python API
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
from openrelief import ReliefConfig, relief_from_dems, relief_array
|
|
154
|
+
|
|
155
|
+
# High-level: DEM(s) -> GeoTIFF + PNG
|
|
156
|
+
relief_from_dems("tiles/", "out.tif",
|
|
157
|
+
ReliefConfig(openness_radius=40, slope_max=20))
|
|
158
|
+
|
|
159
|
+
# Low-level: a NumPy elevation array -> (3, H, W) uint8 relief
|
|
160
|
+
import numpy as np
|
|
161
|
+
rgb = relief_array(dem_array, res_x=1.0, res_y=1.0,
|
|
162
|
+
cfg=ReliefConfig(openness_radius=24))
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`ReliefConfig` carries every parameter above; `use_gpu=None/True/False` controls
|
|
166
|
+
the backend. You can also force a backend globally with the
|
|
167
|
+
`OPENRELIEF_BACKEND=cpu|gpu` environment variable.
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## How it scales
|
|
172
|
+
|
|
173
|
+
Cost is roughly `O(8 · radius · pixels)`. The raster is split into independent
|
|
174
|
+
`--window`-sized tiles with a `radius`-pixel halo:
|
|
175
|
+
|
|
176
|
+
* **CPU:** windows are distributed over processes with joblib and streamed to the
|
|
177
|
+
output as they finish, so memory stays bounded regardless of mosaic size. By
|
|
178
|
+
default there are more windows than cores so a freed worker immediately picks
|
|
179
|
+
up the next one (dynamic load balancing). `--auto-window` instead sizes the
|
|
180
|
+
windows from the core count: `--tiles-per-core 1` gives one tile per
|
|
181
|
+
processor (simplest, but prone to stragglers and high per-worker memory),
|
|
182
|
+
while the default of 4 keeps every core fed. The pixels produced are identical
|
|
183
|
+
either way — only the scheduling changes.
|
|
184
|
+
* **GPU:** windows are streamed to the device sequentially; the per-window size
|
|
185
|
+
keeps GPU memory in check while still giving a large speed-up.
|
|
186
|
+
|
|
187
|
+
Output GeoTIFFs are tiled and compressed (`BIGTIFF=IF_SAFER`), so very large
|
|
188
|
+
mosaics are handled gracefully. Empty windows in a sparse mosaic are skipped.
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## Attribution & citation
|
|
193
|
+
|
|
194
|
+
This package reimplements published methods. Please credit the original authors;
|
|
195
|
+
full details and references are in [NOTICE](NOTICE).
|
|
196
|
+
|
|
197
|
+
* Chiba, T., Kaneta, S., & Suzuki, Y. (2008). *Red relief image map: new
|
|
198
|
+
visualization method for three-dimensional data.* ISPRS Archives,
|
|
199
|
+
XXXVII(B2), 1071–1076.
|
|
200
|
+
* Yokoyama, R., Shirasawa, M., & Pike, R. J. (2002). *Visualizing topography by
|
|
201
|
+
openness.* PE&RS 68(3), 257–265.
|
|
202
|
+
* Asia Air Survey — https://www.rrim.jp/en/
|
|
203
|
+
|
|
204
|
+
## License
|
|
205
|
+
|
|
206
|
+
Source code: MIT (see [LICENSE](LICENSE)). The MIT license covers this code only
|
|
207
|
+
and is not a patent grant; see [NOTICE](NOTICE) for the intellectual-property
|
|
208
|
+
note.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
openrelief/__init__.py,sha256=5l3qcGgpiSK_aXhiwNvD-I2tjrgG5VSpSHa2JQs-Ik8,661
|
|
2
|
+
openrelief/backend.py,sha256=RYaWvXxrNyqnCTcXqglVgybCbRV4MiTDem9H-EXBTM0,1904
|
|
3
|
+
openrelief/cli.py,sha256=OH3K5ZMSOGzkIZPdqy5EkuKumKMSBlVufBXQIrNID2c,4835
|
|
4
|
+
openrelief/color.py,sha256=wZbFPRkM_28zUe00B-Gg50_ZzKvLETSO62DGkPxJiZk,1543
|
|
5
|
+
openrelief/config.py,sha256=XBtTVdAnit4v6jHHgEpuhyReuH1LDvmXB1fumd81_BY,3662
|
|
6
|
+
openrelief/core.py,sha256=vlRWe8ZlHV4aBQCkY-yZEBPfFYQe8QdRW5GUSl7a1fo,10214
|
|
7
|
+
openrelief/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
openrelief/raster.py,sha256=MLvHISnwlfEYZxwPYCIujSGBRXkR_uW9KAjX-bW0Cpo,7005
|
|
9
|
+
openrelief/terrain.py,sha256=0fVjgJARltP7Y31Bu7y-T0WG-59n9bs2cIJtmdsN-Qo,3296
|
|
10
|
+
openrelief-0.1.0.dist-info/licenses/LICENSE,sha256=eq1u1IgZ8RHVKtunQpYMYIxRV00VdevUV4d_JZRgtlQ,1362
|
|
11
|
+
openrelief-0.1.0.dist-info/licenses/NOTICE,sha256=4POho4FmCMgSU7_ickQkYLwjSoZQiV4tpO9dpvU_JAI,2541
|
|
12
|
+
openrelief-0.1.0.dist-info/METADATA,sha256=rZQfSGuhgqi12D_6Bkq7B2BOECormJU14AlnWRqMDdQ,8476
|
|
13
|
+
openrelief-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
14
|
+
openrelief-0.1.0.dist-info/entry_points.txt,sha256=W90Bvy4XYqn_RtmJLsWgoT2INoKIjZc7uV-lrLGQYhg,51
|
|
15
|
+
openrelief-0.1.0.dist-info/top_level.txt,sha256=2Y3SrTxUdOn4cfUsuyqfrTWyHkDtXcIKKNUreT_hfM0,11
|
|
16
|
+
openrelief-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Naveen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
The MIT license above covers the source code of this package only. It is not a
|
|
26
|
+
patent grant and makes no representation about third-party intellectual
|
|
27
|
+
property. See NOTICE for attribution and an intellectual-property note about
|
|
28
|
+
the red-relief visualization technique this software implements.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
openrelief
|
|
2
|
+
==========
|
|
3
|
+
|
|
4
|
+
This software is an independent, open-source implementation of a terrain
|
|
5
|
+
visualization technique that combines topographic slope with positive and
|
|
6
|
+
negative topographic openness, rendered as a red-shaded relief image. The
|
|
7
|
+
technique is commonly known as the "Red Relief Image Map" (RRIM).
|
|
8
|
+
|
|
9
|
+
Attribution
|
|
10
|
+
-----------
|
|
11
|
+
The red-relief image map method was developed by Tatsuro Chiba, Shin-ichi
|
|
12
|
+
Kaneta and Yusuke Suzuki at Asia Air Survey Co., Ltd. The topographic openness
|
|
13
|
+
measure it builds on was introduced by Ryuzo Yokoyama, Michio Shirasawa and
|
|
14
|
+
Richard J. Pike. This package reimplements those published methods from the
|
|
15
|
+
academic literature; it is not produced, endorsed by, or affiliated with Asia
|
|
16
|
+
Air Survey Co., Ltd.
|
|
17
|
+
|
|
18
|
+
Trademarks
|
|
19
|
+
----------
|
|
20
|
+
"RRIM", "Red Relief Image Map", and "赤色立体地図" are used or claimed as
|
|
21
|
+
trademarks of Asia Air Survey Co., Ltd. Those names are referenced here only to
|
|
22
|
+
describe the method this software implements (nominative use). This package is
|
|
23
|
+
deliberately named "openrelief" and does not use those marks as its product
|
|
24
|
+
name or branding. Do not market derivatives of this software under those marks.
|
|
25
|
+
|
|
26
|
+
Intellectual-property note
|
|
27
|
+
--------------------------
|
|
28
|
+
The RRIM method was patented by Asia Air Survey Co., Ltd., with patent family
|
|
29
|
+
members filed in Japan, the United States, China and Taiwan (e.g. JP 3670274).
|
|
30
|
+
These patents derive from filings in the early 2000s and, based on the standard
|
|
31
|
+
~20-year patent term, the relevant patents appear to have expired. Patent
|
|
32
|
+
status is jurisdiction-specific and time-sensitive, and nothing here is legal
|
|
33
|
+
advice. Before publishing, redistributing, or building a commercial product on
|
|
34
|
+
this software, independently verify the current legal status of the relevant
|
|
35
|
+
patents in every jurisdiction that matters to you (e.g. J-PlatPat for Japan,
|
|
36
|
+
the USPTO / Google Patents for the United States), and consult a qualified
|
|
37
|
+
intellectual-property attorney if anything material depends on it.
|
|
38
|
+
|
|
39
|
+
Citation
|
|
40
|
+
--------
|
|
41
|
+
If you use this software in academic work, please cite the original methods:
|
|
42
|
+
|
|
43
|
+
Chiba, T., Kaneta, S., & Suzuki, Y. (2008). Red relief image map: new
|
|
44
|
+
visualization method for three-dimensional data. International Archives of
|
|
45
|
+
the Photogrammetry, Remote Sensing and Spatial Information Sciences,
|
|
46
|
+
XXXVII(B2), 1071-1076.
|
|
47
|
+
|
|
48
|
+
Yokoyama, R., Shirasawa, M., & Pike, R. J. (2002). Visualizing topography by
|
|
49
|
+
openness: a new application of image processing to digital elevation models.
|
|
50
|
+
Photogrammetric Engineering & Remote Sensing, 68(3), 257-265.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
openrelief
|