Skip to content

Commit dd22c13

Browse files
committed
Improve GeoPandas to shapefile creation
Improve combine_bird_predictions.py Refactor process_nests.py for accurate date handling Ensure empty shapefile schema are supported Updated .astype use explicit 'int64' and 'float64' instead of 'int' and 'float
1 parent e86e018 commit dd22c13

5 files changed

Lines changed: 148 additions & 84 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@
99
App/Zooniverse/*
1010
lightning_logs
1111
logs/**
12+
*core.*

combine_bird_predictions.py

Lines changed: 54 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,75 @@
11
import os
22
import sys
33
import shutil
4-
from zipfile import ZIP_DEFLATED
5-
from zipfile import ZipFile
6-
import geopandas
4+
from zipfile import ZipFile, ZIP_DEFLATED
5+
import geopandas as gpd
76
import pandas as pd
87
import tools
98

109

1110
def combine(paths):
12-
"""Take prediction shapefiles and wrap into a single file"""
13-
shapefiles = []
14-
for x in paths:
15-
shapefiles.append(geopandas.read_file(x))
16-
summary = geopandas.GeoDataFrame(pd.concat(shapefiles, ignore_index=True), crs=shapefiles[0].crs)
17-
return summary
11+
"""Read multiple prediction shapefiles and concatenate into one GeoDataFrame."""
12+
gdfs = []
13+
target_crs = None
14+
for p in paths:
15+
gdf = gpd.read_file(p)
16+
if target_crs is None:
17+
target_crs = gdf.crs
18+
elif gdf.crs != target_crs:
19+
# Reproject to the CRS of the first file
20+
gdf = gdf.to_crs(target_crs)
21+
gdfs.append(gdf)
22+
if not gdfs:
23+
raise ValueError("No input shapefiles provided.")
24+
return gpd.GeoDataFrame(pd.concat(gdfs, ignore_index=True), crs=target_crs)
1825

1926

2027
if __name__ == "__main__":
28+
if len(sys.argv) < 2:
29+
print("Usage: python combine_bird_predictions.py <shp1> <shp2> ...")
30+
sys.exit(1)
31+
2132
working_dir = tools.get_working_dir()
22-
predictions_path = f"{working_dir}/predictions/"
23-
output_path = f"{working_dir}/everwatch-workflow/App/Zooniverse/data"
24-
output_zip = os.path.join(output_path, "PredictedBirds.zip")
33+
output_path = os.path.join(working_dir, "everwatch-workflow", "App", "Zooniverse", "data")
34+
os.makedirs(output_path, exist_ok=True)
35+
36+
output_shp_base = os.path.join(output_path, "PredictedBirds")
37+
output_zip = output_shp_base + ".zip"
2538

39+
# Read and combine
2640
predictions = sys.argv[1:]
27-
# write output to zooniverse app
2841
df = combine(predictions)
29-
df.to_file(os.path.join(output_path, "PredictedBirds.shp"))
30-
31-
# Write output as csv
32-
grouped_df = df.groupby(['Site', 'Date', 'label']).size().reset_index(name='count')
33-
csv_file_path = os.path.join(output_path, "PredictedBirds.csv")
34-
grouped_df.to_csv(csv_file_path, index=False)
35-
36-
# Zip the shapefile for storage efficiency
37-
with ZipFile(output_zip, 'w', ZIP_DEFLATED) as zip:
38-
for ext in ['cpg', 'dbf', 'prj', 'shp', 'shx']:
39-
focal_file = os.path.join(output_path, f"PredictedBirds.{ext}")
40-
file_name = os.path.basename(focal_file)
41-
zip.write(focal_file, arcname=file_name)
42-
os.remove(focal_file)
43-
44-
# Copy PredictedBirds.zip to everglades-forecast-web repo
45-
dest_path = "/blue/ewhite/everglades/everglades-forecast-web/data"
46-
if not os.path.exists(dest_path):
47-
os.makedirs(dest_path)
42+
43+
try:
44+
import pyogrio
45+
df.to_file(f"{output_shp_base}.shp", driver="ESRI Shapefile", engine="pyogrio")
46+
except ImportError:
47+
df.to_file(f"{output_shp_base}.shp", driver="ESRI Shapefile", engine="fiona")
48+
49+
# Write summary CSV
50+
grouped_df = df.groupby(["Site", "Date", "label"]).size().reset_index(name="count")
51+
grouped_df.to_csv(output_shp_base + ".csv", index=False)
52+
53+
# Zip shapefile components
54+
shp_exts = ["cpg", "dbf", "prj", "shp", "shx"]
55+
with ZipFile(output_zip, "w", compression=ZIP_DEFLATED) as zf:
56+
for ext in shp_exts:
57+
f = f"{output_shp_base}.{ext}"
58+
if os.path.exists(f):
59+
zf.write(f, arcname=os.path.basename(f))
60+
# Clean up shapefile parts after zipping
61+
for ext in shp_exts:
62+
f = f"{output_shp_base}.{ext}"
63+
if os.path.exists(f):
64+
os.remove(f)
65+
66+
# Copy PredictedBirds.zip to forecast web repo (ensure permissions)
67+
dest_path = os.path.join(working_dir, "everglades-forecast-web", "data")
68+
os.makedirs(dest_path, exist_ok=True)
4869
dest_file = os.path.join(dest_path, "PredictedBirds.zip")
4970

5071
if os.path.exists(output_zip):
5172
shutil.copy(output_zip, dest_file)
5273
print(f"{output_zip} copied to {dest_file}.")
5374
else:
54-
print("{output_zip} file does not exist.")
75+
print(f"{output_zip} file does not exist.")

combine_birds_site_year.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,12 @@ def combine_files(bird_detection_files, year, site, score_thresh, savedir):
2929
df.crs = eventdf.crs
3030
df = df.assign(bird_id=range(1, len(df) + 1)) # Index bird IDs starting at 1
3131
filename = os.path.join(savedir, f"{site}_{year}_combined.shp")
32-
df.to_file(filename)
32+
33+
try:
34+
import pyogrio
35+
df.to_file(filename, driver="ESRI Shapefile", engine="pyogrio")
36+
except ImportError:
37+
df.to_file(filename, driver="ESRI Shapefile", engine="fiona")
3338

3439
return df
3540

combine_nests.py

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import glob
21
import os
32
import re
43
import sys
@@ -18,21 +17,20 @@ def get_site(path):
1817

1918
def load_shapefile(x):
2019
shp = geopandas.read_file(x)
21-
# Force correct types
22-
# Empty shape files don't see to maintain provided types
23-
# when written and loaded
20+
# Force correct datatypes
21+
# Empty shapefiles don't seem to maintain provided types when written and loaded
2422
shp = shp.astype({
25-
'nest_id': 'int',
23+
'nest_id': 'int64',
2624
'Site': 'str',
2725
'Year': 'str',
28-
'xmean': 'float',
29-
'ymean': 'float',
26+
'xmean': 'float64',
27+
'ymean': 'float64',
3028
'first_obs': 'str',
3129
'last_obs': 'str',
32-
'num_obs': 'int',
30+
'num_obs': 'int64',
3331
'species': 'str',
34-
'sum_top1': 'float',
35-
'num_top1': 'int',
32+
'sum_top1': 'float64',
33+
'num_top1': 'int64',
3634
'bird_match': 'str'
3735
})
3836
shp["site"] = get_site(x)

process_nests.py

Lines changed: 79 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -42,67 +42,106 @@ def count_max_consec_detects(nest_data, date_data):
4242

4343

4444
def process_nests(nest_file, year, site, savedir, min_score=0.3, min_detections=3, min_consec_detects=1):
45-
"""Process nests into a one row per nest table"""
45+
"""Process nests into a one-row-per-nest table"""
46+
SCHEMA = {
47+
"geometry": "Point",
48+
"properties": {
49+
"nest_id": "int",
50+
"Site": "str",
51+
"Year": "str",
52+
"xmean": "float",
53+
"ymean": "float",
54+
"first_obs": "str",
55+
"last_obs": "str",
56+
"num_obs": "int",
57+
"species": "str",
58+
"sum_top1": "float",
59+
"num_top1": "int",
60+
"bird_match": "str",
61+
},
62+
}
63+
4664
nests_data = geopandas.read_file(nest_file)
65+
66+
# Convert numeric columns to correct types if they're strings
67+
# (shapefiles sometimes read them as strings)
68+
numeric_columns = ['score', 'match_xmin', 'match_xmax', 'match_ymin', 'match_ymax', 'target_ind', 'bird_id']
69+
# Only convert columns that exist and are object type
70+
cols_to_convert = [
71+
col for col in numeric_columns if col in nests_data.columns and nests_data[col].dtype == 'object'
72+
]
73+
if cols_to_convert:
74+
nests_data[cols_to_convert] = nests_data[cols_to_convert].apply(pd.to_numeric, errors='coerce')
75+
76+
# Build date_data: single row with all dates for the site-year
4777
date_data = nests_data.groupby(['Site', 'Year']).agg({'Date': lambda x: x.unique().tolist()}).reset_index()
4878
target_inds = nests_data['target_ind'].unique()
49-
nests = []
79+
nests_rows = []
80+
5081
for target_ind in target_inds:
51-
nest_data = nests_data[(nests_data['target_ind'] == target_ind) & (nests_data['score'] >= min_score)]
82+
nest_data = nests_data[(nests_data["target_ind"] == target_ind) & (nests_data["score"] >= min_score)]
5283
num_consec_detects = count_max_consec_detects(nest_data, date_data)
84+
5385
if len(nest_data) >= min_detections or num_consec_detects >= min_consec_detects:
86+
# Aggregate scores per label and pick the top label by summed score
5487
summed_scores = nest_data.groupby(['Site', 'Year', 'target_ind', 'label']).score.agg(['sum', 'count'])
5588
top_score_data = summed_scores[summed_scores['sum'] == max(summed_scores['sum'])].reset_index()
89+
5690
nest_info = nest_data.groupby(['Site', 'Year', 'target_ind']).agg({
5791
'Date': ['min', 'max', 'count'],
5892
'match_xmin': ['mean'],
5993
'match_ymin': ['mean'],
6094
'match_xmax': ['mean'],
6195
'match_ymax': ['mean']
6296
}).reset_index()
63-
xmean = (nest_info['match_xmin']['mean'][0] + nest_info['match_xmax']['mean']) / 2
64-
ymean = (nest_info['match_ymin']['mean'][0] + nest_info['match_ymax']['mean']) / 2
65-
bird_match = ",".join([str(x) for x in nest_data["bird_id"]])
66-
nests.append([
67-
target_ind, nest_info['Site'][0], nest_info['Year'][0], xmean[0], ymean[0], nest_info['Date']['min'][0],
68-
nest_info['Date']['max'][0], nest_info['Date']['count'][0], top_score_data['label'][0],
69-
top_score_data['sum'][0], top_score_data['count'][0], bird_match
97+
98+
xmean = (nest_info['match_xmin']['mean'][0] + nest_info['match_xmax']['mean'][0]) / 2
99+
ymean = (nest_info['match_ymin']['mean'][0] + nest_info['match_ymax']['mean'][0]) / 2
100+
101+
nests_rows.append([
102+
int(target_ind),
103+
str(nest_info['Site'][0]),
104+
str(nest_info['Year'][0]),
105+
float(xmean),
106+
float(ymean),
107+
str(nest_info['Date']['min'][0]),
108+
str(nest_info['Date']['max'][0]),
109+
int(nest_info['Date']['count'][0]),
110+
str(top_score_data['label'][0]),
111+
float(top_score_data['sum'][0]),
112+
int(top_score_data['count'][0]),
113+
",".join(str(x) for x in nest_data["bird_id"]),
70114
])
71115

72-
if not os.path.exists(savedir):
73-
os.makedirs(savedir)
116+
os.makedirs(savedir, exist_ok=True)
74117
filename = os.path.join(savedir, f"{site}_{year}_processed_nests.shp")
75118

76-
if nests:
77-
nests = pd.DataFrame(nests,
78-
columns=[
79-
'nest_id', 'Site', 'Year', 'xmean', 'ymean', 'first_obs', 'last_obs', 'num_obs',
80-
'species', 'sum_top1', 'num_top1', 'bird_match'
81-
])
82-
nests_shp = geopandas.GeoDataFrame(nests, geometry=geopandas.points_from_xy(nests.xmean, nests.ymean))
83-
nests_shp.crs = nests_data.crs
84-
nests_shp.to_file(filename)
119+
gdf_tofile = None
120+
if nests_rows:
121+
nests_df = pd.DataFrame(nests_rows, columns=list(SCHEMA["properties"].keys()))
122+
nests_gdf = geopandas.GeoDataFrame(
123+
nests_df,
124+
geometry=geopandas.points_from_xy(nests_df.xmean, nests_df.ymean),
125+
crs=nests_data.crs,
126+
)
127+
gdf_tofile = nests_gdf
85128
else:
86-
schema = {
87-
"geometry": "Polygon",
88-
"properties": {
89-
'nest_id': 'int',
90-
'Site': 'str',
91-
'Year': 'str',
92-
'xmean': 'float',
93-
'ymean': 'float',
94-
'first_obs': 'str',
95-
'last_obs': 'str',
96-
'num_obs': 'int',
97-
'species': 'str',
98-
'sum_top1': 'float',
99-
'num_top1': 'int',
100-
'bird_match': 'str'
101-
}
129+
empty_data = {
130+
k: pd.Series(dtype="int64" if v == "int" else "float64" if v == "float" else "object")
131+
for k, v in SCHEMA["properties"].items()
102132
}
103-
crs = nests_data.crs
104-
empty_nests = geopandas.GeoDataFrame(geometry=[])
105-
empty_nests.to_file(filename, driver='ESRI Shapefile', schema=schema, crs=crs)
133+
empty_gdf = geopandas.GeoDataFrame(
134+
empty_data,
135+
geometry=geopandas.GeoSeries([], dtype="geometry"),
136+
crs=nests_data.crs,
137+
)
138+
gdf_tofile = empty_gdf
139+
140+
try:
141+
import pyogrio
142+
gdf_tofile.to_file(filename, driver="ESRI Shapefile", engine="pyogrio")
143+
except ImportError:
144+
gdf_tofile.to_file(filename, driver="ESRI Shapefile", engine="fiona")
106145

107146

108147
if __name__ == "__main__":

0 commit comments

Comments
 (0)