Skip to content

Commit fa24b33

Browse files
authored
Merge pull request #1 from DigitalGeographyLab/develop
Bug fixes and add animation component. All tested and working.
2 parents 255d585 + 68dfedb commit fa24b33

2 files changed

Lines changed: 398 additions & 96 deletions

File tree

animate_flows.py

Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
"""
4+
Created on Fri Mar 28 15:27:50 2025
5+
6+
@author: waeiski
7+
"""
8+
9+
import geopandas as gpd
10+
import pandas as pd
11+
import matplotlib.pyplot as plt
12+
from matplotlib.animation import FuncAnimation
13+
import contextily as ctx
14+
import numpy as np
15+
from matplotlib.colors import Normalize
16+
17+
# configure file paths
18+
GPKG_PATH = "/path/to/geopackage.gpkg" # Path to your GPKG file
19+
OUTPUT_GIF_PATH = "/path/to/output.gif" # Output GIF filename
20+
21+
# column names (adjust if different in your files) ---
22+
GPKG_ORIGIN_ID_COL = 'orig_id'
23+
GPKG_DEST_ID_COL = 'dest_id'
24+
CSV_ORIGIN_ID_COL = 'orig_id'
25+
CSV_DEST_ID_COL = 'dest_id'
26+
CSV_COUNT_COL = 'COUNT'
27+
28+
# animation and style parameters ---
29+
TOP_PERCENTILE_THRESHOLD = 0.95 # top 5% for moving dots
30+
NUM_FRAMES = 120 # number of frames in the animation loop
31+
FPS = 30 # frames per second for the output GIF
32+
BASEMAP_SOURCE = ctx.providers.CartoDB.DarkMatter # basemap style
33+
FLOW_COLORMAP = 'viridis' # colormap for line intensity
34+
DOT_COLOR = 'lightcyan' # color of the moving dots
35+
DOT_SIZE = 2 # size of the moving dots
36+
MIN_LINEWIDTH = 0.5 # minimum linewidth for flows
37+
MAX_LINEWIDTH = 1.5 # maximum linewidth for flows
38+
MIN_ALPHA = 0.15 # minimum alpha (transparency) for flows
39+
MAX_ALPHA = 0.9 # maximum alpha for flows (scaled by count)
40+
GLOW_EFFECT_MULTIPLIER = 2.3 # for top 5%, draw slightly thicker base line
41+
42+
43+
# main flow animation function
44+
def create_flow_animation():
45+
print("1. Loading data...")
46+
try:
47+
gdf_lines = gpd.read_file(GPKG_PATH)
48+
gdf_lines = gdf_lines[[GPKG_ORIGIN_ID_COL,
49+
GPKG_DEST_ID_COL,
50+
'geometry']]
51+
df_flows = gpd.read_file(GPKG_PATH)
52+
df_flows = df_flows[[GPKG_ORIGIN_ID_COL, GPKG_DEST_ID_COL,
53+
CSV_COUNT_COL]]
54+
except Exception as e:
55+
print(f"Error loading input files: {e}")
56+
return
57+
58+
print(f" Loaded {len(gdf_lines)} lines from GeoPackage.")
59+
print(f" Loaded {len(df_flows)} flows from CSV.")
60+
61+
# set up list of columns that must be in the data
62+
required_gpkg_cols = [GPKG_ORIGIN_ID_COL, GPKG_DEST_ID_COL, 'geometry']
63+
64+
# validate the data
65+
if not all(col in gdf_lines.columns for col in required_gpkg_cols):
66+
print(f"Error: GPKG missing required columns: {required_gpkg_cols}")
67+
return
68+
69+
# another list of required columns for the csv data
70+
required_csv_cols = [CSV_ORIGIN_ID_COL, CSV_DEST_ID_COL, CSV_COUNT_COL]
71+
72+
# validate csv data
73+
if not all(col in df_flows.columns for col in required_csv_cols):
74+
print(f"Error: CSV missing required columns: {required_csv_cols}")
75+
return
76+
77+
# check if count column is right type
78+
if not pd.api.types.is_numeric_dtype(df_flows[CSV_COUNT_COL]):
79+
print(f"Error: CSV count column '{CSV_COUNT_COL}' is not numeric.")
80+
return
81+
82+
print("2. Merging and Preparing Data...")
83+
# ensure ID columns have the same data type for merging
84+
try:
85+
gdf_lines[GPKG_ORIGIN_ID_COL] = gdf_lines[GPKG_ORIGIN_ID_COL
86+
].astype(str)
87+
gdf_lines[GPKG_DEST_ID_COL] = gdf_lines[GPKG_DEST_ID_COL].astype(str)
88+
df_flows[CSV_ORIGIN_ID_COL] = df_flows[CSV_ORIGIN_ID_COL].astype(str)
89+
df_flows[CSV_DEST_ID_COL] = df_flows[CSV_DEST_ID_COL].astype(str)
90+
except Exception as e:
91+
print(f"Warning: Could not ensure consistent"
92+
f" ID types for merging: {e}")
93+
94+
# merge flows with geometries
95+
gdf_merged = gdf_lines.merge(
96+
df_flows,
97+
left_on=[GPKG_ORIGIN_ID_COL, GPKG_DEST_ID_COL],
98+
right_on=[CSV_ORIGIN_ID_COL, CSV_DEST_ID_COL],
99+
how='inner' # Only keep lines that have a corresponding flow count
100+
)
101+
102+
if gdf_merged.empty:
103+
print("Error: No matching flows found between GeoPackage and "
104+
"CSV after merging.")
105+
return
106+
107+
print(f" Merged {len(gdf_merged)} flows with geometries.")
108+
109+
# reproject to web mercator (EPSG:3857) for compatibility with
110+
# contextily basemaps
111+
print(f" Original CRS: {gdf_merged.crs}")
112+
if gdf_merged.crs is None:
113+
print("Warning: No CRS defined. Assuming EPSG:4326 (WGS84).")
114+
gdf_merged.set_crs("EPSG:4326", inplace=True)
115+
116+
try:
117+
gdf_merged = gdf_merged.to_crs(epsg=3857)
118+
print(f" Reprojected to {gdf_merged.crs}.")
119+
except Exception as e:
120+
print(f"Error during reprojection: {e}. Check if the "
121+
f"original CRS is valid.")
122+
return
123+
124+
# get thresholds and top flows
125+
count_threshold = gdf_merged[CSV_COUNT_COL
126+
].quantile(TOP_PERCENTILE_THRESHOLD)
127+
gdf_top = gdf_merged[gdf_merged[CSV_COUNT_COL] >= count_threshold].copy(
128+
).sort_values(by=['COUNT'])
129+
gdf_other = gdf_merged[gdf_merged[CSV_COUNT_COL] < count_threshold].copy()
130+
131+
print(f" Identified {len(gdf_top)} flows in the top "
132+
f"{100*(1-TOP_PERCENTILE_THRESHOLD):.0f}% "
133+
f"(Count >= {count_threshold:.2f}).")
134+
print(f" {len(gdf_other)} flows in the lower percentile.")
135+
136+
if gdf_top.empty:
137+
print("Warning: No flows met the top percentile threshold. "
138+
"No moving dots will be animated.")
139+
140+
# normalize count data linearly
141+
norm = Normalize(vmin=gdf_merged[CSV_COUNT_COL].min(),
142+
vmax=gdf_merged[CSV_COUNT_COL].max())
143+
cmap = plt.get_cmap(FLOW_COLORMAP)
144+
145+
# function for getting style propertios
146+
def get_style_props(count):
147+
normalized = norm(count)
148+
# Apply non-linear scaling (e.g., power) to emphasize higher counts
149+
scaled_norm = normalized ** 0.5
150+
linewidth = MIN_LINEWIDTH + (MAX_LINEWIDTH -
151+
MIN_LINEWIDTH) * scaled_norm
152+
alpha = MIN_ALPHA + (MAX_ALPHA - MIN_ALPHA) * scaled_norm
153+
color = cmap(normalized) # Color based on original normalization
154+
return linewidth, alpha, color
155+
156+
print("3. Setting up Plot...")
157+
fig, ax = plt.subplots(figsize=(10, 10))
158+
ax.set_aspect('equal')
159+
ax.axis('off') # Turn off the axis border, ticks, and labels
160+
161+
# plot base lines of top flows
162+
print(" Plotting base lines for top flows...")
163+
for _, row in gdf_top.sort_values(by=['COUNT']).iterrows():
164+
count = row[CSV_COUNT_COL]
165+
line = row['geometry']
166+
if line and line.geom_type == 'LineString':
167+
168+
# slightly thicker/brighter for the animated lines' base
169+
lw, alpha, color = get_style_props(count)
170+
171+
# apply glow effect multiplier
172+
glow_lw = lw * GLOW_EFFECT_MULTIPLIER
173+
glow_alpha = min(alpha * GLOW_EFFECT_MULTIPLIER, 0.95) # Cap alpha
174+
175+
# draw lines
176+
ax.plot(*line.xy, color=color, linewidth=glow_lw, alpha=glow_alpha,
177+
zorder=10)
178+
179+
# add basemap
180+
print(" Adding basemap...")
181+
try:
182+
# Set plot limits slightly padded BEFORE adding basemap
183+
minx, miny, maxx, maxy = gdf_merged.total_bounds
184+
pad = (maxx - minx) * 0.05 # 5% padding for a nicer map
185+
ax.set_xlim(minx - pad, maxx + pad)
186+
ax.set_ylim(miny - pad, maxy + pad)
187+
ctx.add_basemap(ax, source=BASEMAP_SOURCE, zoom='auto')
188+
except Exception as e:
189+
print(f"Warning: Could not add basemap: {e}. "
190+
f"Proceeding without basemap.")
191+
192+
# animation preparation
193+
print("4. Preparing Animation...")
194+
# if gdf is not empty start plotting
195+
if not gdf_top.empty:
196+
197+
# get starting locations
198+
initial_x = [p.x for p in gdf_top.geometry.iloc[0:0]]
199+
initial_y = [p.y for p in gdf_top.geometry.iloc[0:0]]
200+
201+
# render the initial dot locations
202+
dots_scatter = ax.scatter(initial_x, initial_y, color=DOT_COLOR,
203+
s=DOT_SIZE, zorder=15, alpha=0.5)
204+
else:
205+
# no dots to animate
206+
dots_scatter = None
207+
208+
# get geometries to list if they exist
209+
if not gdf_top.empty:
210+
line_geometries = gdf_top['geometry'].tolist()
211+
else:
212+
line_geometries = []
213+
214+
# define init function for the animation
215+
def init():
216+
217+
if dots_scatter:
218+
219+
# create empty data
220+
dots_scatter.set_offsets(np.empty((0, 2)))
221+
return dots_scatter,
222+
223+
# return empty list if no dots
224+
return []
225+
226+
# function for updating a frame
227+
def update(frame):
228+
229+
# if no line geometries return empty list
230+
if not line_geometries:
231+
return []
232+
233+
# get the fraction for animation loop
234+
fraction = (frame % NUM_FRAMES) / float(NUM_FRAMES)
235+
236+
# empty list to add the point movements
237+
points_xy = []
238+
239+
# loop over lines
240+
for line in line_geometries:
241+
242+
# check if line is correct
243+
if line and line.geom_type == 'LineString' and line.length > 0:
244+
245+
# Interpolate point along the line
246+
interpolated_point = line.interpolate(fraction,
247+
normalized=True)
248+
249+
# add interpolated point to list
250+
points_xy.append([interpolated_point.x, interpolated_point.y])
251+
252+
# check if list is not empty
253+
if points_xy:
254+
255+
# create dots scatter and return it
256+
dots_scatter.set_offsets(np.array(points_xy))
257+
return dots_scatter,
258+
259+
# else return empty list
260+
else:
261+
return []
262+
print("5. Creating and Saving Animation...")
263+
if not gdf_top.empty:
264+
265+
# remove white space from figure
266+
fig.subplots_adjust(left=0, bottom=0, right=1, top=1,
267+
wspace=None, hspace=None)
268+
269+
# set animation object
270+
anim = FuncAnimation(
271+
fig,
272+
update,
273+
frames=NUM_FRAMES,
274+
init_func=init,
275+
repeat_delay=1000,
276+
interval=1000/FPS, # Interval in milliseconds
277+
blit=True # Use blitting for performance
278+
)
279+
280+
else:
281+
282+
# if no top flows, just save the static plot as a "GIF"
283+
print(" No top flows to animate. Saving static plot as GIF.")
284+
plt.savefig(OUTPUT_GIF_PATH, dpi=150, bbox_inches='tight')
285+
print(f"Static plot saved to {OUTPUT_GIF_PATH}")
286+
plt.close(fig)
287+
return
288+
289+
# save the animation
290+
try:
291+
# use pillow writer for animation
292+
anim.save(OUTPUT_GIF_PATH, writer='pillow',
293+
fps=FPS, dpi=50) # Adjust dpi as needed
294+
295+
print(f"Animation successfully saved to: {OUTPUT_GIF_PATH}")
296+
except Exception as e:
297+
print(f"\nError saving animation: {e}")
298+
print("This might be due to the animation writer "
299+
"(e.g., 'pillow' or 'imagemagick').")
300+
print("Ensure 'Pillow' is installed (`pip install Pillow`).")
301+
print("Alternatively, try installing 'imagemagick' and using "
302+
"writer='imagemagick'.")
303+
304+
# Close the plot figure to free memory
305+
plt.close(fig)
306+
307+
308+
# run the animation
309+
create_flow_animation()

0 commit comments

Comments
 (0)