Skip to content

Commit 746b8a4

Browse files
authored
Merge pull request #22 from TaskarCenterAtUW/dev
Merge from Dev to Stage
2 parents dc47cac + 0c72711 commit 746b8a4

22 files changed

+9597
-126
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Byte-compiled / optimized / DLL files
22
__pycache__/
3+
cache/
34
*.py[cod]
45
*$py.class
56

@@ -160,4 +161,4 @@ cython_debug/
160161
# and can be added to the global gitignore or merged into this file. For a more nuclear
161162
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
162163
#.idea/
163-
.ve
164+
.ve

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,22 @@
11
# TDEI-python-osw-quality-metric
22
Quality metric calculator for OSW record
3+
4+
5+
# Incoming message
6+
7+
```json
8+
{
9+
"datasetId":"",
10+
"intersection_file":""
11+
}
12+
13+
```
14+
15+
# Outgoing message
16+
```json
17+
{
18+
"datasetId":"",
19+
"metrics_file":""
20+
}
21+
22+
```

requirements.txt

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
11
fastapi~=0.112.1
22
python_ms_core~=0.0.22
33
pydantic_settings~=2.4.0
4-
uvicorn~=0.30.6
4+
uvicorn~=0.30.6
5+
networkx==3.2.1
6+
geopandas==0.12.2
7+
osmnx==1.6.0
8+
dask==2024.5.2
9+
dask-geopandas==0.3.1
10+
geonetworkx==0.5.3
11+
shapely==2.0.1
12+
numpy==1.26.4
13+
pandas==1.3.4
14+
fiona==1.9.6

src/assets/messages/incoming.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
"messageId": "message-id-from-msg",
44
"data": {
55
"jobId": "0b41ebc5-350c-42d3-90af-3af4ad3628fb",
6-
"data_file": "https://tdeisamplestorage.blob.core.windows.net/osw/test_upload/Archive.zip",
7-
"algorithms": "fixed"
6+
"data_file": "https://tdeisamplestorage.blob.core.windows.net/osw/test/wenatchee.zip",
7+
"algorithm": "fixed",
8+
"sub_regions_file":""
89
}
910
}

src/calculators/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
from .qm_calculator import QMCalculator
2-
from .qm_fixed_calculator import QMFixedCalculator, QMRandomCalculator
2+
from .qm_fixed_calculator import QMFixedCalculator
3+
from .qm_xn_lib_calculator import QMXNLibCalculator

src/calculators/qm_calculator.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
from abc import ABC, abstractmethod
2+
from typing import NamedTuple
3+
4+
class QualityMetricResult(NamedTuple):
5+
success: bool
6+
message: str
7+
output_file: str
28

39
class QMCalculator(ABC):
410
@abstractmethod
5-
def calculate_quality_metric(self, feature:dict) -> float:
11+
def calculate_quality_metric(self) -> QualityMetricResult:
612
pass
713
@abstractmethod
8-
def qm_metric_tag(self) -> str:
9-
pass
14+
def algorithm_name(self) -> str:
15+
pass
Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,41 @@
1-
from .qm_calculator import QMCalculator
1+
from src.calculators.qm_calculator import QMCalculator, QualityMetricResult
22
import random
3-
3+
import geopandas as gpd
4+
import sys
45

56
class QMFixedCalculator(QMCalculator):
6-
def __init__(self):
7+
'''
8+
Dummy quality metric calculator that assigns a random score to each edge in the input file
9+
'''
10+
11+
def __init__(self, edges_file_path:str, output_file_path:str, polygon_file_path:str=None):
12+
self.edges_file_path = edges_file_path
13+
self.output_file_path = output_file_path
14+
self.polygon_file_path = polygon_file_path
715
pass
8-
9-
def calculate_quality_metric(self, feature: dict) -> float:
10-
return 10.5
11-
def qm_metric_tag(self) -> str:
12-
return "ext:qm:fixed"
1316

17+
def calculate_quality_metric(self):
18+
gdf = gpd.read_file(self.edges_file_path)
19+
gdf['fixed_score'] = random.randint(0, 100)
20+
gdf.to_file(self.output_file_path)
21+
return QualityMetricResult(success=True, message="QMFixedCalculator", output_file=self.output_file_path)
1422

15-
class QMRandomCalculator(QMCalculator):
16-
def __init__(self):
17-
pass
18-
19-
def calculate_quality_metric(self, feature: dict) -> float:
20-
return random.uniform(30, 100)
23+
def algorithm_name(self):
24+
return "QMFixedCalculator"
25+
26+
27+
if __name__ == '__main__':
28+
osw_edge_file_path = sys.argv[1] # First argument: OSW edge file path
29+
qm_file_path = sys.argv[2] # Second argument: Quality metric output file path
2130

