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.
- geo_activity_playground-0.12.0/LICENSE +21 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/PKG-INFO +1 -2
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/core/activities.py +9 -8
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/core/activity_parsers.py +6 -0
- geo_activity_playground-0.12.0/geo_activity_playground/core/heatmap.py +292 -0
- geo_activity_playground-0.12.0/geo_activity_playground/core/test_tiles.py +23 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/core/tiles.py +26 -0
- geo_activity_playground-0.12.0/geo_activity_playground/explorer/clusters.py +272 -0
- geo_activity_playground-0.12.0/geo_activity_playground/explorer/converters.py +128 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/explorer/grid_file.py +78 -48
- geo_activity_playground-0.12.0/geo_activity_playground/heatmap.py +92 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/importers/directory.py +15 -3
- geo_activity_playground-0.12.0/geo_activity_playground/webui/activity_controller.py +174 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/app.py +18 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/entry_controller.py +1 -1
- geo_activity_playground-0.12.0/geo_activity_playground/webui/explorer_controller.py +114 -0
- geo_activity_playground-0.12.0/geo_activity_playground/webui/grayscale_tile_controller.py +16 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/heatmap_controller.py +14 -35
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/templates/activity.html.j2 +35 -4
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/templates/calendar.html.j2 +5 -2
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/templates/explorer.html.j2 +73 -15
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/pyproject.toml +2 -2
- geo_activity_playground-0.10.0/geo_activity_playground/core/heatmap.py +0 -37
- geo_activity_playground-0.10.0/geo_activity_playground/core/test_tiles.py +0 -15
- geo_activity_playground-0.10.0/geo_activity_playground/explorer/converters.py +0 -86
- geo_activity_playground-0.10.0/geo_activity_playground/heatmap.py +0 -251
- geo_activity_playground-0.10.0/geo_activity_playground/webui/activity_controller.py +0 -88
- geo_activity_playground-0.10.0/geo_activity_playground/webui/explorer_controller.py +0 -43
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/__init__.py +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/__main__.py +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/core/__init__.py +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/core/config.py +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/core/coordinates.py +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/core/plots.py +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/core/sources.py +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/core/tasks.py +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/explorer/__init__.py +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/explorer/video.py +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/importers/strava_api.py +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/calendar_controller.py +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/eddington_controller.py +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/equipment_controller.py +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/static/android-chrome-192x192.png +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/static/android-chrome-384x384.png +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/static/apple-touch-icon.png +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/static/browserconfig.xml +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/static/favicon-16x16.png +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/static/favicon-32x32.png +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/static/favicon.ico +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/static/mstile-150x150.png +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/static/safari-pinned-tab.svg +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/static/site.webmanifest +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/templates/calendar-month.html.j2 +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/templates/eddington.html.j2 +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/templates/equipment.html.j2 +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/templates/heatmap.html.j2 +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/templates/index.html.j2 +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.12.0}/geo_activity_playground/webui/templates/page.html.j2 +0 -0
- {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.
|
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
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
df["
|
67
|
-
|
68
|
-
|
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)
|