Skip to content

Commit 989a2f5

Browse files
authored
Merge pull request #38 from TaskarCenterAtUW/feature-2409
[0.2.12] Fixed-2409
2 parents d05234d + a5e2c5e commit 989a2f5

File tree

11 files changed

+435
-28
lines changed

11 files changed

+435
-28
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Change log
22

3+
### 0.2.12
4+
- Updated OSMTaggedNodeParser to apply the OSW node and point filters with normalization before adding loose tagged nodes, ensuring non-compliant features like crossings are no longer emitted.
5+
- Extended serializer tests to cover the new tagged-node behavior, confirming that compliant kerb features are retained while schema-invalid crossings are skipped.
6+
- Updated GeoJSON node export to normalize IDs, retain full OSM identifiers, and skip non-OSW features so schema-invalid crossings are no longer emitted.
7+
- Ensured only synthetic node IDs have their prefix trimmed, fixing the prior bug where numeric IDs lost the leading digit and caused _id/ext:osm_id mismatches.
8+
- Expanded serializer tests to cover OSW-compliant node export, rejection of non-compliant crossings, and prefix handling for generated point IDs.
9+
- Refined GeoJSON export to filter nodes using tag-only metadata, preventing schema-invalid features from being emitted.
10+
- Normalized ext:osm_id handling to preserve full numeric identifiers while trimming prefixed synthetic values.
11+
12+
313
### 0.2.11
414
- Retain numeric `incline` values and new `length` tags during way normalization
515
- Recognize any `highway=steps` way as stairs, preserving valid `climb` tags

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,4 @@ shapely~=2.0.2
55
pyproj~=3.6.1
66
coverage~=7.5.1
77
ogr2osm==1.2.0
8-
python-osw-validation==0.2.13
8+
python-osw-validation==0.2.15

src/osm_osw_reformatter/serializer/osm/osm_graph.py

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -308,16 +308,30 @@ def area(self, a):
308308
exteriors_count = exteriors_count + 1
309309

310310
class OSMTaggedNodeParser(osmium.SimpleHandler):
311-
def __init__(self, G: nx.MultiDiGraph):
311+
def __init__(self, G: nx.MultiDiGraph, node_filter: Optional[callable] = None,
312+
point_filter: Optional[callable] = None) -> None:
313+
312314
osmium.SimpleHandler.__init__(self)
313315
self.G = G
316+
self.node_filter = node_filter or (lambda tags: False)
317+
self.point_filter = point_filter or (lambda tags: False)
314318

315319
def node(self, n):
316-
# Only add nodes with tags
317-
if n.tags and len(n.tags) > 0:
318-
d = dict(n.tags)
319-
# Store OSM node id as string (to match the pattern in your output)
320-
self.G.add_node(n.id, lon=n.location.lon, lat=n.location.lat, **d)
320+
if not n.tags or len(n.tags) == 0:
321+
return
322+
323+
tags = dict(n.tags)
324+
325+
if self.node_filter(tags):
326+
normalized = OSWNodeNormalizer(tags).normalize()
327+
if normalized:
328+
self.G.add_node(n.id, lon=n.location.lon, lat=n.location.lat, **normalized)
329+
return
330+
331+
if self.point_filter(tags):
332+
normalized = OSWPointNormalizer(tags).normalize()
333+
if normalized:
334+
self.G.add_node("p" + str(n.id), lon=n.location.lon, lat=n.location.lat, **normalized)
321335