22-
def qm_metric_tag(self) -> str:
23-
return "ext:qm:random"
31+
# Check if the optional third argument (xn_polygon_path) is provided
32+
if len(sys.argv) > 3:
33+
xn_polygon_path = sys.argv[3] # Third argument: Intersection polygon file path (optional)
34+
qm_calculator = QMFixedCalculator(osw_edge_file_path, qm_file_path, xn_polygon_path)
35+
print(qm_calculator.calculate_quality_metric())
36+
37+
else:
38+
# If the third argument is not provided, call without xn_polygon_path
39+
qm_calculator = QMFixedCalculator(osw_edge_file_path, qm_file_path)
40+
print(qm_calculator.calculate_quality_metric())
41+
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
from src.calculators.qm_calculator import QMCalculator, QualityMetricResult
2+
import geopandas as gpd
3+
import sys
4+
import warnings
5+
import networkx as nx
6+
import traceback
7+
import geonetworkx as gnx
8+
import osmnx as ox
9+
import dask_geopandas
10+
from shapely import Point, LineString, MultiLineString, Polygon, MultiPolygon
11+
from shapely.ops import voronoi_diagram
12+
import itertools
13+
import numpy as np
14+
import pandas as pd
15+
16+
17+
class QMXNLibCalculator(QMCalculator):
18+
def __init__(self, edges_file_path:str, output_file_path:str, polygon_file_path:str=None):
19+
"""
20+
Initializes the QMXNLibCalculator class.
21+
22+
Args:
23+
edges_file_path (str): Path to the file containing the OSW edge data.
24+
output_file_path (str): Path to where the output quality metric file will be saved.
25+
polygon_file_path (str, optional): Path to the intersection polygon file. If not provided, will use the polygon computed from the convex hull of OSW edge data. Defaults to None.
26+
"""
27+
self.edges_file_path = edges_file_path
28+
self.output_file_path = output_file_path
29+
self.polygon_file_path = polygon_file_path
30+
warnings.filterwarnings("ignore")
31+
self.default_projection = 'epsg:26910'
32+
self.output_projection = 'epsg:4326'
33+
self.precision = 1e-5
34+
35+
def add_edges_from_linestring(self, graph, linestring, edge_attrs):
36+
points = list(linestring.coords)
37+
for start, end in zip(points[:-1], points[1:]):
38+
graph.add_edge(start, end, **edge_attrs)
39+
40+
def graph_from_gdf(self, gdf):
41+
G = nx.Graph()
42+
for index, row in gdf.iterrows():
43+
geom = row.geometry
44+
if isinstance(geom, LineString):
45+
self.add_edges_from_linestring(G, geom, row.to_dict())
46+
elif isinstance(geom, MultiLineString):
47+
for linestring in geom.geoms:
48+
self.add_edges_from_linestring(G, linestring, row.to_dict())
49+
return G
50+
51+
def group_G_pts(self, G, poly):
52+
P = poly
53+
node_pts = [Point(x) for x in G.nodes()]
54+
boundary = list(P.boundary.coords)
55+
segments = [LineString([boundary[i], boundary[i + 1]]) for i in range(len(boundary) - 1)]
56+
segment_point_map = {index: [] for index in range(len(segments))}
57+
for point in node_pts:
58+
for idx, segment in enumerate(segments):
59+
if segment.distance(point) < self.precision:
60+
segment_point_map[idx].append((point.x, point.y))
61+
break
62+
return segment_point_map
63+
64+
def edges_are_connected(self, G, e1_pts, e2_pts):
65+
for pt1 in e1_pts:
66+
for pt2 in e2_pts:
67+
if nx.has_path(G, pt1, pt2):
68+
return True
69+
return False
70+
71+
def algorithm_name(self):
72+
return "QMXNLibCalculator"
73+
74+
def tile_tra_score(self, G, polygon):
75+
# assign each point to a polygon line
76+
pts_line_map = self.group_G_pts(G, polygon)
77+
boundary_nodes = [item for sublist in pts_line_map.values() for item in sublist]
78+
79+
# find all pair of edges
80+
edge_pairs = list(itertools.combinations_with_replacement(pts_line_map.keys(), 2))
81+
82+
n_total = len(edge_pairs)
83+
n_connected = 0
84+
connected_pairs = list()
85+
for pair in edge_pairs:
86+
is_connected = self.edges_are_connected(G, pts_line_map[pair[0]], pts_line_map[pair[1]])
87+
if is_connected:
88+
n_connected += 1
89+
connected_pairs.append(pair)
90+
return n_total, n_connected, connected_pairs
91+
92+
def get_stats(self, polygon, G, gdf):
93+
stats = {}
94+
undirected_g = nx.Graph(G)
95+
96+
try:
97+
n_total, n_connected, connected_pairs = self.tile_tra_score(G, polygon)
98+
stats['tra_score'] = n_connected / n_total
99+
except Exception as e:
100+
print(f"Unexpected {e}, {type(e)} with polygon {polygon} when getting number of connected edge pairs")
101+
#traceback.print_exc()
102+
stats["tra_score"] = -1
103+
return stats
104+
105+
def get_measures_from_polygon(self, polygon, gdf):
106+
if isinstance(polygon, MultiPolygon) and len(polygon.geoms)==1:
107+
polygon = polygon.geoms[0]
108+
# crop gdf to the polygon
109+
cropped_gdf = gpd.clip(gdf, polygon)
110+
111+
G = self.graph_from_gdf(cropped_gdf)
112+
stats = self.get_stats(polygon, G, cropped_gdf)
113+
return stats
114+
115+
def qm_func(self, feature, gdf):
116+
poly = feature.geometry
117+
if (poly.geom_type == 'Polygon' or poly.geom_type == 'MultiPolygon'):
118+
measures = self.get_measures_from_polygon(poly, gdf)
119+
feature.loc['tra_score'] = measures['tra_score']
120+
return feature
121+
else:
122+
return feature
123+
124+
def create_voronoi_diagram(self, G_roads_simplified, bounds):
125+
# first thin the nodes
126+
gdf_roads_simplified = gnx.graph_edges_to_gdf(G_roads_simplified)
127+
voronoi = voronoi_diagram(gdf_roads_simplified.boundary.unary_union, envelope=bounds)
128+
voronoi_gdf = gpd.GeoDataFrame({'geometry':voronoi.geoms})
129+
voronoi_gdf = voronoi_gdf.set_crs(gdf_roads_simplified.crs)
130+
voronoi_gdf_clipped = gpd.clip(voronoi_gdf, bounds)
131+
voronoi_gdf_clipped = voronoi_gdf_clipped.to_crs(self.default_projection)
132+
133+
return voronoi_gdf_clipped
134+
135+
def calculate_quality_metric(self):
136+
try:
137+
gdf = gpd.read_file(self.edges_file_path)
138+
139+
if self.polygon_file_path:
140+
tile_gdf = gpd.read_file(self.polygon_file_path)
141+
else:
142+
unified_geom = gdf.unary_union
143+
bounding_polygon = unified_geom.convex_hull
144+
g_roads_simplified = ox.graph.graph_from_polygon(bounding_polygon, network_type='drive', simplify=True, retain_all=True)
145+
tile_gdf = self.create_voronoi_diagram(g_roads_simplified, bounding_polygon)
146+
147+
gdf = gdf.to_crs(self.default_projection)
148+
tile_gdf = tile_gdf.to_crs(self.default_projection)
149+
tile_gdf = tile_gdf[['geometry']]
150+
151+
df_dask = dask_geopandas.from_geopandas(tile_gdf, npartitions=64)
152+
153+
output = df_dask.apply(self.qm_func,axis=1, meta=[
154+
('geometry', 'geometry'),
155+
('tra_score', 'object')
156+
], gdf=gdf).compute(scheduler='multiprocessing')
157+
output = output.to_crs(self.output_projection) # The output should be in WGS84 (epsg:4326)
158+
output.to_file(self.output_file_path, driver='GeoJSON')
159+
return QualityMetricResult(success=True, message='QMXNLibCalculator', output_file=self.output_file_path)
160+
161+
except Exception as e:
162+
print(f"Error {e} occurred when calculating quality metric for data {self.edges_file_path}")
163+
return QualityMetricResult(success=False, message=f'Error: {e}', output_file="")
164+
165+
166+
if __name__ == '__main__':
167+
osw_edge_file_path = sys.argv[1] # First argument: OSW edge file path
168+
qm_file_path = sys.argv[2] # Second argument: Quality metric output file path
169+
170+
# Check if the optional third argument (xn_polygon_path) is provided
171+
if len(sys.argv) > 3:
172+
xn_polygon_path = sys.argv[3] # Third argument: Intersection polygon file path (optional)
173+
qm_calculator = QMXNLibCalculator(osw_edge_file_path, qm_file_path, xn_polygon_path)
174+
print(qm_calculator.calculate_quality_metric())
175+
176+
else:
177+
# If the third argument is not provided, call without xn_polygon_path
178+
qm_calculator = QMXNLibCalculator(osw_edge_file_path, qm_file_path)
179+
print(qm_calculator.calculate_quality_metric())

0 commit comments

Comments
 (0)