def test_meters_to_decimal_degrees(self): input_result_dict = { 1.0: { 0: 111320, 23: 102470, 45: 78710, 67: 43496 }, 0.1: { 0: 11132, 23: 10247, 45: 7871, 67: 4349.6 }, 0.01: { 0: 1113.2, 23: 1024.7, 45: 787.1, 67: 434.96 }, 0.001: { 0: 111.32, 23: 102.47, 45: 78.71, 67: 43.496 }, } for degree, lat_output in input_result_dict.items(): for lat, meters in lat_output.items(): decimal_degree_output = meters_to_decimal_degrees(meters, lat) assert np.isclose(decimal_degree_output, degree, atol=0.1)
def findPlaces(stps, dataName, minDist, minPoints): """ Used the result from findStayPoints() to cluster places with DBSCAN Parameters ---------- stps : gdf - staypoints, found by the algorithm of trackintel dataName: str - ID of participant minDist, minPoints: float - Thresholds for the DBSCAN Returns ------- plcs : gdf - The clustered places defined by the DBSCAN """ # Find places plcs = stm.cluster_staypoints(stps, method='dbscan', epsilon=meters_to_decimal_degrees( minDist, 47.5), num_samples=minPoints) return plcs
def generate_locations(staypoints, method='dbscan', epsilon=100, num_samples=1, distance_matrix_metric='euclidean', agg_level='user'): """ Generate locations from the staypoints. Parameters ---------- staypoints : GeoDataFrame (as trackintel staypoints) The staypoints have to follow the standard definition for staypoints DataFrames. method : {'dbscan'} Method to create locations. - 'dbscan' : Uses the DBSCAN algorithm to cluster staypoints. epsilon : float, default 100 The epsilon for the 'dbscan' method. if 'distance_matrix_metric' is 'haversine' or 'euclidean', the unit is in meters. num_samples : int, default 1 The minimal number of samples in a cluster. distance_matrix_metric: {'haversine', 'euclidean'} The distance matrix used by the applied method. Any mentioned below are possible: https://scikit-learn.org/stable/modules/generated/sklearn.metrics.pairwise_distances.html agg_level: {'user','dataset'} The level of aggregation when generating locations: - 'user' : locations are generated independently per-user. - 'dataset' : shared locations are generated for all users. Returns ------- ret_sp: GeoDataFrame (as trackintel staypoints) The original staypoints with a new column ``[`location_id`]``. ret_loc: GeoDataFrame (as trackintel locations) The generated locations. Examples -------- >>> spts.as_staypoints.generate_locations(method='dbscan', epsilon=100, num_samples=1) """ if agg_level not in ['user', 'dataset']: raise AttributeError( "The parameter agg_level must be one of ['user', 'dataset'].") if method not in ['dbscan']: raise AttributeError("The parameter method must be one of ['dbscan'].") # initialize the return GeoDataFrames ret_stps = staypoints.copy() if method == 'dbscan': if distance_matrix_metric == 'haversine': # The input and output of sklearn's harvarsine metrix are both in radians, # see https://scikit-learn.org/stable/modules/generated/sklearn.metrics.pairwise.haversine_distances.html # here the 'epsilon' is directly applied to the metric's output. # convert to radius db = DBSCAN(eps=epsilon / 6371000, min_samples=num_samples, algorithm='ball_tree', metric=distance_matrix_metric) else: db = DBSCAN(eps=epsilon, min_samples=num_samples, algorithm='ball_tree', metric=distance_matrix_metric) if agg_level == 'user': location_id_counter = 0 # TODO: change into groupby for user_id_this in ret_stps["user_id"].unique(): # Slice staypoints array by user. This is not a copy! user_staypoints = ret_stps[ret_stps["user_id"] == user_id_this] if distance_matrix_metric == 'haversine': # the input is converted to list of (lat, lon) tuples in radians unit p = np.array([[radians(g.y), radians(g.x)] for g in user_staypoints.geometry]) else: p = np.array([[g.x, g.y] for g in user_staypoints.geometry]) labels = db.fit_predict(p) # enforce unique lables across all users without changing noise labels max_label = np.max(labels) labels[ labels != -1] = labels[labels != -1] + location_id_counter if max_label > -1: location_id_counter = location_id_counter + max_label + 1 # add staypoint - location matching to original staypoints ret_stps.loc[user_staypoints.index, 'location_id'] = labels else: if distance_matrix_metric == 'haversine': # the input is converted to list of (lat, lon) tuples in radians unit p = np.array([[radians(g.y), radians(g.x)] for g in ret_stps.geometry]) else: p = np.array([[g.x, g.y] for g in ret_stps.geometry]) labels = db.fit_predict(p) ret_stps['location_id'] = labels ### create locations as grouped staypoints temp_sp = ret_stps[['user_id', 'location_id', ret_stps.geometry.name]] if agg_level == 'user': # directly dissolve by 'user_id' and 'location_id' ret_loc = temp_sp.dissolve(by=['user_id', 'location_id'], as_index=False) else: ## generate user-location pairs with same geometries across users # get user-location pairs ret_loc = temp_sp.dissolve(by=['user_id', 'location_id'], as_index=False).drop(columns={'geom'}) # get location geometries geom_df = temp_sp.dissolve( by=['location_id'], as_index=False).drop(columns={'user_id'}) # merge pairs with location geometries ret_loc = ret_loc.merge(geom_df, on='location_id', how='left') # filter stps not belonging to locations ret_loc = ret_loc.loc[ret_loc['location_id'] != -1] ret_loc['center'] = None # initialize # locations with only one staypoints is of type "Point" point_idx = ret_loc.geom_type == 'Point' if not ret_loc.loc[point_idx].empty: ret_loc.loc[point_idx, 'center'] = ret_loc.loc[point_idx, 'geom'] # locations with multiple staypoints is of type "MultiPoint" if not ret_loc.loc[~point_idx].empty: ret_loc.loc[~point_idx, 'center'] = ret_loc.loc[~point_idx, 'geom'].apply(lambda p: Point( np.array(p)[:, 0].mean(), np.array(p)[:, 1].mean())) # extent is the convex hull of the geometry ret_loc['extent'] = None # initialize if not ret_loc.empty: ret_loc['extent'] = ret_loc['geom'].apply(lambda p: p.convex_hull) # convex_hull of one point would be a Point and two points a Linestring, # we change them into Polygon by creating a buffer of epsilon around them. pointLine_idx = (ret_loc['extent'].geom_type == 'LineString') | ( ret_loc['extent'].geom_type == 'Point') if not ret_loc.loc[pointLine_idx].empty: # Perform meter to decimal conversion if the distance metric is haversine if distance_matrix_metric == 'haversine': ret_loc.loc[pointLine_idx, 'extent'] = ret_loc.loc[pointLine_idx].apply( lambda p: p['extent'].buffer( meters_to_decimal_degrees( epsilon, p['center'].y)), axis=1) else: ret_loc.loc[pointLine_idx, 'extent'] = ret_loc.loc[pointLine_idx].apply( lambda p: p['extent'].buffer(epsilon), axis=1) ret_loc = ret_loc.set_geometry('center') ret_loc = ret_loc[['user_id', 'location_id', 'center', 'extent']] # index management ret_loc.rename(columns={'location_id': 'id'}, inplace=True) ret_loc.set_index('id', inplace=True) # stps not linked to a location receive np.nan in 'location_id' ret_stps.loc[ret_stps['location_id'] == -1, 'location_id'] = np.nan ## dtype consistency # locs id (generated by this function) should be int64 ret_loc.index = ret_loc.index.astype('int64') # location_id of spts can only be in Int64 (missing values) ret_stps['location_id'] = ret_stps['location_id'].astype('Int64') # user_id of ret_loc should be the same as ret_stps ret_loc['user_id'] = ret_loc['user_id'].astype(ret_stps['user_id'].dtype) return ret_stps, ret_loc
def generate_locations( staypoints, method="dbscan", epsilon=100, num_samples=1, distance_metric="haversine", agg_level="user", print_progress=False, n_jobs=1, ): """ Generate locations from the staypoints. Parameters ---------- staypoints : GeoDataFrame (as trackintel staypoints) The staypoints have to follow the standard definition for staypoints DataFrames. method : {'dbscan'} Method to create locations. - 'dbscan' : Uses the DBSCAN algorithm to cluster staypoints. epsilon : float, default 100 The epsilon for the 'dbscan' method. if 'distance_metric' is 'haversine' or 'euclidean', the unit is in meters. num_samples : int, default 1 The minimal number of samples in a cluster. distance_metric: {'haversine', 'euclidean'} The distance metric used by the applied method. Any mentioned below are possible: https://scikit-learn.org/stable/modules/generated/sklearn.metrics.pairwise_distances.html agg_level: {'user','dataset'} The level of aggregation when generating locations: - 'user' : locations are generated independently per-user. - 'dataset' : shared locations are generated for all users. print_progress : bool, default False If print_progress is True, the progress bar is displayed n_jobs: int, default 1 The maximum number of concurrently running jobs. If -1 all CPUs are used. If 1 is given, no parallel computing code is used at all, which is useful for debugging. See https://joblib.readthedocs.io/en/latest/parallel.html#parallel-reference-documentation for a detailed description Returns ------- sp: GeoDataFrame (as trackintel staypoints) The original staypoints with a new column ``[`location_id`]``. locs: GeoDataFrame (as trackintel locations) The generated locations. Examples -------- >>> sp.as_staypoints.generate_locations(method='dbscan', epsilon=100, num_samples=1) """ if agg_level not in ["user", "dataset"]: raise AttributeError( "The parameter agg_level must be one of ['user', 'dataset'].") if method not in ["dbscan"]: raise AttributeError("The parameter method must be one of ['dbscan'].") # initialize the return GeoDataFrames sp = staypoints.copy() sp = sp.sort_values(["user_id", "started_at"]) geo_col = sp.geometry.name if method == "dbscan": if distance_metric == "haversine": # The input and output of sklearn's harvarsine metrix are both in radians, # see https://scikit-learn.org/stable/modules/generated/sklearn.metrics.pairwise.haversine_distances.html # here the 'epsilon' is directly applied to the metric's output. # convert to radius db = DBSCAN(eps=epsilon / 6371000, min_samples=num_samples, algorithm="ball_tree", metric=distance_metric) else: db = DBSCAN(eps=epsilon, min_samples=num_samples, algorithm="ball_tree", metric=distance_metric) if agg_level == "user": sp = applyParallel( sp.groupby("user_id", as_index=False), _generate_locations_per_user, n_jobs=n_jobs, print_progress=print_progress, geo_col=geo_col, distance_metric=distance_metric, db=db, ) # keeping track of noise labels sp_non_noise_labels = sp[sp["location_id"] != -1] sp_noise_labels = sp[sp["location_id"] == -1] # sort so that the last location id of a user = max(location id) sp_non_noise_labels = sp_non_noise_labels.sort_values( ["user_id", "location_id"]) # identify start positions of new user_ids start_of_user_id = sp_non_noise_labels[ "user_id"] != sp_non_noise_labels["user_id"].shift(1) # calculate the offset (= last location id of the previous user) # multiplication is to mask all positions where no new user starts and addition is to have a +1 when a # new user starts loc_id_offset = sp_non_noise_labels["location_id"].shift( 1) * start_of_user_id + start_of_user_id # fill first nan with 0 and create the cumulative sum loc_id_offset = loc_id_offset.fillna(0).cumsum() sp_non_noise_labels["location_id"] = sp_non_noise_labels[ "location_id"] + loc_id_offset sp = gpd.GeoDataFrame(pd.concat( [sp_non_noise_labels, sp_noise_labels]), geometry=geo_col) sp.sort_values(["user_id", "started_at"], inplace=True) else: if distance_metric == "haversine": # the input is converted to list of (lat, lon) tuples in radians unit p = np.array([[radians(g.y), radians(g.x)] for g in sp.geometry]) else: p = np.array([[g.x, g.y] for g in sp.geometry]) labels = db.fit_predict(p) sp["location_id"] = labels ### create locations as grouped staypoints temp_sp = sp[["user_id", "location_id", sp.geometry.name]] if agg_level == "user": # directly dissolve by 'user_id' and 'location_id' locs = temp_sp.dissolve(by=["user_id", "location_id"], as_index=False) else: ## generate user-location pairs with same geometries across users # get user-location pairs locs = temp_sp.dissolve( by=["user_id", "location_id"], as_index=False).drop(columns={temp_sp.geometry.name}) # get location geometries geom_df = temp_sp.dissolve( by=["location_id"], as_index=False).drop(columns={"user_id"}) # merge pairs with location geometries locs = locs.merge(geom_df, on="location_id", how="left") # filter staypoints not belonging to locations locs = locs.loc[locs["location_id"] != -1] locs["center"] = None # initialize # locations with only one staypoints is of type "Point" point_idx = locs.geom_type == "Point" if not locs.loc[point_idx].empty: locs.loc[point_idx, "center"] = locs.loc[point_idx, locs.geometry.name] # locations with multiple staypoints is of type "MultiPoint" if not locs.loc[~point_idx].empty: locs.loc[~point_idx, "center"] = locs.loc[ ~point_idx, locs.geometry.name].apply(lambda p: Point( np.array(p)[:, 0].mean(), np.array(p)[:, 1].mean())) # extent is the convex hull of the geometry locs["extent"] = None # initialize if not locs.empty: locs["extent"] = locs[locs.geometry.name].apply( lambda p: p.convex_hull) # convex_hull of one point would be a Point and two points a Linestring, # we change them into Polygon by creating a buffer of epsilon around them. pointLine_idx = (locs["extent"].geom_type == "LineString") | ( locs["extent"].geom_type == "Point") if not locs.loc[pointLine_idx].empty: # Perform meter to decimal conversion if the distance metric is haversine if distance_metric == "haversine": locs.loc[pointLine_idx, "extent"] = locs.loc[pointLine_idx].apply( lambda p: p["extent"].buffer( meters_to_decimal_degrees( epsilon, p["center"].y)), axis=1) else: locs.loc[pointLine_idx, "extent"] = locs.loc[pointLine_idx].apply( lambda p: p["extent"].buffer(epsilon), axis=1) locs = locs.set_geometry("center") locs = locs[["user_id", "location_id", "center", "extent"]] # index management locs.rename(columns={"location_id": "id"}, inplace=True) locs.set_index("id", inplace=True) # staypoints not linked to a location receive np.nan in 'location_id' sp.loc[sp["location_id"] == -1, "location_id"] = np.nan if len(locs) > 0: locs.as_locations else: warnings.warn("No locations can be generated, returning empty locs.") ## dtype consistency # locs id (generated by this function) should be int64 locs.index = locs.index.astype("int64") # location_id of staypoints can only be in Int64 (missing values) sp["location_id"] = sp["location_id"].astype("Int64") # user_id of locs should be the same as sp locs["user_id"] = locs["user_id"].astype(sp["user_id"].dtype) return sp, locs
level=logging.INFO, filemode='w') # GPSies trajectory. pfs = ti.read_positionfixes_csv('examples/data/geolife_trajectory.csv', sep=';') pfs.as_positionfixes.plot( out_filename='examples/out/gpsies_trajectory_positionfixes.png', plot_osm=True) spts = pfs.as_positionfixes.extract_staypoints(method='sliding', dist_threshold=100, time_threshold=5 * 60) spts.as_staypoints.plot( out_filename='examples/out/gpsies_trajectory_staypoints.png', radius=meters_to_decimal_degrees(100, 47.5), positionfixes=pfs, plot_osm=True) plcs = spts.as_staypoints.extract_locations(method='dbscan', epsilon=meters_to_decimal_degrees( 120, 47.5), num_samples=3) plcs.as_locations.plot( out_filename='examples/out/gpsies_trajectory_locations.png', radius=meters_to_decimal_degrees(120, 47.5), positionfixes=pfs, staypoints=spts, staypoints_radius=meters_to_decimal_degrees(100, 47.5), plot_osm=True)
def plot_staypoints(staypoints, out_filename=None, radius=100, positionfixes=None, plot_osm=False, axis=None): """Plot staypoints (optionally to a file). You can specify the radius with which each staypoint should be drawn, as well as if underlying positionfixes and OSM streets should be drawn. The data gets transformed to wgs84 for the plotting. Parameters ---------- staypoints : GeoDataFrame (as trackintel staypoints) The staypoints to plot. out_filename : str, optional The file to plot to, if this is not set, the plot will simply be shown. radius : float, default 100 (meter) The radius in meter with which circles around staypoints should be drawn. positionfixes : GeoDataFrame (as trackintel positionfixes), optional If available, some positionfixes that can additionally be plotted. plot_osm : bool, default False If this is set to True, it will download an OSM street network and plot below the staypoints. axis : matplotlib.pyplot.Artist, optional axis on which to draw the plot Examples -------- >>> stps.as_staypoints.plot('output.png', radius=100, positionfixes=pfs, plot_osm=True) """ if axis is None: _, ax = regular_figure() else: ax = axis name_geocol = staypoints.geometry.name _, staypoints = check_gdf_crs(staypoints, transform=True) if positionfixes is not None: positionfixes.as_positionfixes.plot(plot_osm=plot_osm, axis=ax) else: west = staypoints.geometry.x.min() - 0.03 east = staypoints.geometry.x.max() + 0.03 north = staypoints.geometry.y.max() + 0.03 south = staypoints.geometry.y.min() - 0.03 if plot_osm: plot_osm_streets(north, south, east, west, ax) ax.set_xlim([west, east]) ax.set_ylim([south, north]) center_latitude = (ax.get_ylim()[0] + ax.get_ylim()[1]) / 2 radius = meters_to_decimal_degrees(radius, center_latitude) for pt in staypoints.to_dict("records"): circle = mpatches.Circle( (pt[name_geocol].x, pt[name_geocol].y), radius, facecolor="none", edgecolor="g", zorder=3 ) ax.add_artist(circle) ax.set_aspect("equal", adjustable="box") if out_filename is not None: save_fig(out_filename, formats=["png"]) elif axis is None: plt.show()
def cluster_staypoints(staypoints, method='dbscan', epsilon=100, num_samples=1, distance_matrix_metric='euclidean', agg_level='user'): """Clusters staypoints to get locations. Parameters ---------- staypoints : GeoDataFrame The staypoints have to follow the standard definition for staypoints DataFrames. method : str, {'dbscan'}, default 'dbscan' The following methods are available to cluster staypoints into locations: 'dbscan' : Uses the DBSCAN algorithm to cluster staypoints. epsilon : float, default 100 The epsilon for the 'dbscan' method. num_samples : int, default 1 The minimal number of samples in a cluster. distance_matrix_metric: str, default 'euclidean' The distance matrix used by the applied method. Possible metrics are: {'haversine', 'euclidean'} or any mentioned in: https://scikit-learn.org/stable/modules/generated/sklearn.metrics.pairwise_distances.html agg_level: str, {'user' or 'dataset'}, default 'user' The level of aggregation when generating locations: 'user' : locations are generated independently per-user. 'dataset' : shared locations are generated for all users. Returns ------- GeoDataFrame A new GeoDataFrame containing locations that a person visited multiple times. Examples -------- >>> spts.as_staypoints.cluster_staypoints(method='dbscan', epsilon=100, num_samples=1) """ if agg_level not in ['user', 'dataset']: raise AttributeError( "The parameter agg_level must be one of ['user', 'dataset'].") ret_sp = staypoints.copy() if method == 'dbscan': if distance_matrix_metric == 'haversine': # The input and output of sklearn's harvarsine metrix are both in radians, # see https://scikit-learn.org/stable/modules/generated/sklearn.metrics.pairwise.haversine_distances.html # here the 'epsilon' is directly applied to the metric's output. epsilon = epsilon / 6371000 # convert to radius db = DBSCAN(eps=epsilon, min_samples=num_samples, algorithm='ball_tree', metric=distance_matrix_metric) if agg_level == 'user': location_id_counter = 0 for user_id_this in ret_sp["user_id"].unique(): # Slice staypoints array by user. This is not a copy! user_staypoints = ret_sp[ret_sp["user_id"] == user_id_this] if distance_matrix_metric == 'haversine': # the input is converted to list of (lat, lon) tuples in radians unit p = np.array([[radians(g.y), radians(g.x)] for g in user_staypoints.geometry]) else: p = np.array([[g.x, g.y] for g in user_staypoints.geometry]) labels = db.fit_predict(p) # enforce unique lables across all users without changing noise labels max_label = np.max(labels) labels[labels != -1] = labels[labels != -1] + location_id_counter + 1 if max_label > -1: location_id_counter = location_id_counter + max_label + 1 # add staypoint - location matching to original staypoints ret_sp.loc[user_staypoints.index, 'location_id'] = labels else: if distance_matrix_metric == 'haversine': # the input is converted to list of (lat, lon) tuples in radians unit p = np.array([[radians(g.y), radians(g.x)] for g in ret_sp.geometry]) else: p = np.array([[g.x, g.y] for g in ret_sp.geometry]) labels = db.fit_predict(p) # add 1 to match the 'user' level result ret_sp['location_id'] = labels + 1 # create locations as grouped staypoints temp_sp = ret_sp[['user_id', 'location_id', ret_sp.geometry.name]] ret_loc = temp_sp.dissolve(by=['user_id', 'location_id'], as_index=False) # filter outlier ret_loc = ret_loc.loc[ret_loc['location_id'] != -1] # locations with only one staypoints is of type "Point" point_idx = ret_loc.geom_type == 'Point' ret_loc['center'] = 0 # initialize ret_loc.loc[point_idx, 'center'] = ret_loc.loc[point_idx, 'geom'] # locations with multiple staypoints is of type "MultiPoint" ret_loc.loc[~point_idx, 'center'] = ret_loc.loc[~point_idx, 'geom'].apply(lambda p: Point( np.array(p)[:, 0].mean(), np.array(p)[:, 1].mean())) # extent is the convex hull of the geometry ret_loc['extent'] = ret_loc['geom'].apply(lambda p: p.convex_hull) # convex_hull of one point would be a Point and two points a Linestring, # we change them into Polygon by creating a buffer of epsilon around them. pointLine_idx = (ret_loc['extent'].geom_type == 'LineString') | ( ret_loc['extent'].geom_type == 'Point') # Perform meter to decimal conversion if the distance metric is haversine if distance_matrix_metric == 'haversine': ret_loc.loc[ pointLine_idx, 'extent'] = ret_loc.loc[pointLine_idx].apply( lambda p: p['extent'].buffer( meters_to_decimal_degrees(epsilon, p['center'].y)), axis=1) else: ret_loc.loc[pointLine_idx, 'extent'] = ret_loc.loc[pointLine_idx].apply( lambda p: p['extent'].buffer(epsilon), axis=1) ret_loc = ret_loc.set_geometry('center') ret_loc = ret_loc[['user_id', 'location_id', 'center', 'extent']] ret_loc['location_id'] = ret_loc['location_id'].astype('int') return ret_sp, ret_loc
def plot_center_of_locations( locations, out_filename=None, radius=None, positionfixes=None, staypoints=None, staypoints_radius=None, plot_osm=False, axis=None, ): """Plot locations (optionally to a file). Optionally, you can specify several other datasets to be plotted beneath the locations. Parameters ---------- locations : GeoDataFrame (as trackintel locations) The locations to plot. out_filename : str, optional The file to plot to, if this is not set, the plot will simply be shown. radius : float, optional The radius in meter with which circles around locations should be drawn. positionfixes : GeoDataFrame (as trackintel positionfixes), optional If available, some positionfixes that can additionally be plotted. staypoints : GeoDataFrame (as trackintel staypoints), optional If available, some staypoints that can additionally be plotted. staypoints_radius : float, optional The radius in meter with which circles around staypoints should be drawn. plot_osm : bool, default False If this is set to True, it will download an OSM street network and plot below the staypoints. axis : matplotlib.pyplot.Artist, optional axis on which to draw the plot Examples -------- >>> df.as_locations.plot('output.png', radius=100, positionfixes=pdf, >>> staypoints=spf, staypoints_radius=80, plot_osm=True) """ if axis is None: _, ax = regular_figure() else: ax = axis locations = transform_gdf_to_wgs84(locations) if staypoints is not None: staypoints.as_staypoints.plot(radius=staypoints_radius, positionfixes=positionfixes, plot_osm=plot_osm, axis=ax) elif positionfixes is not None: positionfixes.as_positionfixes.plot(plot_osm=plot_osm, axis=ax) elif plot_osm: west = locations["center"].x.min() - 0.03 east = locations["center"].x.max() + 0.03 north = locations["center"].y.max() + 0.03 south = locations["center"].y.min() - 0.03 plot_osm_streets(north, south, east, west, ax) if radius is None: radius = 125 center_latitude = (ax.get_ylim()[0] + ax.get_ylim()[1]) / 2 radius = meters_to_decimal_degrees(radius, center_latitude) for pt in locations.to_dict("records"): circle = mpatches.Circle((pt["center"].x, pt["center"].y), radius, facecolor="none", edgecolor="r", zorder=4) ax.add_artist(circle) if out_filename is not None: save_fig(out_filename, formats=["png"]) else: plt.show()