322336
class OSMGraph:
323337
def __init__(self, G: nx.MultiDiGraph = None) -> None:
@@ -359,21 +373,21 @@ def from_osm_file(
359373
del line_parser
360374

361375
# --- PATCH START: Add all loose/tagged nodes ---
362-
tagged_node_parser = OSMTaggedNodeParser(G)
376+
tagged_node_parser = OSMTaggedNodeParser(G, node_filter, point_filter)
363377
tagged_node_parser.apply_file(osm_file)
364378
G = tagged_node_parser.G
365379
del tagged_node_parser
366380
# --- PATCH END ---
367381

368-
# zone_parser = OSMZoneParser(G, zone_filter, progressbar=progressbar)
369-
# zone_parser.apply_file(osm_file)
370-
# G = zone_parser.G
371-
# del zone_parser
382+
zone_parser = OSMZoneParser(G, zone_filter, progressbar=progressbar)
383+
zone_parser.apply_file(osm_file)
384+
G = zone_parser.G
385+
del zone_parser
372386

373-
# polygon_parser = OSMPolygonParser(G, polygon_filter, progressbar=progressbar)
374-
# polygon_parser.apply_file(osm_file)
375-
# G = polygon_parser.G
376-
# del polygon_parser
387+
polygon_parser = OSMPolygonParser(G, polygon_filter, progressbar=progressbar)
388+
polygon_parser.apply_file(osm_file)
389+
G = polygon_parser.G
390+
del polygon_parser
377391

378392
return OSMGraph(G)
379393

@@ -618,7 +632,9 @@ def to_geojson(self, *args) -> None:
618632
polygon_features = []
619633
for n, d in self.G.nodes(data=True):
620634
d_copy = {**d}
621-
d_copy["_id"] = str(n)[1:]
635+
id_str = str(n)
636+
trimmed_id = id_str[1:] if isinstance(n, str) else id_str
637+
d_copy["_id"] = trimmed_id
622638
d_copy['ext:osm_id'] = str(d_copy.get('osm_id', d_copy["_id"]))
623639

624640
if OSWPointNormalizer.osw_point_filter(d):

src/osm_osw_reformatter/serializer/osm/osm_normalizer.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,105 @@ def process_feature_post(self, osmgeometry, ogrfeature, ogrgeometry):
7878

7979
if osm_id is not None:
8080
osmgeometry.id = osm_id
81+
82+
def process_output(self, osmnodes, osmways, osmrelations):
83+
"""
84+
Convert negative IDs into deterministic 63-bit positive IDs
85+
for all nodes, ways, and relations (and their references),
86+
and add a '_id' tag with the new derived positive ID.
87+
"""
88+
mask_63bit = (1 << 63) - 1
89+
90+
def _set_id_tag(osm_obj, new_id):
91+
tags = getattr(osm_obj, "tags", None)
92+
if tags is None or not hasattr(tags, "__setitem__"):
93+
return
94+
95+
value = str(new_id)
96+
existing = tags.get("_id") if hasattr(tags, "get") else None
97+
98+
if isinstance(existing, list):
99+
tags["_id"] = [value]
100+
elif existing is None:
101+
# Determine if the container generally stores values as lists
102+
sample_value = None
103+
if hasattr(tags, "values"):
104+
for sample_value in tags.values():
105+
if sample_value is not None:
106+
break
107+
if isinstance(sample_value, list):
108+
tags["_id"] = [value]
109+
else:
110+
# Default to list storage to match ogr2osm's internal structures
111+
tags["_id"] = [value]
112+
else:
113+
tags["_id"] = value
114+
115+
def _normalise_id(osm_obj):
116+
if osm_obj.id < 0:
117+
new_id = osm_obj.id & mask_63bit
118+
osm_obj.id = new_id
119+
_set_id_tag(osm_obj, new_id)
120+
return new_id
121+
return osm_obj.id
122+
123+
# Fix node IDs
124+
for node in osmnodes:
125+
_normalise_id(node)
126+
127+
# Fix ways and their node references
128+
for way in osmways:
129+
_normalise_id(way)
130+
131+
# Detect how node references are stored
132+
node_refs = getattr(way, "nds", None) or getattr(way, "refs", None) or getattr(way, "nodeRefs", None) or getattr(way, "nodes", None)
133+
134+
if node_refs is not None:
135+
new_refs = []
136+
for ref in node_refs:
137+
# Handle both int and OsmNode-like objects
138+
if isinstance(ref, int):
139+
new_refs.append(ref & mask_63bit if ref < 0 else ref)
140+
elif hasattr(ref, "id"):
141+
if ref.id < 0:
142+
ref.id = ref.id & mask_63bit
143+
_set_id_tag(ref, ref.id)
144+
new_refs.append(ref)
145+
else:
146+
new_refs.append(ref)
147+
148+
# Write back using whichever attribute exists
149+
if hasattr(way, "nds"):
150+
way.nds = new_refs
151+
elif hasattr(way, "refs"):
152+
way.refs = new_refs
153+
elif hasattr(way, "nodeRefs"):
154+
way.nodeRefs = new_refs
155+
elif hasattr(way, "nodes"):
156+
way.nodes = new_refs
157+
158+
# Fix relation IDs and their member refs
159+
for rel in osmrelations:
160+
if rel.id < 0:
161+
rel.id = rel.id & mask_63bit
162+
_normalise_id(rel)
163+
164+
if hasattr(rel, "members"):
165+
for member in rel.members:
166+
if hasattr(member, "ref"):
167+
ref = member.ref
168+
if isinstance(ref, int) and ref < 0:
169+
member.ref = ref & mask_63bit
170+
elif hasattr(ref, "id") and ref.id < 0:
171+
ref.id = ref.id & mask_63bit
172+
_set_id_tag(ref, ref.id)
173+
174+
# Ensure deterministic ordering now that IDs have been normalised
175+
if hasattr(osmnodes, "sort"):
176+
osmnodes.sort(key=lambda n: n.id)
177+
if hasattr(osmways, "sort"):
178+
osmways.sort(key=lambda w: w.id)
179+
if hasattr(osmrelations, "sort"):
180+
osmrelations.sort(key=lambda r: r.id)
181+
182+

src/osm_osw_reformatter/serializer/osw/osw_normalizer.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,7 @@ def __init__(self, tags):
430430
self.tags = tags
431431

432432
def filter(self):
433-
return (self.is_building())
433+
return self.is_building()
434434

435435
@staticmethod
436436
def osw_polygon_filter(tags):
@@ -457,7 +457,7 @@ def __init__(self, tags):
457457
self.tags = tags
458458

459459
def filter(self):
460-
return (self.is_pedestrian())
460+
return self.is_pedestrian()
461461

462462
@staticmethod
463463
def osw_zone_filter(tags):

src/osm_osw_reformatter/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = '0.2.11'
1+
__version__ = '0.2.12'
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?xml version='1.0' encoding='UTF-8'?>
2+
<osm version='0.6' generator='JOSM'>
3+
<node id='565516917' timestamp='2018-06-03T18:15:49Z' uid='5046269' user='Rich1234' visible='true' version='3' changeset='59515112' lat='38.8605033' lon='-77.0598865'>
4+
<tag k='highway' v='traffic_signals' />
5+
<tag k='source' v='survey' />
6+
</node>
7+
</osm>

tests/unit_tests/test_osm2osw/test_osm2osw.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
TEST_FILE = os.path.join(ROOT_DIR, 'test_files/wa.microsoft.osm.pbf')
1212
TEST_WIDTH_FILE = os.path.join(ROOT_DIR, 'test_files/width-test.xml')
1313
TEST_INCLINE_FILE = os.path.join(ROOT_DIR, 'test_files/incline-test.xml')
14+
TEST_INVALID_NODE_TAGS_FILE = os.path.join(ROOT_DIR, 'test_files/node_with_invalid_tags.xml')
1415

1516

1617
def is_valid_float(value):
@@ -33,13 +34,13 @@ async def run_test():
3334

3435
asyncio.run(run_test())
3536

36-
def test_generated_3_files(self):
37+
def test_generated_files(self):
3738
osm_file_path = TEST_FILE
3839

3940
async def run_test():
4041
osm2osw = OSM2OSW(osm_file=osm_file_path, workdir=OUTPUT_DIR, prefix='test')
4142
result = await osm2osw.convert()
42-
self.assertEqual(len(result.generated_files), 4)
43+
self.assertEqual(len(result.generated_files), 6)
4344
for file in result.generated_files:
4445
os.remove(file)
4546

@@ -52,7 +53,7 @@ async def run_test():
5253
osm2osw = OSM2OSW(osm_file=osm_file_path, workdir=OUTPUT_DIR, prefix='test')
5354
result = await osm2osw.convert()
5455

55-
self.assertEqual(len(result.generated_files), 4)
56+
self.assertEqual(len(result.generated_files), 6)
5657

5758
for file in result.generated_files:
5859
if file.endswith('.geojson'):
@@ -246,6 +247,18 @@ async def run_test():
246247

247248
asyncio.run(run_test())
248249

250+
def test_will_not_generate_nodes_file_if_node_with_invalid_tags(self):
251+
osm_file_path = TEST_INVALID_NODE_TAGS_FILE
252+
253+
async def run_test():
254+
osm2osw = OSM2OSW(osm_file=osm_file_path, workdir=OUTPUT_DIR, prefix='test')
255+
result = await osm2osw.convert()
256+
self.assertEqual(len(result.generated_files), 0)
257+
for file in result.generated_files:
258+
os.remove(file)
259+
260+
asyncio.run(run_test())
261+
249262

250263
if __name__ == '__main__':
251264
unittest.main()

0 commit comments

Comments
 (0)