geo-activity-playground 0.35.0__py3-none-any.whl → 0.36.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.
- geo_activity_playground/__main__.py +12 -0
- geo_activity_playground/core/activities.py +21 -13
- geo_activity_playground/core/raster_map.py +246 -0
- geo_activity_playground/core/tiles.py +6 -50
- geo_activity_playground/explorer/video.py +1 -1
- geo_activity_playground/heatmap_video.py +93 -0
- geo_activity_playground/importers/activity_parsers.py +1 -1
- geo_activity_playground/importers/directory.py +6 -15
- geo_activity_playground/webui/activity/blueprint.py +3 -10
- geo_activity_playground/webui/activity/controller.py +10 -71
- geo_activity_playground/webui/app.py +32 -22
- geo_activity_playground/webui/{auth/blueprint.py → auth_blueprint.py} +1 -1
- geo_activity_playground/webui/calendar/blueprint.py +2 -5
- geo_activity_playground/webui/{eddington/controller.py → eddington_blueprint.py} +17 -13
- geo_activity_playground/webui/equipment/blueprint.py +2 -8
- geo_activity_playground/webui/explorer/blueprint.py +2 -10
- geo_activity_playground/webui/heatmap/blueprint.py +36 -10
- geo_activity_playground/webui/heatmap/heatmap_controller.py +151 -71
- geo_activity_playground/webui/heatmap/templates/heatmap/index.html.j2 +30 -12
- geo_activity_playground/webui/{search/blueprint.py → search_blueprint.py} +1 -1
- geo_activity_playground/webui/settings/blueprint.py +1 -2
- geo_activity_playground/webui/square_planner_blueprint.py +118 -0
- geo_activity_playground/webui/{summary/controller.py → summary_blueprint.py} +23 -24
- geo_activity_playground/webui/templates/page.html.j2 +11 -0
- geo_activity_playground/webui/tile_blueprint.py +42 -0
- geo_activity_playground/webui/upload_blueprint.py +1 -3
- {geo_activity_playground-0.35.0.dist-info → geo_activity_playground-0.36.0.dist-info}/METADATA +1 -1
- {geo_activity_playground-0.35.0.dist-info → geo_activity_playground-0.36.0.dist-info}/RECORD +36 -43
- geo_activity_playground/core/heatmap.py +0 -194
- geo_activity_playground/webui/eddington/__init__.py +0 -0
- geo_activity_playground/webui/eddington/blueprint.py +0 -19
- geo_activity_playground/webui/square_planner/__init__.py +0 -0
- geo_activity_playground/webui/square_planner/blueprint.py +0 -38
- geo_activity_playground/webui/square_planner/controller.py +0 -101
- geo_activity_playground/webui/summary/__init__.py +0 -0
- geo_activity_playground/webui/summary/blueprint.py +0 -17
- geo_activity_playground/webui/tile/__init__.py +0 -0
- geo_activity_playground/webui/tile/blueprint.py +0 -32
- geo_activity_playground/webui/tile/controller.py +0 -36
- /geo_activity_playground/webui/{auth/templates → templates}/auth/index.html.j2 +0 -0
- /geo_activity_playground/webui/{eddington/templates → templates}/eddington/index.html.j2 +0 -0
- /geo_activity_playground/webui/{search/templates → templates}/search/index.html.j2 +0 -0
- /geo_activity_playground/webui/{square_planner/templates → templates}/square_planner/index.html.j2 +0 -0
- /geo_activity_playground/webui/{summary/templates → templates}/summary/index.html.j2 +0 -0
- {geo_activity_playground-0.35.0.dist-info → geo_activity_playground-0.36.0.dist-info}/LICENSE +0 -0
- {geo_activity_playground-0.35.0.dist-info → geo_activity_playground-0.36.0.dist-info}/WHEEL +0 -0
- {geo_activity_playground-0.35.0.dist-info → geo_activity_playground-0.36.0.dist-info}/entry_points.txt +0 -0
@@ -12,6 +12,7 @@ from geo_activity_playground.core.config import import_old_config
|
|
12
12
|
from geo_activity_playground.core.config import import_old_strava_config
|
13
13
|
from geo_activity_playground.explorer.tile_visits import TileVisitAccessor
|
14
14
|
from geo_activity_playground.explorer.video import explorer_video_main
|
15
|
+
from geo_activity_playground.heatmap_video import main_heatmap_video
|
15
16
|
from geo_activity_playground.webui.app import web_ui_main
|
16
17
|
from geo_activity_playground.webui.upload_blueprint import scan_for_activities
|
17
18
|
|
@@ -80,6 +81,17 @@ def main() -> None:
|
|
80
81
|
subparser = subparsers.add_parser("cache", help="Cache stuff")
|
81
82
|
subparser.set_defaults(func=lambda options: main_cache(options.basedir))
|
82
83
|
|
84
|
+
subparser = subparsers.add_parser(
|
85
|
+
"heatmap-video", help="Create a video with the evolution of the heatmap"
|
86
|
+
)
|
87
|
+
subparser.add_argument("latitude", type=float)
|
88
|
+
subparser.add_argument("longitude", type=float)
|
89
|
+
subparser.add_argument("zoom", type=int)
|
90
|
+
subparser.add_argument("--decay", type=float, default=0.05)
|
91
|
+
subparser.add_argument("--video-width", type=int, default=1920)
|
92
|
+
subparser.add_argument("--video-height", type=int, default=1080)
|
93
|
+
subparser.set_defaults(func=main_heatmap_video)
|
94
|
+
|
83
95
|
options = parser.parse_args()
|
84
96
|
coloredlogs.install(
|
85
97
|
fmt="%(asctime)s %(name)s %(levelname)s %(message)s",
|
@@ -3,6 +3,7 @@ import functools
|
|
3
3
|
import json
|
4
4
|
import logging
|
5
5
|
import pickle
|
6
|
+
from collections.abc import Callable
|
6
7
|
from typing import Any
|
7
8
|
from typing import Iterator
|
8
9
|
from typing import Optional
|
@@ -180,41 +181,48 @@ def make_geojson_from_time_series(time_series: pd.DataFrame) -> str:
|
|
180
181
|
return geojson.dumps(fc)
|
181
182
|
|
182
183
|
|
183
|
-
def
|
184
|
-
|
185
|
-
|
186
|
-
high = max(speed_without_na)
|
187
|
-
clamp_speed = lambda speed: min(max((speed - low) / (high - low), 0.0), 1.0)
|
184
|
+
def inter_quartile_range(values):
|
185
|
+
return np.quantile(values, 0.75) - np.quantile(values, 0.25)
|
186
|
+
|
188
187
|
|
188
|
+
def make_geojson_color_line(time_series: pd.DataFrame) -> str:
|
189
|
+
low, high, clamp_speed = _make_speed_clamp(time_series["speed"])
|
189
190
|
cmap = matplotlib.colormaps["viridis"]
|
190
191
|
features = [
|
191
192
|
geojson.Feature(
|
192
193
|
geometry=geojson.LineString(
|
193
194
|
coordinates=[
|
194
195
|
[row["longitude"], row["latitude"]],
|
195
|
-
[
|
196
|
+
[next_row["longitude"], next_row["latitude"]],
|
196
197
|
]
|
197
198
|
),
|
198
199
|
properties={
|
199
|
-
"speed":
|
200
|
-
"color": matplotlib.colors.to_hex(cmap(clamp_speed(
|
200
|
+
"speed": next_row["speed"] if np.isfinite(next_row["speed"]) else 0.0,
|
201
|
+
"color": matplotlib.colors.to_hex(cmap(clamp_speed(next_row["speed"]))),
|
201
202
|
},
|
202
203
|
)
|
203
204
|
for _, group in time_series.groupby("segment_id")
|
204
|
-
for (_, row), (_,
|
205
|
+
for (_, row), (_, next_row) in zip(group.iterrows(), group.iloc[1:].iterrows())
|
205
206
|
]
|
206
207
|
feature_collection = geojson.FeatureCollection(features)
|
207
208
|
return geojson.dumps(feature_collection)
|
208
209
|
|
209
210
|
|
210
211
|
def make_speed_color_bar(time_series: pd.DataFrame) -> dict[str, Any]:
|
211
|
-
|
212
|
-
low = min(speed_without_na)
|
213
|
-
high = max(speed_without_na)
|
212
|
+
low, high, clamp_speed = _make_speed_clamp(time_series["speed"])
|
214
213
|
cmap = matplotlib.colormaps["viridis"]
|
215
|
-
clamp_speed = lambda speed: min(max((speed - low) / (high - low), 0.0), 1.0)
|
216
214
|
colors = [
|
217
215
|
(f"{speed:.1f}", matplotlib.colors.to_hex(cmap(clamp_speed(speed))))
|
218
216
|
for speed in np.linspace(low, high, 10)
|
219
217
|
]
|
220
218
|
return {"low": low, "high": high, "colors": colors}
|
219
|
+
|
220
|
+
|
221
|
+
def _make_speed_clamp(speeds: pd.Series) -> tuple[float, float, Callable]:
|
222
|
+
speed_without_na = speeds.dropna()
|
223
|
+
low = min(speed_without_na)
|
224
|
+
high = min(
|
225
|
+
max(speed_without_na),
|
226
|
+
np.median(speed_without_na) + 1.5 * inter_quartile_range(speed_without_na),
|
227
|
+
)
|
228
|
+
return low, high, lambda speed: min(max((speed - low) / (high - low), 0.0), 1.0)
|
@@ -0,0 +1,246 @@
|
|
1
|
+
import collections
|
2
|
+
import dataclasses
|
3
|
+
import functools
|
4
|
+
import logging
|
5
|
+
import pathlib
|
6
|
+
import time
|
7
|
+
import urllib.parse
|
8
|
+
|
9
|
+
import numpy as np
|
10
|
+
import requests
|
11
|
+
from PIL import Image
|
12
|
+
|
13
|
+
from geo_activity_playground.core.config import Config
|
14
|
+
from geo_activity_playground.core.tiles import compute_tile_float
|
15
|
+
from geo_activity_playground.core.tiles import get_tile_upper_left_lat_lon
|
16
|
+
|
17
|
+
|
18
|
+
logger = logging.getLogger(__name__)
|
19
|
+
|
20
|
+
|
21
|
+
OSM_TILE_SIZE = 256 # OSM tile size in pixel
|
22
|
+
OSM_MAX_ZOOM = 19 # OSM maximum zoom level
|
23
|
+
MAX_TILE_COUNT = 2000 # maximum number of tiles to download
|
24
|
+
|
25
|
+
## Basic data types ##
|
26
|
+
|
27
|
+
|
28
|
+
@dataclasses.dataclass
|
29
|
+
class GeoBounds:
|
30
|
+
"""
|
31
|
+
Models an area on the globe as a rectangle of latitude and longitude.
|
32
|
+
|
33
|
+
Latitude goes from South Pole (-90°) to North Pole (+90°). Longitude goes from West (-180°) to East (+180°). Be careful when converting latitude to Y-coordinates as increasing latitude will mean decreasing Y.
|
34
|
+
"""
|
35
|
+
|
36
|
+
lat_min: float
|
37
|
+
lon_min: float
|
38
|
+
lat_max: float
|
39
|
+
lon_max: float
|
40
|
+
|
41
|
+
|
42
|
+
@dataclasses.dataclass
|
43
|
+
class TileBounds:
|
44
|
+
zoom: int
|
45
|
+
x1: float
|
46
|
+
y1: float
|
47
|
+
x2: float
|
48
|
+
y2: float
|
49
|
+
|
50
|
+
@property
|
51
|
+
def width(self) -> float:
|
52
|
+
return self.x2 - self.x1
|
53
|
+
|
54
|
+
@property
|
55
|
+
def height(self) -> float:
|
56
|
+
return self.y2 - self.y1
|
57
|
+
|
58
|
+
|
59
|
+
@dataclasses.dataclass
|
60
|
+
class PixelBounds:
|
61
|
+
x1: int
|
62
|
+
y1: int
|
63
|
+
x2: int
|
64
|
+
y2: int
|
65
|
+
|
66
|
+
@classmethod
|
67
|
+
def from_tile_bounds(cls, tile_bounds: TileBounds) -> "PixelBounds":
|
68
|
+
return pixel_bounds_from_tile_bounds(tile_bounds)
|
69
|
+
|
70
|
+
@property
|
71
|
+
def width(self) -> int:
|
72
|
+
return self.x2 - self.x1
|
73
|
+
|
74
|
+
@property
|
75
|
+
def height(self) -> int:
|
76
|
+
return self.y2 - self.y1
|
77
|
+
|
78
|
+
@property
|
79
|
+
def shape(self) -> tuple[int, int]:
|
80
|
+
return self.height, self.width
|
81
|
+
|
82
|
+
|
83
|
+
@dataclasses.dataclass
|
84
|
+
class RasterMapImage:
|
85
|
+
image: np.ndarray
|
86
|
+
tile_bounds: TileBounds
|
87
|
+
geo_bounds: GeoBounds
|
88
|
+
pixel_bounds: PixelBounds
|
89
|
+
|
90
|
+
|
91
|
+
## Converter functions ##
|
92
|
+
|
93
|
+
|
94
|
+
def pixel_bounds_from_tile_bounds(tile_bounds: TileBounds) -> PixelBounds:
|
95
|
+
return PixelBounds(
|
96
|
+
int(tile_bounds.x1 * OSM_TILE_SIZE),
|
97
|
+
int(tile_bounds.y1 * OSM_TILE_SIZE),
|
98
|
+
int(tile_bounds.x2 * OSM_TILE_SIZE),
|
99
|
+
int(tile_bounds.y2 * OSM_TILE_SIZE),
|
100
|
+
)
|
101
|
+
|
102
|
+
|
103
|
+
def get_sensible_zoom_level(
|
104
|
+
bounds: GeoBounds, picture_size: tuple[int, int]
|
105
|
+
) -> TileBounds:
|
106
|
+
zoom = OSM_MAX_ZOOM
|
107
|
+
|
108
|
+
while True:
|
109
|
+
x_tile_min, y_tile_max = map(
|
110
|
+
int, compute_tile_float(bounds.lat_min, bounds.lon_min, zoom)
|
111
|
+
)
|
112
|
+
x_tile_max, y_tile_min = map(
|
113
|
+
int, compute_tile_float(bounds.lat_max, bounds.lon_max, zoom)
|
114
|
+
)
|
115
|
+
|
116
|
+
x_tile_max += 1
|
117
|
+
y_tile_max += 1
|
118
|
+
|
119
|
+
if (x_tile_max - x_tile_min) * OSM_TILE_SIZE <= picture_size[0] and (
|
120
|
+
y_tile_max - y_tile_min
|
121
|
+
) * OSM_TILE_SIZE <= picture_size[1]:
|
122
|
+
break
|
123
|
+
|
124
|
+
zoom -= 1
|
125
|
+
|
126
|
+
tile_count = (x_tile_max - x_tile_min) * (y_tile_max - y_tile_min)
|
127
|
+
|
128
|
+
if tile_count > MAX_TILE_COUNT:
|
129
|
+
raise RuntimeError("Zoom value too high, too many tiles to download")
|
130
|
+
|
131
|
+
return TileBounds(zoom, x_tile_min, y_tile_min, x_tile_max, y_tile_max)
|
132
|
+
|
133
|
+
|
134
|
+
@functools.lru_cache()
|
135
|
+
def get_tile(zoom: int, x: int, y: int, url_template: str) -> Image.Image:
|
136
|
+
destination = osm_tile_path(x, y, zoom, url_template)
|
137
|
+
if not destination.exists():
|
138
|
+
logger.info(f"Downloading OSM tile {x=}, {y=}, {zoom=} …")
|
139
|
+
url = url_template.format(x=x, y=y, zoom=zoom)
|
140
|
+
download_file(url, destination)
|
141
|
+
with Image.open(destination) as image:
|
142
|
+
image.load()
|
143
|
+
image = image.convert("RGB")
|
144
|
+
return image
|
145
|
+
|
146
|
+
|
147
|
+
def tile_bounds_around_center(
|
148
|
+
tile_center: tuple[float, float], pixel_size: tuple[int, int], zoom: int
|
149
|
+
) -> TileBounds:
|
150
|
+
x, y = tile_center
|
151
|
+
width = pixel_size[0] / OSM_TILE_SIZE
|
152
|
+
height = pixel_size[1] / OSM_TILE_SIZE
|
153
|
+
return TileBounds(
|
154
|
+
zoom, x - width / 2, y - height / 2, x + width / 2, y + height / 2
|
155
|
+
)
|
156
|
+
|
157
|
+
|
158
|
+
def _paste_array(
|
159
|
+
target: np.ndarray, source: np.ndarray, offset_0: int, offset_1: int
|
160
|
+
) -> None:
|
161
|
+
source_min_0 = 0
|
162
|
+
source_min_1 = 0
|
163
|
+
source_max_0 = source.shape[0]
|
164
|
+
source_max_1 = source.shape[1]
|
165
|
+
|
166
|
+
target_min_0 = offset_0
|
167
|
+
target_min_1 = offset_1
|
168
|
+
target_max_0 = offset_0 + source.shape[0]
|
169
|
+
target_max_1 = offset_1 + source.shape[1]
|
170
|
+
|
171
|
+
if target_min_1 < 0:
|
172
|
+
source_min_1 -= target_min_1
|
173
|
+
target_min_1 = 0
|
174
|
+
if target_min_0 < 0:
|
175
|
+
source_min_0 -= target_min_0
|
176
|
+
target_min_0 = 0
|
177
|
+
if target_max_1 > target.shape[1]:
|
178
|
+
a = target_max_1 - target.shape[1]
|
179
|
+
target_max_1 -= a
|
180
|
+
source_max_1 -= a
|
181
|
+
if target_max_0 > target.shape[0]:
|
182
|
+
a = target_max_0 - target.shape[0]
|
183
|
+
target_max_0 -= a
|
184
|
+
source_max_0 -= a
|
185
|
+
|
186
|
+
if source_max_1 < 0 or source_max_0 < 0:
|
187
|
+
return
|
188
|
+
|
189
|
+
target[target_min_0:target_max_0, target_min_1:target_max_1] = source[
|
190
|
+
source_min_0:source_max_0, source_min_1:source_max_1
|
191
|
+
]
|
192
|
+
|
193
|
+
|
194
|
+
def map_image_from_tile_bounds(tile_bounds: TileBounds, config: Config) -> np.ndarray:
|
195
|
+
pixel_bounds = pixel_bounds_from_tile_bounds(tile_bounds)
|
196
|
+
background = np.zeros((pixel_bounds.height, pixel_bounds.width, 3))
|
197
|
+
|
198
|
+
north_west = np.array([tile_bounds.x1, tile_bounds.y1])
|
199
|
+
offset = north_west % 1
|
200
|
+
tile_anchor = north_west - offset
|
201
|
+
pixel_anchor: np.ndarray = np.array([0, 0]) - np.array(
|
202
|
+
offset * OSM_TILE_SIZE, dtype=np.int64
|
203
|
+
)
|
204
|
+
|
205
|
+
num_tile_x = int(np.ceil(tile_bounds.width)) + 1
|
206
|
+
num_tile_y = int(np.ceil(tile_bounds.height)) + 1
|
207
|
+
|
208
|
+
for x in range(int(tile_anchor[0]), int(tile_anchor[0] + num_tile_x)):
|
209
|
+
for y in range(int(tile_anchor[1]), int(tile_anchor[1]) + num_tile_y):
|
210
|
+
tile = np.array(get_tile(tile_bounds.zoom, x, y, config.map_tile_url)) / 255
|
211
|
+
_paste_array(
|
212
|
+
background,
|
213
|
+
tile,
|
214
|
+
(y - int(tile_anchor[1])) * OSM_TILE_SIZE + int(pixel_anchor[1]),
|
215
|
+
(x - int(tile_anchor[0])) * OSM_TILE_SIZE + int(pixel_anchor[0]),
|
216
|
+
)
|
217
|
+
|
218
|
+
return background
|
219
|
+
|
220
|
+
|
221
|
+
def convert_to_grayscale(image: np.ndarray) -> np.ndarray:
|
222
|
+
image = np.sum(image * [0.2126, 0.7152, 0.0722], axis=2)
|
223
|
+
image = np.dstack((image, image, image))
|
224
|
+
return image
|
225
|
+
|
226
|
+
|
227
|
+
def osm_tile_path(x: int, y: int, zoom: int, url_template: str) -> pathlib.Path:
|
228
|
+
base_dir = pathlib.Path("Open Street Map Tiles")
|
229
|
+
dir_for_source = base_dir / urllib.parse.quote_plus(url_template)
|
230
|
+
path = dir_for_source / f"{zoom}/{x}/{y}.png"
|
231
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
232
|
+
return path
|
233
|
+
|
234
|
+
|
235
|
+
def download_file(url: str, destination: pathlib.Path):
|
236
|
+
if not destination.parent.exists():
|
237
|
+
destination.parent.mkdir(exist_ok=True, parents=True)
|
238
|
+
r = requests.get(
|
239
|
+
url,
|
240
|
+
allow_redirects=True,
|
241
|
+
headers={"User-Agent": "Martin's Geo Activity Playground"},
|
242
|
+
)
|
243
|
+
assert r.ok
|
244
|
+
with open(destination, "wb") as f:
|
245
|
+
f.write(r.content)
|
246
|
+
time.sleep(0.1)
|
@@ -1,34 +1,12 @@
|
|
1
|
-
import functools
|
2
1
|
import logging
|
3
2
|
import math
|
4
|
-
import pathlib
|
5
|
-
import time
|
6
|
-
import urllib.parse
|
7
3
|
from typing import Iterator
|
8
4
|
from typing import Optional
|
9
5
|
|
10
6
|
import numpy as np
|
11
|
-
import requests
|
12
|
-
from PIL import Image
|
13
|
-
|
14
|
-
logger = logging.getLogger(__name__)
|
15
|
-
|
16
|
-
|
17
|
-
def osm_tile_path(x: int, y: int, zoom: int, url_template: str) -> pathlib.Path:
|
18
|
-
base_dir = pathlib.Path("Open Street Map Tiles")
|
19
|
-
dir_for_source = base_dir / urllib.parse.quote_plus(url_template)
|
20
|
-
path = dir_for_source / f"{zoom}/{x}/{y}.png"
|
21
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
22
|
-
return path
|
23
7
|
|
24
8
|
|
25
|
-
|
26
|
-
x = np.radians(lon)
|
27
|
-
y = np.arcsinh(np.tan(np.radians(lat)))
|
28
|
-
x = (1 + x / np.pi) / 2
|
29
|
-
y = (1 - y / np.pi) / 2
|
30
|
-
n = 2**zoom
|
31
|
-
return int(x * n), int(y * n)
|
9
|
+
logger = logging.getLogger(__name__)
|
32
10
|
|
33
11
|
|
34
12
|
def compute_tile_float(lat: float, lon: float, zoom: int) -> tuple[float, float]:
|
@@ -40,6 +18,11 @@ def compute_tile_float(lat: float, lon: float, zoom: int) -> tuple[float, float]
|
|
40
18
|
return x * n, y * n
|
41
19
|
|
42
20
|
|
21
|
+
def compute_tile(lat: float, lon: float, zoom: int) -> tuple[int, int]:
|
22
|
+
x, y = compute_tile_float(lat, lon, zoom)
|
23
|
+
return int(x), int(y)
|
24
|
+
|
25
|
+
|
43
26
|
def get_tile_upper_left_lat_lon(
|
44
27
|
tile_x: int, tile_y: int, zoom: int
|
45
28
|
) -> tuple[float, float]:
|
@@ -50,33 +33,6 @@ def get_tile_upper_left_lat_lon(
|
|
50
33
|
return lat_deg, lon_deg
|
51
34
|
|
52
35
|
|
53
|
-
def download_file(url: str, destination: pathlib.Path):
|
54
|
-
if not destination.parent.exists():
|
55
|
-
destination.parent.mkdir(exist_ok=True, parents=True)
|
56
|
-
r = requests.get(
|
57
|
-
url,
|
58
|
-
allow_redirects=True,
|
59
|
-
headers={"User-Agent": "Martin's Geo Activity Playground"},
|
60
|
-
)
|
61
|
-
assert r.ok
|
62
|
-
with open(destination, "wb") as f:
|
63
|
-
f.write(r.content)
|
64
|
-
time.sleep(0.1)
|
65
|
-
|
66
|
-
|
67
|
-
@functools.lru_cache()
|
68
|
-
def get_tile(zoom: int, x: int, y: int, url_template: str) -> Image.Image:
|
69
|
-
destination = osm_tile_path(x, y, zoom, url_template)
|
70
|
-
if not destination.exists():
|
71
|
-
logger.info(f"Downloading OSM tile {x=}, {y=}, {zoom=} …")
|
72
|
-
url = url_template.format(x=x, y=y, zoom=zoom)
|
73
|
-
download_file(url, destination)
|
74
|
-
with Image.open(destination) as image:
|
75
|
-
image.load()
|
76
|
-
image = image.convert("RGB")
|
77
|
-
return image
|
78
|
-
|
79
|
-
|
80
36
|
def xy_to_latlon(x: float, y: float, zoom: int) -> tuple[float, float]:
|
81
37
|
"""
|
82
38
|
Returns (lat, lon) in degree from OSM coordinates (x,y) rom https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames
|
@@ -0,0 +1,93 @@
|
|
1
|
+
import collections
|
2
|
+
import os
|
3
|
+
import pathlib
|
4
|
+
|
5
|
+
import matplotlib.pyplot as pl
|
6
|
+
import numpy as np
|
7
|
+
import pandas as pd
|
8
|
+
from PIL import Image
|
9
|
+
from PIL import ImageDraw
|
10
|
+
from tqdm import tqdm
|
11
|
+
|
12
|
+
from geo_activity_playground.core.activities import ActivityRepository
|
13
|
+
from geo_activity_playground.core.config import ConfigAccessor
|
14
|
+
from geo_activity_playground.core.raster_map import convert_to_grayscale
|
15
|
+
from geo_activity_playground.core.raster_map import map_image_from_tile_bounds
|
16
|
+
from geo_activity_playground.core.raster_map import OSM_TILE_SIZE
|
17
|
+
from geo_activity_playground.core.raster_map import tile_bounds_around_center
|
18
|
+
from geo_activity_playground.core.tiles import compute_tile_float
|
19
|
+
|
20
|
+
|
21
|
+
def main_heatmap_video(options) -> None:
|
22
|
+
zoom: int = options.zoom
|
23
|
+
print(options)
|
24
|
+
video_size = options.video_width, options.video_height
|
25
|
+
os.chdir(options.basedir)
|
26
|
+
|
27
|
+
repository = ActivityRepository()
|
28
|
+
repository.reload()
|
29
|
+
assert len(repository) > 0
|
30
|
+
config_accessor = ConfigAccessor()
|
31
|
+
|
32
|
+
center_xy = compute_tile_float(options.latitude, options.longitude, zoom)
|
33
|
+
|
34
|
+
tile_bounds = tile_bounds_around_center(center_xy, video_size, zoom)
|
35
|
+
background = map_image_from_tile_bounds(tile_bounds, config_accessor())
|
36
|
+
|
37
|
+
background = convert_to_grayscale(background)
|
38
|
+
background = 1.0 - background # invert colors
|
39
|
+
|
40
|
+
activities_per_day = collections.defaultdict(set)
|
41
|
+
for activity in tqdm(
|
42
|
+
repository.iter_activities(), desc="Gather activities per day"
|
43
|
+
):
|
44
|
+
activities_per_day[activity["start"].date()].add(activity["id"])
|
45
|
+
|
46
|
+
running_counts = np.zeros(background.shape[:2], np.float64)
|
47
|
+
|
48
|
+
output_dir = pathlib.Path("Heatmap Video")
|
49
|
+
output_dir.mkdir(exist_ok=True)
|
50
|
+
|
51
|
+
first_day = min(activities_per_day)
|
52
|
+
last_day = max(activities_per_day)
|
53
|
+
days = pd.date_range(first_day, last_day)
|
54
|
+
for current_day in tqdm(days, desc="Generate video frames"):
|
55
|
+
for activity_id in activities_per_day[current_day.date()]:
|
56
|
+
im = Image.new("L", video_size)
|
57
|
+
draw = ImageDraw.Draw(im)
|
58
|
+
|
59
|
+
time_series = repository.get_time_series(activity_id)
|
60
|
+
for _, group in time_series.groupby("segment_id"):
|
61
|
+
tile_xz = group["x"] * 2**zoom
|
62
|
+
tile_yz = group["y"] * 2**zoom
|
63
|
+
|
64
|
+
xy_pixels = list(
|
65
|
+
zip(
|
66
|
+
(tile_xz - center_xy[0]) * OSM_TILE_SIZE
|
67
|
+
+ options.video_width / 2,
|
68
|
+
(tile_yz - center_xy[1]) * OSM_TILE_SIZE
|
69
|
+
+ options.video_height / 2,
|
70
|
+
)
|
71
|
+
)
|
72
|
+
pixels = [int(value) for t in xy_pixels for value in t]
|
73
|
+
draw.line(pixels, fill=1, width=max(3, 6 * (zoom - 17)))
|
74
|
+
aim = np.array(im)
|
75
|
+
running_counts += aim
|
76
|
+
|
77
|
+
tile_counts = np.sqrt(running_counts) / 5
|
78
|
+
tile_counts[tile_counts > 1.0] = 1.0
|
79
|
+
|
80
|
+
cmap = pl.get_cmap(config_accessor().color_scheme_for_heatmap)
|
81
|
+
data_color = cmap(tile_counts)
|
82
|
+
data_color[data_color == cmap(0.0)] = 0.0 # remove background color
|
83
|
+
|
84
|
+
rendered = np.zeros_like(background)
|
85
|
+
for c in range(3):
|
86
|
+
rendered[:, :, c] = (1.0 - data_color[:, :, c]) * background[
|
87
|
+
:, :, c
|
88
|
+
] + data_color[:, :, c]
|
89
|
+
|
90
|
+
img = Image.fromarray((rendered * 255).astype("uint8"), "RGB")
|
91
|
+
img.save(output_dir / f"{current_day.date()}.png", format="png")
|
92
|
+
|
93
|
+
running_counts *= 1 - options.decay
|
@@ -24,7 +24,7 @@ class ActivityParseError(BaseException):
|
|
24
24
|
|
25
25
|
|
26
26
|
def read_activity(path: pathlib.Path) -> tuple[ActivityMeta, pd.DataFrame]:
|
27
|
-
suffixes = path.suffixes
|
27
|
+
suffixes = [s.lower() for s in path.suffixes]
|
28
28
|
metadata = ActivityMeta()
|
29
29
|
|
30
30
|
if suffixes[-1] == ".gz":
|
@@ -25,7 +25,7 @@ ACTIVITY_DIR = pathlib.Path("Activities")
|
|
25
25
|
|
26
26
|
|
27
27
|
def import_from_directory(
|
28
|
-
metadata_extraction_regexes: list[str],
|
28
|
+
metadata_extraction_regexes: list[str], config: Config
|
29
29
|
) -> None:
|
30
30
|
|
31
31
|
activity_paths = [
|
@@ -63,20 +63,11 @@ def import_from_directory(
|
|
63
63
|
del file_hashes[deleted_file]
|
64
64
|
work_tracker.discard(deleted_file)
|
65
65
|
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
paths_with_errors.append(errors)
|
72
|
-
else:
|
73
|
-
with multiprocessing.Pool(num_processes) as pool:
|
74
|
-
paths_with_errors = tqdm(
|
75
|
-
pool.imap(_cache_single_file, new_activity_paths),
|
76
|
-
desc="Parse activity metadata (concurrently)",
|
77
|
-
total=len(new_activity_paths),
|
78
|
-
)
|
79
|
-
paths_with_errors = [error for error in paths_with_errors if error]
|
66
|
+
paths_with_errors = []
|
67
|
+
for path in tqdm(new_activity_paths, desc="Parse activity metadata (serially)"):
|
68
|
+
errors = _cache_single_file(path)
|
69
|
+
if errors:
|
70
|
+
paths_with_errors.append(errors)
|
80
71
|
|
81
72
|
for path in tqdm(new_activity_paths, desc="Collate activity metadata"):
|
82
73
|
activity_id = get_file_hash(path)
|
@@ -1,6 +1,5 @@
|
|
1
1
|
import json
|
2
2
|
import urllib.parse
|
3
|
-
from collections.abc import Collection
|
4
3
|
|
5
4
|
from flask import Blueprint
|
6
5
|
from flask import redirect
|
@@ -9,26 +8,20 @@ from flask import request
|
|
9
8
|
from flask import Response
|
10
9
|
from flask import url_for
|
11
10
|
|
12
|
-
from
|
13
|
-
from ...explorer.tile_visits import TileVisitAccessor
|
14
|
-
from .controller import ActivityController
|
15
|
-
from geo_activity_playground.core.config import Config
|
11
|
+
from geo_activity_playground.core.activities import ActivityRepository
|
16
12
|
from geo_activity_playground.core.paths import activity_meta_override_dir
|
17
|
-
from geo_activity_playground.
|
13
|
+
from geo_activity_playground.webui.activity.controller import ActivityController
|
18
14
|
from geo_activity_playground.webui.authenticator import Authenticator
|
19
15
|
from geo_activity_playground.webui.authenticator import needs_authentication
|
20
16
|
|
21
17
|
|
22
18
|
def make_activity_blueprint(
|
19
|
+
activity_controller: ActivityController,
|
23
20
|
repository: ActivityRepository,
|
24
|
-
tile_visit_accessor: TileVisitAccessor,
|
25
|
-
config: Config,
|
26
21
|
authenticator: Authenticator,
|
27
22
|
) -> Blueprint:
|
28
23
|
blueprint = Blueprint("activity", __name__, template_folder="templates")
|
29
24
|
|
30
|
-
activity_controller = ActivityController(repository, tile_visit_accessor, config)
|
31
|
-
|
32
25
|
@blueprint.route("/all")
|
33
26
|
def all():
|
34
27
|
return render_template(
|