geo-activity-playground 0.10.0__tar.gz → 0.12.0__tar.gz

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.
Files changed (59) hide show
  1. geo_activity_playground-0.12.0/LICENSE +21 -0
  2. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/PKG-INFO +1 -2
  3. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/core/activities.py +9 -8
  4. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/core/activity_parsers.py +6 -0
  5. geo_activity_playground-0.12.0/geo_activity_playground/core/heatmap.py +292 -0
  6. geo_activity_playground-0.12.0/geo_activity_playground/core/test_tiles.py +23 -0
  7. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/core/tiles.py +26 -0
  8. geo_activity_playground-0.12.0/geo_activity_playground/explorer/clusters.py +272 -0
  9. geo_activity_playground-0.12.0/geo_activity_playground/explorer/converters.py +128 -0
  10. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/explorer/grid_file.py +78 -48
  11. geo_activity_playground-0.12.0/geo_activity_playground/heatmap.py +92 -0
  12. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/importers/directory.py +15 -3
  13. geo_activity_playground-0.12.0/geo_activity_playground/webui/activity_controller.py +174 -0
  14. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/app.py +18 -0
  15. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/entry_controller.py +1 -1
  16. geo_activity_playground-0.12.0/geo_activity_playground/webui/explorer_controller.py +114 -0
  17. geo_activity_playground-0.12.0/geo_activity_playground/webui/grayscale_tile_controller.py +16 -0
  18. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/heatmap_controller.py +14 -35
  19. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/templates/activity.html.j2 +35 -4
  20. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/templates/calendar.html.j2 +5 -2
  21. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/templates/explorer.html.j2 +73 -15
  22. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/pyproject.toml +2 -2
  23. geo_activity_playground-0.10.0/geo_activity_playground/core/heatmap.py +0 -37
  24. geo_activity_playground-0.10.0/geo_activity_playground/core/test_tiles.py +0 -15
  25. geo_activity_playground-0.10.0/geo_activity_playground/explorer/converters.py +0 -86
  26. geo_activity_playground-0.10.0/geo_activity_playground/heatmap.py +0 -251
  27. geo_activity_playground-0.10.0/geo_activity_playground/webui/activity_controller.py +0 -88
  28. geo_activity_playground-0.10.0/geo_activity_playground/webui/explorer_controller.py +0 -43
  29. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/__init__.py +0 -0
  30. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/__main__.py +0 -0
  31. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/core/__init__.py +0 -0
  32. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/core/config.py +0 -0
  33. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/core/coordinates.py +0 -0
  34. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/core/plots.py +0 -0
  35. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/core/sources.py +0 -0
  36. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/core/tasks.py +0 -0
  37. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/explorer/__init__.py +0 -0
  38. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/explorer/video.py +0 -0
  39. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/importers/strava_api.py +0 -0
  40. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/calendar_controller.py +0 -0
  41. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/eddington_controller.py +0 -0
  42. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/equipment_controller.py +0 -0
  43. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/static/android-chrome-192x192.png +0 -0
  44. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/static/android-chrome-384x384.png +0 -0
  45. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/static/apple-touch-icon.png +0 -0
  46. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/static/browserconfig.xml +0 -0
  47. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/static/favicon-16x16.png +0 -0
  48. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/static/favicon-32x32.png +0 -0
  49. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/static/favicon.ico +0 -0
  50. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/static/mstile-150x150.png +0 -0
  51. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/static/safari-pinned-tab.svg +0 -0
  52. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/static/site.webmanifest +0 -0
  53. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/templates/calendar-month.html.j2 +0 -0
  54. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/templates/eddington.html.j2 +0 -0
  55. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/templates/equipment.html.j2 +0 -0
  56. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/templates/heatmap.html.j2 +0 -0
  57. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/templates/index.html.j2 +0 -0
  58. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/templates/page.html.j2 +0 -0
  59. {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/templates/summary-statistics.html.j2 +0 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Martin Ueding
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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: geo-activity-playground
3
- Version: 0.10.0
3
+ Version: 0.12.0
4
4
  Summary: Analysis of geo data activities like rides, runs or hikes.
5
5
  License: MIT
6
6
  Author: Martin Ueding
@@ -26,7 +26,6 @@ Requires-Dist: pandas (>=2.0,<3.0)
26
26
  Requires-Dist: pyarrow (>=12.0.1,<13.0.0)
27
27
  Requires-Dist: python-dateutil (>=2.8.2,<3.0.0)
28
28
  Requires-Dist: requests (>=2.28.1,<3.0.0)
29
- Requires-Dist: scikit-learn (>=1.3.0,<2.0.0)
30
29
  Requires-Dist: scipy (>=1.8.1,<2.0.0)
31
30
  Requires-Dist: stravalib (>=1.3.3,<2.0.0)
32
31
  Requires-Dist: tcxreader (>=0.4.5,<0.5.0)
@@ -59,14 +59,15 @@ class ActivityRepository:
59
59
  df["time"] = [start + datetime.timedelta(seconds=t) for t in time]
60
60
  assert pd.api.types.is_dtype_equal(df["time"].dtype, "datetime64[ns, UTC]")
61
61
 
62
- df["distance/km"] = df["distance"] / 1000
63
-
64
- if "speed" not in df.columns:
65
- df["speed"] = (
66
- df["distance"].diff()
67
- / (df["time"].diff().dt.total_seconds() + 1e-3)
68
- * 3.6
69
- )
62
+ if "distance" in df.columns:
63
+ df["distance/km"] = df["distance"] / 1000
64
+
65
+ if "speed" not in df.columns:
66
+ df["speed"] = (
67
+ df["distance"].diff()
68
+ / (df["time"].diff().dt.total_seconds() + 1e-3)
69
+ * 3.6
70
+ )
70
71
 
71
72
  return df
72
73
 
@@ -159,6 +159,12 @@ def read_activity(path: pathlib.Path) -> pd.DataFrame:
159
159
  except AttributeError as e:
160
160
  print(df)
161
161
  print(df.dtypes)
162
+ types = {}
163
+ for elem in df["time"]:
164
+ t = str(type(elem))
165
+ if t not in types:
166
+ types[t] = elem
167
+ print(types)
162
168
  raise ActivityParseError(
163
169
  "It looks like the date parsing has gone wrong."
164
170
  ) from e
@@ -0,0 +1,292 @@
1
+ """
2
+ This code is based on https://github.com/remisalmon/Strava-local-heatmap.
3
+ """
4
+ import dataclasses
5
+ import functools
6
+ import logging
7
+ import pathlib
8
+
9
+ import matplotlib.pyplot as pl
10
+ import numpy as np
11
+ import pandas as pd
12
+
13
+ from geo_activity_playground.core.activities import ActivityRepository
14
+ from geo_activity_playground.core.tasks import work_tracker
15
+ from geo_activity_playground.core.tiles import get_tile
16
+ from geo_activity_playground.core.tiles import get_tile_upper_left_lat_lon
17
+ from geo_activity_playground.core.tiles import latlon_to_xy
18
+
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ @functools.cache
24
+ def get_all_points(repository: ActivityRepository) -> pd.DataFrame:
25
+ logger.info("Gathering all points …")
26
+ all_points_path = pathlib.Path("Cache/all_points.parquet")
27
+ if all_points_path.exists():
28
+ all_points = pd.read_parquet(all_points_path)
29
+ else:
30
+ all_points = pd.DataFrame()
31
+ new_shards = []
32
+ with work_tracker(pathlib.Path("Cache/task_all_points.json")) as tracker:
33
+ for activity in repository.iter_activities():
34
+ if activity.id in tracker:
35
+ continue
36
+ tracker.add(activity.id)
37
+
38
+ logger.info(f"Parsing points from {activity.id} …")
39
+ time_series = repository.get_time_series(activity.id)
40
+ if len(time_series) == 0 or "latitude" not in time_series.columns:
41
+ continue
42
+ new_shards.append(time_series[["latitude", "longitude"]])
43
+ logger.info("Concatenating shards …")
44
+ all_points = pd.concat([all_points] + new_shards)
45
+ all_points.to_parquet(all_points_path)
46
+ return all_points
47
+
48
+
49
+ @dataclasses.dataclass
50
+ class GeoBounds:
51
+ lat_min: float
52
+ lon_min: float
53
+ lat_max: float
54
+ lon_max: float
55
+
56
+
57
+ def get_bounds(lat_lon_data: np.array) -> GeoBounds:
58
+ return GeoBounds(*np.min(lat_lon_data, axis=0), *np.max(lat_lon_data, axis=0))
59
+
60
+
61
+ def add_margin(lower: float, upper: float) -> tuple[float, float]:
62
+ spread = upper - lower
63
+ margin = spread / 20
64
+ return max(0, lower - margin), upper + margin
65
+
66
+
67
+ def add_margin_to_geo_bounds(bounds: GeoBounds) -> GeoBounds:
68
+ lat_min, lat_max = add_margin(bounds.lat_min, bounds.lat_max)
69
+ lon_min, lon_max = add_margin(bounds.lon_min, bounds.lon_max)
70
+ return GeoBounds(lat_min, lon_min, lat_max, lon_max)
71
+
72
+
73
+ OSM_TILE_SIZE = 256 # OSM tile size in pixel
74
+ OSM_MAX_ZOOM = 19 # OSM maximum zoom level
75
+ MAX_TILE_COUNT = 2000 # maximum number of tiles to download
76
+
77
+
78
+ @dataclasses.dataclass
79
+ class TileBounds:
80
+ zoom: int
81
+ x_tile_min: int
82
+ x_tile_max: int
83
+ y_tile_min: int
84
+ y_tile_max: int
85
+
86
+ @property
87
+ def shape(self) -> tuple[int, int]:
88
+ return (
89
+ (self.y_tile_max - self.y_tile_min) * OSM_TILE_SIZE,
90
+ (self.x_tile_max - self.x_tile_min) * OSM_TILE_SIZE,
91
+ )
92
+
93
+
94
+ def geo_bounds_from_tile_bounds(tile_bounds: TileBounds) -> GeoBounds:
95
+ lat_max, lon_min = get_tile_upper_left_lat_lon(
96
+ tile_bounds.x_tile_min, tile_bounds.y_tile_min, tile_bounds.zoom
97
+ )
98
+ lat_min, lon_max = get_tile_upper_left_lat_lon(
99
+ tile_bounds.x_tile_max, tile_bounds.y_tile_max, tile_bounds.zoom
100
+ )
101
+ return GeoBounds(lat_min, lon_min, lat_max, lon_max)
102
+
103
+
104
+ def get_sensible_zoom_level(
105
+ bounds: GeoBounds, picture_size: tuple[int, int]
106
+ ) -> TileBounds:
107
+ zoom = OSM_MAX_ZOOM
108
+
109
+ while True:
110
+ x_tile_min, y_tile_max = map(
111
+ int, latlon_to_xy(bounds.lat_min, bounds.lon_min, zoom)
112
+ )
113
+ x_tile_max, y_tile_min = map(
114
+ int, latlon_to_xy(bounds.lat_max, bounds.lon_max, zoom)
115
+ )
116
+
117
+ x_tile_max += 1
118
+ y_tile_max += 1
119
+
120
+ if (x_tile_max - x_tile_min) * OSM_TILE_SIZE <= picture_size[0] and (
121
+ y_tile_max - y_tile_min
122
+ ) * OSM_TILE_SIZE <= picture_size[1]:
123
+ break
124
+
125
+ zoom -= 1
126
+
127
+ tile_count = (x_tile_max - x_tile_min) * (y_tile_max - y_tile_min)
128
+
129
+ if tile_count > MAX_TILE_COUNT:
130
+ raise RuntimeError("Zoom value too high, too many tiles to download")
131
+
132
+ return TileBounds(
133
+ zoom=zoom,
134
+ x_tile_min=x_tile_min,
135
+ x_tile_max=x_tile_max,
136
+ y_tile_min=y_tile_min,
137
+ y_tile_max=y_tile_max,
138
+ )
139
+
140
+
141
+ def build_map_from_tiles(tile_bounds: TileBounds) -> np.array:
142
+ background = np.zeros((*tile_bounds.shape, 3))
143
+
144
+ for x in range(tile_bounds.x_tile_min, tile_bounds.x_tile_max):
145
+ for y in range(tile_bounds.y_tile_min, tile_bounds.y_tile_max):
146
+ tile = np.array(get_tile(tile_bounds.zoom, x, y)) / 255
147
+
148
+ i = y - tile_bounds.y_tile_min
149
+ j = x - tile_bounds.x_tile_min
150
+
151
+ background[
152
+ i * OSM_TILE_SIZE : (i + 1) * OSM_TILE_SIZE,
153
+ j * OSM_TILE_SIZE : (j + 1) * OSM_TILE_SIZE,
154
+ :,
155
+ ] = tile[:, :, :3]
156
+
157
+ return background
158
+
159
+
160
+ def convert_to_grayscale(image: np.ndarray) -> np.ndarray:
161
+ image = np.sum(image * [0.2126, 0.7152, 0.0722], axis=2)
162
+ image = np.dstack((image, image, image))
163
+ return image
164
+
165
+
166
+ def crop_image_to_bounds(
167
+ image: np.ndarray, geo_bounds: GeoBounds, tile_bounds: TileBounds
168
+ ) -> np.ndarray:
169
+ min_x, min_y = latlon_to_xy(
170
+ geo_bounds.lat_max, geo_bounds.lon_min, tile_bounds.zoom
171
+ )
172
+ max_x, max_y = latlon_to_xy(
173
+ geo_bounds.lat_min, geo_bounds.lon_max, tile_bounds.zoom
174
+ )
175
+ min_x = int((min_x - tile_bounds.x_tile_min) * OSM_TILE_SIZE)
176
+ min_y = int((min_y - tile_bounds.y_tile_min) * OSM_TILE_SIZE)
177
+ max_x = int((max_x - tile_bounds.x_tile_min) * OSM_TILE_SIZE)
178
+ max_y = int((max_y - tile_bounds.y_tile_min) * OSM_TILE_SIZE)
179
+ image = image[min_y:max_y, min_x:max_x, :]
180
+ return image
181
+
182
+
183
+ def gaussian_filter(image, sigma):
184
+ # returns image filtered with a gaussian function of variance sigma**2
185
+ #
186
+ # input: image = numpy.ndarray
187
+ # sigma = float
188
+ # output: image = numpy.ndarray
189
+
190
+ i, j = np.meshgrid(
191
+ np.arange(image.shape[0]), np.arange(image.shape[1]), indexing="ij"
192
+ )
193
+
194
+ mu = (int(image.shape[0] / 2.0), int(image.shape[1] / 2.0))
195
+
196
+ gaussian = (
197
+ 1.0
198
+ / (2.0 * np.pi * sigma * sigma)
199
+ * np.exp(-0.5 * (((i - mu[0]) / sigma) ** 2 + ((j - mu[1]) / sigma) ** 2))
200
+ )
201
+
202
+ gaussian = np.roll(gaussian, (-mu[0], -mu[1]), axis=(0, 1))
203
+
204
+ image_fft = np.fft.rfft2(image)
205
+ gaussian_fft = np.fft.rfft2(gaussian)
206
+
207
+ image = np.fft.irfft2(image_fft * gaussian_fft)
208
+
209
+ return image
210
+
211
+
212
+ def build_heatmap_image(
213
+ lat_lon_data: np.ndarray, num_activities: int, tile_bounds: TileBounds
214
+ ) -> np.ndarray:
215
+ # fill trackpoints
216
+ sigma_pixel = 1
217
+
218
+ data = np.zeros(tile_bounds.shape)
219
+
220
+ xy_data = latlon_to_xy(lat_lon_data[:, 0], lat_lon_data[:, 1], tile_bounds.zoom)
221
+ xy_data = np.array(xy_data).T
222
+ xy_data = np.round(
223
+ (xy_data - [tile_bounds.x_tile_min, tile_bounds.y_tile_min]) * OSM_TILE_SIZE
224
+ ) # to supertile coordinates
225
+
226
+ for j, i in xy_data.astype(int):
227
+ data[
228
+ i - sigma_pixel : i + sigma_pixel, j - sigma_pixel : j + sigma_pixel
229
+ ] += 1.0
230
+
231
+ res_pixel = (
232
+ 156543.03
233
+ * np.cos(np.radians(np.mean(lat_lon_data[:, 0])))
234
+ / (2.0**tile_bounds.zoom)
235
+ ) # from https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames
236
+
237
+ # trackpoint max accumulation per pixel = 1/5 (trackpoint/meter) * res_pixel (meter/pixel) * activities
238
+ # (Strava records trackpoints every 5 meters in average for cycling activites)
239
+ m = np.round((1.0 / 5.0) * res_pixel * num_activities)
240
+
241
+ data[data > m] = m
242
+
243
+ # equalize histogram and compute kernel density estimation
244
+ data_hist, _ = np.histogram(data, bins=int(m + 1))
245
+
246
+ data_hist = np.cumsum(data_hist) / data.size # normalized cumulated histogram
247
+
248
+ for i in range(data.shape[0]):
249
+ for j in range(data.shape[1]):
250
+ data[i, j] = m * data_hist[int(data[i, j])] # histogram equalization
251
+
252
+ data = gaussian_filter(
253
+ data, float(sigma_pixel)
254
+ ) # kernel density estimation with normal kernel
255
+
256
+ data = (data - data.min()) / (data.max() - data.min()) # normalize to [0,1]
257
+
258
+ # colorize
259
+ cmap = pl.get_cmap("hot")
260
+
261
+ data_color = cmap(data)
262
+ data_color[data_color == cmap(0.0)] = 0.0 # remove background color
263
+ return data_color
264
+
265
+
266
+ def build_heatmap_tile(lat_lon_data: np.ndarray, tile_bounds: TileBounds) -> np.ndarray:
267
+ xy_data = latlon_to_xy(lat_lon_data[:, 0], lat_lon_data[:, 1], tile_bounds.zoom)
268
+ xy_data = np.array(xy_data).T
269
+
270
+ xy_data = np.round(
271
+ (xy_data - [tile_bounds.x_tile_min, tile_bounds.y_tile_min]) * OSM_TILE_SIZE
272
+ )
273
+ sigma_pixel = 1
274
+ data = np.zeros((OSM_TILE_SIZE, OSM_TILE_SIZE))
275
+ for j, i in xy_data.astype(int):
276
+ data[
277
+ i - sigma_pixel : i + sigma_pixel, j - sigma_pixel : j + sigma_pixel
278
+ ] += 1.0
279
+
280
+ np.log(data, where=data > 0, out=data)
281
+ data /= 6
282
+ data_max = data.max()
283
+ if data_max > 2:
284
+ logger.warning(f"Maximum data in tile: {data_max}")
285
+ data[data > 1.0] = 1.0
286
+
287
+ # colorize
288
+ cmap = pl.get_cmap("hot")
289
+
290
+ data_color = cmap(data)
291
+ data_color[data_color == cmap(0.0)] = 0.0 # remove background color
292
+ return data_color
@@ -0,0 +1,23 @@
1
+ from .tiles import compute_tile
2
+ from .tiles import get_tile_upper_left_lat_lon
3
+ from .tiles import interpolate_missing_tile
4
+
5
+
6
+ def test_rheinbach() -> None:
7
+ lat, lon = 50.6202, 6.9504
8
+ assert compute_tile(lat, lon, 14) == (8508, 5512)
9
+
10
+
11
+ def test_back() -> None:
12
+ tile_x, tile_y = 8508, 5512
13
+ zoom = 14
14
+ lat, lon = get_tile_upper_left_lat_lon(tile_x, tile_y, zoom)
15
+ print(lat, lon)
16
+ assert compute_tile(lat, lon, zoom) == (tile_x, tile_y)
17
+
18
+
19
+ def test_interpolate() -> None:
20
+ assert interpolate_missing_tile(1.25, 2.25, 2.5, 1.5) == (1, 1)
21
+ assert interpolate_missing_tile(2.5, 1.5, 1.25, 2.25) == (1, 1)
22
+ assert interpolate_missing_tile(2.25, 2.5, 1.75, 1.25) == (2, 1)
23
+ assert interpolate_missing_tile(1.25, 2.25, 2.25, 2.5) == None
@@ -3,6 +3,7 @@ import logging
3
3
  import math
4
4
  import pathlib
5
5
  import time
6
+ from typing import Optional
6
7
 
7
8
  import numpy as np
8
9
  import requests
@@ -20,6 +21,15 @@ def compute_tile(lat: float, lon: float, zoom: int) -> tuple[int, int]:
20
21
  return int(x * n), int(y * n)
21
22
 
22
23
 
24
+ def compute_tile_float(lat: float, lon: float, zoom: int) -> tuple[int, int]:
25
+ x = np.radians(lon)
26
+ y = np.arcsinh(np.tan(np.radians(lat)))
27
+ x = (1 + x / np.pi) / 2
28
+ y = (1 - y / np.pi) / 2
29
+ n = 2**zoom
30
+ return x * n, y * n
31
+
32
+
23
33
  def get_tile_upper_left_lat_lon(
24
34
  tile_x: int, tile_y: int, zoom: int
25
35
  ) -> tuple[float, float]:
@@ -79,3 +89,19 @@ def xy_to_latlon(x: float, y: float, zoom: int) -> tuple[float, float]:
79
89
  lat_rad = np.arctan(np.sinh(np.pi * (1.0 - 2.0 * y / n)))
80
90
  lat_deg = float(np.degrees(lat_rad))
81
91
  return lat_deg, lon_deg
92
+
93
+
94
+ def interpolate_missing_tile(
95
+ x1: float, y1: float, x2: float, y2: float
96
+ ) -> Optional[tuple[int, int]]:
97
+ # We are only interested in diagonal tile combinations, therefore we skip adjacent ones.
98
+ if int(x1) == int(x2) or int(y1) == int(y2):
99
+ return None
100
+
101
+ x_hat = int(max(x1, x2))
102
+ l = (x_hat - x1) / (x2 - x1)
103
+ y_hat = int(y1 + l * (y2 - y1))
104
+ if y_hat == int(y1):
105
+ return (int(x2), y_hat)
106
+ else:
107
+ return (int(x1), y_hat)