|
| 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