geo-activity-playground 0.10.0__tar.gz → 0.11.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.10.0 → geo_activity_playground-0.11.0}/PKG-INFO +1 -2
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/core/activities.py +9 -8
- geo_activity_playground-0.11.0/geo_activity_playground/core/heatmap.py +292 -0
- geo_activity_playground-0.11.0/geo_activity_playground/core/test_tiles.py +23 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/core/tiles.py +26 -0
- geo_activity_playground-0.11.0/geo_activity_playground/explorer/clusters.py +260 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/explorer/converters.py +41 -13
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/explorer/grid_file.py +43 -42
- geo_activity_playground-0.11.0/geo_activity_playground/heatmap.py +92 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/importers/directory.py +15 -3
- geo_activity_playground-0.11.0/geo_activity_playground/webui/activity_controller.py +174 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/app.py +18 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/entry_controller.py +1 -1
- geo_activity_playground-0.11.0/geo_activity_playground/webui/explorer_controller.py +108 -0
- geo_activity_playground-0.11.0/geo_activity_playground/webui/grayscale_tile_controller.py +16 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/heatmap_controller.py +14 -35
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/templates/activity.html.j2 +35 -4
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/templates/calendar.html.j2 +5 -2
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/templates/explorer.html.j2 +56 -15
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/pyproject.toml +1 -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/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.11.0}/geo_activity_playground/__init__.py +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/__main__.py +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/core/__init__.py +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/core/activity_parsers.py +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/core/config.py +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/core/coordinates.py +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/core/plots.py +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/core/sources.py +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/core/tasks.py +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/explorer/__init__.py +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/explorer/video.py +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/importers/strava_api.py +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/calendar_controller.py +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/eddington_controller.py +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/equipment_controller.py +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/static/android-chrome-192x192.png +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/static/android-chrome-384x384.png +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/static/apple-touch-icon.png +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/static/browserconfig.xml +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/static/favicon-16x16.png +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/static/favicon-32x32.png +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/static/favicon.ico +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/static/mstile-150x150.png +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/static/safari-pinned-tab.svg +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/static/site.webmanifest +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/templates/calendar-month.html.j2 +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/templates/eddington.html.j2 +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/templates/equipment.html.j2 +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/templates/heatmap.html.j2 +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/templates/index.html.j2 +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/templates/page.html.j2 +0 -0
- {geo_activity_playground-0.10.0 → geo_activity_playground-0.11.0}/geo_activity_playground/webui/templates/summary-statistics.html.j2 +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: geo-activity-playground
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.11.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
|
|
@@ -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)
|
@@ -0,0 +1,260 @@
|
|
1
|
+
import itertools
|
2
|
+
import json
|
3
|
+
import logging
|
4
|
+
import pathlib
|
5
|
+
from typing import Iterator
|
6
|
+
|
7
|
+
import geojson
|
8
|
+
import pandas as pd
|
9
|
+
|
10
|
+
from geo_activity_playground.core.tiles import get_tile_upper_left_lat_lon
|
11
|
+
|
12
|
+
|
13
|
+
logger = logging.getLogger(__name__)
|
14
|
+
|
15
|
+
|
16
|
+
def adjacent_to(tile: tuple[int, int]) -> Iterator[tuple[int, int]]:
|
17
|
+
x, y = tile
|
18
|
+
yield (x + 1, y)
|
19
|
+
yield (x - 1, y)
|
20
|
+
yield (x, y + 1)
|
21
|
+
yield (x, y - 1)
|
22
|
+
|
23
|
+
|
24
|
+
class ExplorerClusterState:
|
25
|
+
def __init__(self, zoom: int) -> None:
|
26
|
+
self.num_neighbors: dict[tuple[int, int], int] = {}
|
27
|
+
self.cluster_tiles: dict[tuple[int, int], list[tuple[int, int]]] = {}
|
28
|
+
self.cluster_evolution = pd.DataFrame()
|
29
|
+
self.start = 0
|
30
|
+
|
31
|
+
self._state_path = pathlib.Path(f"Cache/explorer_cluster_{zoom}_state.json")
|
32
|
+
self._cluster_evolution_path = pathlib.Path(
|
33
|
+
f"Cache/explorer_cluster_{zoom}_evolution.parquet"
|
34
|
+
)
|
35
|
+
|
36
|
+
def load(self) -> None:
|
37
|
+
logger.info("Loading explorer cluster state …")
|
38
|
+
if self._state_path.exists():
|
39
|
+
with open(self._state_path) as f:
|
40
|
+
data = json.load(f)
|
41
|
+
self.num_neighbors = {
|
42
|
+
tuple(map(int, key.split("/"))): value
|
43
|
+
for key, value in data["num_neighbors"].items()
|
44
|
+
}
|
45
|
+
self.cluster_tiles = {
|
46
|
+
tuple(map(int, key.split("/"))): [
|
47
|
+
tuple(t) for t in data["clusters"][str(value)]
|
48
|
+
]
|
49
|
+
for key, value in data["memberships"].items()
|
50
|
+
}
|
51
|
+
self.start = data["start"]
|
52
|
+
|
53
|
+
if self._cluster_evolution_path.exists():
|
54
|
+
self.cluster_evolution = pd.read_parquet(self._cluster_evolution_path)
|
55
|
+
|
56
|
+
def save(self) -> None:
|
57
|
+
logger.info("Saving explorer cluster state …")
|
58
|
+
data = {
|
59
|
+
"num_neighbors": {
|
60
|
+
f"{x}/{y}": count for (x, y), count in self.num_neighbors.items()
|
61
|
+
},
|
62
|
+
"memberships": {
|
63
|
+
f"{x}/{y}": id(members)
|
64
|
+
for (x, y), members in self.cluster_tiles.items()
|
65
|
+
},
|
66
|
+
"clusters": {
|
67
|
+
id(members): members for members in self.cluster_tiles.values()
|
68
|
+
},
|
69
|
+
"start": self.start,
|
70
|
+
}
|
71
|
+
with open(self._state_path, "w") as f:
|
72
|
+
json.dump(data, f)
|
73
|
+
|
74
|
+
self.cluster_evolution.to_parquet(self._cluster_evolution_path)
|
75
|
+
|
76
|
+
|
77
|
+
def get_explorer_cluster_evolution(zoom: int) -> ExplorerClusterState:
|
78
|
+
tiles = pd.read_parquet(pathlib.Path(f"Cache/first_time_per_tile_{zoom}.parquet"))
|
79
|
+
tiles.sort_values("first_time", inplace=True)
|
80
|
+
|
81
|
+
s = ExplorerClusterState(zoom)
|
82
|
+
s.load()
|
83
|
+
|
84
|
+
logger.info("Compute new explorer cluster state …")
|
85
|
+
|
86
|
+
if len(s.cluster_evolution) > 0:
|
87
|
+
max_cluster_so_far = s.cluster_evolution["max_cluster_size"].iloc[-1]
|
88
|
+
else:
|
89
|
+
max_cluster_so_far = 0
|
90
|
+
|
91
|
+
rows = []
|
92
|
+
for index, row in tiles.iloc[s.start :].iterrows():
|
93
|
+
new_clusters = False
|
94
|
+
# Current tile.
|
95
|
+
tile = (row["tile_x"], row["tile_y"])
|
96
|
+
|
97
|
+
# This tile is new, therefore it doesn't have an entries in the neighbor list yet.
|
98
|
+
s.num_neighbors[tile] = 0
|
99
|
+
|
100
|
+
# Go through the adjacent tile and check whether there are neighbors.
|
101
|
+
for other in adjacent_to(tile):
|
102
|
+
if other in s.num_neighbors:
|
103
|
+
# The other tile is already visited. That means that the current tile has a neighbor.
|
104
|
+
s.num_neighbors[tile] += 1
|
105
|
+
# Alto the other tile has gained a neighbor.
|
106
|
+
s.num_neighbors[other] += 1
|
107
|
+
|
108
|
+
# If the current tile has all neighbors, make it it's own cluster.
|
109
|
+
if s.num_neighbors[tile] == 4:
|
110
|
+
s.cluster_tiles[tile] = [tile]
|
111
|
+
|
112
|
+
# Also make the adjacent tiles their own clusters, if they are full.
|
113
|
+
this_and_neighbors = [tile] + list(adjacent_to(tile))
|
114
|
+
for other in this_and_neighbors:
|
115
|
+
if s.num_neighbors.get(other, 0) == 4:
|
116
|
+
s.cluster_tiles[other] = [other]
|
117
|
+
|
118
|
+
for candidate in this_and_neighbors:
|
119
|
+
if candidate not in s.cluster_tiles:
|
120
|
+
continue
|
121
|
+
# The candidate is a cluster tile. Let's see whether any of the neighbors are also cluster tiles but with a different cluster. Then we need to join them.
|
122
|
+
for other in adjacent_to(candidate):
|
123
|
+
if other not in s.cluster_tiles:
|
124
|
+
continue
|
125
|
+
# The other tile is also a cluster tile.
|
126
|
+
if s.cluster_tiles[candidate] is s.cluster_tiles[other]:
|
127
|
+
continue
|
128
|
+
# The two clusters are not the same. We add the other's cluster tile to this tile.
|
129
|
+
s.cluster_tiles[candidate].extend(s.cluster_tiles[other])
|
130
|
+
# Update the other cluster tiles that they now point to the new cluster. This also updates the other tile.
|
131
|
+
for member in s.cluster_tiles[other]:
|
132
|
+
s.cluster_tiles[member] = s.cluster_tiles[candidate]
|
133
|
+
new_clusters = True
|
134
|
+
|
135
|
+
if new_clusters:
|
136
|
+
max_cluster_size = max(
|
137
|
+
(len(members) for members in s.cluster_tiles.values()),
|
138
|
+
default=0,
|
139
|
+
)
|
140
|
+
if max_cluster_size > max_cluster_so_far:
|
141
|
+
rows.append(
|
142
|
+
{
|
143
|
+
"time": row["first_time"],
|
144
|
+
"max_cluster_size": max_cluster_size,
|
145
|
+
}
|
146
|
+
)
|
147
|
+
max_cluster_size = max_cluster_so_far
|
148
|
+
|
149
|
+
new_cluster_evolution = pd.DataFrame(rows)
|
150
|
+
s.cluster_evolution = pd.concat([s.cluster_evolution, new_cluster_evolution])
|
151
|
+
s.start = len(tiles)
|
152
|
+
s.save()
|
153
|
+
return s
|
154
|
+
|
155
|
+
|
156
|
+
def bounding_box_for_biggest_cluster(
|
157
|
+
clusters: list[list[tuple[int, int]]], zoom: int
|
158
|
+
) -> str:
|
159
|
+
biggest_cluster = max(clusters, key=lambda members: len(members))
|
160
|
+
min_x = min(x for x, y in biggest_cluster)
|
161
|
+
max_x = max(x for x, y in biggest_cluster)
|
162
|
+
min_y = min(y for x, y in biggest_cluster)
|
163
|
+
max_y = max(y for x, y in biggest_cluster)
|
164
|
+
lat_max, lon_min = get_tile_upper_left_lat_lon(min_x, min_y, zoom)
|
165
|
+
lat_min, lon_max = get_tile_upper_left_lat_lon(max_x, max_y, zoom)
|
166
|
+
return geojson.dumps(
|
167
|
+
geojson.Feature(
|
168
|
+
geometry=geojson.Polygon(
|
169
|
+
[
|
170
|
+
[
|
171
|
+
(lon_min, lat_max),
|
172
|
+
(lon_max, lat_max),
|
173
|
+
(lon_max, lat_min),
|
174
|
+
(lon_min, lat_min),
|
175
|
+
(lon_min, lat_max),
|
176
|
+
]
|
177
|
+
]
|
178
|
+
),
|
179
|
+
)
|
180
|
+
)
|
181
|
+
|
182
|
+
|
183
|
+
class SquareHistoryState:
|
184
|
+
def __init__(self, zoom: int) -> None:
|
185
|
+
self._state_path = pathlib.Path(f"Cache/square_history_{zoom}_state.json")
|
186
|
+
self._square_history_path = pathlib.Path(f"Cache/square_history_{zoom}.parquet")
|
187
|
+
|
188
|
+
self.max_square_size = 0
|
189
|
+
self.start = 0
|
190
|
+
self.visited_tiles = set()
|
191
|
+
self.square_history = pd.DataFrame()
|
192
|
+
|
193
|
+
def load(self) -> None:
|
194
|
+
logger.info("Load explorer square state …")
|
195
|
+
if self._state_path.exists():
|
196
|
+
with open(self._state_path) as f:
|
197
|
+
data = json.load(f)
|
198
|
+
self.visited_tiles = set((x, y) for x, y in data["visited_tiles"])
|
199
|
+
self.max_square_size = data["max_square_size"]
|
200
|
+
self.start = data["start"]
|
201
|
+
|
202
|
+
if self._square_history_path.exists():
|
203
|
+
self.square_history = pd.read_parquet(self._square_history_path)
|
204
|
+
|
205
|
+
def save(self) -> None:
|
206
|
+
logger.info("Save explorer square state …")
|
207
|
+
data = {
|
208
|
+
"max_square_size": self.max_square_size,
|
209
|
+
"visited_tiles": list(self.visited_tiles),
|
210
|
+
"start": self.start,
|
211
|
+
}
|
212
|
+
with open(self._state_path, "w") as f:
|
213
|
+
json.dump(data, f)
|
214
|
+
|
215
|
+
self.square_history.to_parquet(self._square_history_path)
|
216
|
+
|
217
|
+
|
218
|
+
def get_square_history(zoom: int) -> SquareHistoryState:
|
219
|
+
tiles = pd.read_parquet(f"Cache/first_time_per_tile_{zoom}.parquet")
|
220
|
+
tiles.sort_values("first_time", inplace=True)
|
221
|
+
s = SquareHistoryState(zoom)
|
222
|
+
s.load()
|
223
|
+
logger.info("Compute new explorer square state …")
|
224
|
+
rows = []
|
225
|
+
for index, row in tiles.iloc[s.start :].iterrows():
|
226
|
+
tile = (row["tile_x"], row["tile_y"])
|
227
|
+
x, y = tile
|
228
|
+
s.visited_tiles.add(tile)
|
229
|
+
for square_size in itertools.count(s.max_square_size + 1):
|
230
|
+
this_tile_size_viable = False
|
231
|
+
for x_offset in range(square_size):
|
232
|
+
for y_offset in range(square_size):
|
233
|
+
this_offset_viable = True
|
234
|
+
for xx in range(square_size):
|
235
|
+
for yy in range(square_size):
|
236
|
+
if (
|
237
|
+
x + xx - x_offset,
|
238
|
+
y + yy - y_offset,
|
239
|
+
) not in s.visited_tiles:
|
240
|
+
this_offset_viable = False
|
241
|
+
break
|
242
|
+
if not this_offset_viable:
|
243
|
+
break
|
244
|
+
if this_offset_viable:
|
245
|
+
s.max_square_size = square_size
|
246
|
+
rows.append(
|
247
|
+
{"time": row["first_time"], "max_square_size": square_size}
|
248
|
+
)
|
249
|
+
this_tile_size_viable = True
|
250
|
+
break
|
251
|
+
if this_tile_size_viable:
|
252
|
+
break
|
253
|
+
if not this_tile_size_viable:
|
254
|
+
break
|
255
|
+
|
256
|
+
new_square_history = pd.DataFrame(rows)
|
257
|
+
s.square_history = pd.concat([s.square_history, new_square_history])
|
258
|
+
s.start = len(tiles)
|
259
|
+
s.save()
|
260
|
+
return s
|