Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Change log

### 0.3.3
- Fix OSM→OSW export classification so canonical OSM tags are used for semantic recognition and `ext:*` tags are preserved as extensions instead of being treated as feature-defining tags.
- Fix closed ext-only ways such as `ext:demolished:building=yes` to emit polygon output without falling through to point geometry construction.
- Fix GeoJSON node/point export so topology endpoints remain in `nodes.geojson`, keeping edge `_u_id`/`_v_id` references OSW-compliant on roundtrip validation.
- Add regression coverage for bug 3477 and serializer/compliance cases around ext-only geometries and remapped node references.

### 0.3.2
- Fix duplicate polygon `_id` generation in OSM→OSW export by assigning sequential IDs per feature type.
- Remap edge `_u_id`/`_v_id` and zone `_w_id` references to exported node IDs so references stay consistent after ID normalization.
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ shapely~=2.0.2
pyproj~=3.6.1
coverage~=7.5.1
ogr2osm==1.2.0
python-osw-validation==0.3.1
python-osw-validation==0.3.5
8 changes: 7 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,13 @@
},
long_description_content_type='text/markdown',
url='https://github.com/TaskarCenterAtUW/TDEI-python-lib-osw-formatter',
install_requires=install_requires,
install_requires=[
'osmium~=3.6.0',
'asyncio~=3.4.3',
'networkx~=3.2',
'shapely~=2.0.2',
'ogr2osm==1.2.0'
],
packages=find_packages(where='src'),
classifiers=[
'Programming Language :: Python :: 3',
Expand Down
65 changes: 47 additions & 18 deletions src/osm_osw_reformatter/serializer/osm/osm_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,13 +150,20 @@ def way(self, w):
if self.progressbar:
self.progressbar.update(1)

if not self.line_filter(w.tags):
tags = dict(w.tags)
if not self.line_filter(tags):
return

d = {}
tags = dict(w.tags)
normalizer = OSWLineNormalizer(tags)

is_closed = len(w.nodes) > 2 and w.nodes[0].ref == w.nodes[-1].ref
if is_closed and normalizer.is_custom() and not (
normalizer.is_fence() or normalizer.is_tree_row()
):
return

d2 = {**d, **OSWLineNormalizer(tags).normalize()}
d2 = {**d, **normalizer.normalize()}

ndref = []
for i in range(len(w.nodes)):
Expand Down Expand Up @@ -264,13 +271,23 @@ def area(self, a):
if self.progressbar:
self.progressbar.update(1)

if not self.polygon_filter(a.tags):
tags = dict(a.tags)
if not self.polygon_filter(tags):
return

d = {}
tags = dict(a.tags)
normalizer = OSWPolygonNormalizer(tags)
line_normalizer = OSWLineNormalizer(tags)
zone_normalizer = OSWZoneNormalizer(tags)

if normalizer.is_custom() and (
line_normalizer.is_fence()
or line_normalizer.is_tree_row()
or zone_normalizer.is_pedestrian()
):
return

d2 = {**d, **OSWPolygonNormalizer(tags).normalize()}
d2 = {**d, **normalizer.normalize()}

exteriors_count = 0
for exterior in a.outer_rings():
Expand Down Expand Up @@ -534,7 +551,7 @@ def construct_geometries(self, progressbar: Optional[callable] = None) -> None:

if progressbar:
progressbar.update(1)
elif OSWPolygonNormalizer.osw_polygon_filter(d):
elif "ndref" in d and "indref" in d:
ndref = d.get("ndref")
indref = d.get("indref", [])
if not ndref:
Expand All @@ -547,7 +564,7 @@ def construct_geometries(self, progressbar: Optional[callable] = None) -> None:

if progressbar:
progressbar.update(1)
elif OSWLineNormalizer.osw_line_filter(d):
elif "ndref" in d:
ndref = d.get("ndref")
if not ndref:
continue
Expand Down Expand Up @@ -653,11 +670,28 @@ def _remap_node_ref(ref, node_id_map):
for n, d in self.G.nodes(data=True):
d_copy = {**d}
source_id = _source_id(n)
geometry_obj = d_copy.pop("geometry")
geometry = mapping(geometry_obj)
geometry_type = geometry_obj.geom_type
is_topology_node = geometry_type == "Point" and self.G.degree(n) > 0

if is_topology_node:
_assign_ids(d_copy, node_id_counter, source_id)
node_id_map[n] = d_copy["_id"]
node_id_counter += 1

if OSWPointNormalizer.osw_point_filter(d):
if 'lon' in d_copy:
d_copy.pop('lon')

if 'lat' in d_copy:
d_copy.pop('lat')

node_features.append(
{'type': 'Feature', 'geometry': geometry, 'properties': d_copy}
)
elif geometry_type == "Point" and OSWPointNormalizer.osw_point_filter(d):
_assign_ids(d_copy, point_id_counter, source_id)
point_id_counter += 1
geometry = mapping(d_copy.pop("geometry"))

if "lon" in d_copy:
d_copy.pop("lon")
Expand All @@ -668,26 +702,23 @@ def _remap_node_ref(ref, node_id_map):
point_features.append(
{"type": "Feature", "geometry": geometry, "properties": d_copy}
)
elif OSWLineNormalizer.osw_line_filter(d):
elif geometry_type == "LineString":
_assign_ids(d_copy, line_id_counter, source_id)
line_id_counter += 1
geometry = mapping(d_copy.pop("geometry"))

line_features.append(
{"type": "Feature", "geometry": geometry, "properties": d_copy}
)
elif OSWZoneNormalizer.osw_zone_filter(d):
elif geometry_type == "Polygon" and OSWZoneNormalizer.osw_zone_filter(d):
_assign_ids(d_copy, zone_id_counter, source_id)
zone_id_counter += 1
geometry = mapping(d_copy.pop("geometry"))

zone_features.append(
{"type": "Feature", "geometry": geometry, "properties": d_copy}
)
elif OSWPolygonNormalizer.osw_polygon_filter(d):
elif geometry_type == "Polygon":
_assign_ids(d_copy, polygon_id_counter, source_id)
polygon_id_counter += 1
geometry = mapping(d_copy.pop("geometry"))

polygon_features.append(
{"type": "Feature", "geometry": geometry, "properties": d_copy}
Expand All @@ -697,8 +728,6 @@ def _remap_node_ref(ref, node_id_map):
node_id_map[n] = d_copy["_id"]
node_id_counter += 1

geometry = mapping(d_copy.pop('geometry'))

if 'lon' in d_copy:
d_copy.pop('lon')

Expand Down
33 changes: 25 additions & 8 deletions src/osm_osw_reformatter/serializer/osw/osw_normalizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,7 @@


def _tag_value(tags, key):
if key in tags:
return tags.get(key, "")
return tags.get(f"ext:{key}", "")
return tags.get(key, "")


def _tags_to_dict(tags):
Expand All @@ -43,6 +41,25 @@ def _tags_to_dict(tags):
return {}


def _feature_tags(tags):
tag_dict = _tags_to_dict(tags)
internal_keys = {
"geometry",
"indref",
"lat",
"length",
"lon",
"ndref",
"osm_id",
"segment",
}
return {
k: v
for k, v in tag_dict.items()
if k not in internal_keys and not str(k).startswith("_")
}


def _has_only_ext_tags(tags):
if not tags:
return False
Expand Down Expand Up @@ -278,7 +295,7 @@ def _normalize_kerb(self, keep_keys = {}, defaults = {}):
def is_kerb(self):
kerb_value = _tag_value(self.tags, "kerb")
barrier_value = _tag_value(self.tags, "barrier")
has_kerb_key = "kerb" in self.tags or "ext:kerb" in self.tags
has_kerb_key = "kerb" in self.tags
return (kerb_value in self.KERB_VALUES) or (
barrier_value == "kerb" and (not has_kerb_key or kerb_value == "yes")
)
Expand Down Expand Up @@ -361,7 +378,7 @@ def is_tree(self):
return _tag_value(self.tags, "natural") == "tree"

def is_custom(self):
tag_dict = _tags_to_dict(self.tags)
tag_dict = _feature_tags(self.tags)
return _has_only_ext_tags(tag_dict)

class OSWLineNormalizer:
Expand Down Expand Up @@ -405,7 +422,7 @@ def is_tree_row(self):
return _tag_value(self.tags, "natural") == "tree_row"

def is_custom(self):
tag_dict = _tags_to_dict(self.tags)
tag_dict = _feature_tags(self.tags)
return _has_only_ext_tags(tag_dict)

class OSWPolygonNormalizer:
Expand Down Expand Up @@ -556,13 +573,13 @@ def _normalize_polygon(self, keep_keys={}, defaults = {}):
return new_tags

def is_building(self):
return _tag_value(self.tags, "building") in self.BUILDING_VALUES
return self.tags.get("building", "") in self.BUILDING_VALUES

def is_wood(self):
return _tag_value(self.tags, "natural") == "wood"

def is_custom(self):
tag_dict = _tags_to_dict(self.tags)
tag_dict = _feature_tags(self.tags)
return _has_only_ext_tags(tag_dict)

class OSWZoneNormalizer:
Expand Down
2 changes: 1 addition & 1 deletion src/osm_osw_reformatter/version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = '0.3.2'
__version__ = '0.3.3'
16 changes: 16 additions & 0 deletions tests/unit_tests/test_files/bug_3477.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<osm version="0.6" generator="test" upload="false">
<node id="1" visible="true" version="1" changeset="1" timestamp="2025-02-28T06:06:01Z" user="Cy Rossignol" uid="1" lat="47.6151489" lon="-122.3206583"/>
<node id="2" visible="true" version="1" changeset="1" timestamp="2025-02-28T06:06:01Z" user="Cy Rossignol" uid="1" lat="47.6149868" lon="-122.3206509"/>
<node id="3" visible="true" version="1" changeset="1" timestamp="2025-02-28T06:06:01Z" user="Cy Rossignol" uid="1" lat="47.6149898" lon="-122.3203263"/>
<node id="4" visible="true" version="1" changeset="1" timestamp="2025-02-28T06:06:01Z" user="Cy Rossignol" uid="1" lat="47.6151507" lon="-122.3203266"/>

<way id="301846" visible="true" version="2" changeset="816" timestamp="2026-01-07T22:03:22Z" user="Amy Bordenave" uid="8">
<nd ref="1"/>
<nd ref="4"/>
<nd ref="3"/>
<nd ref="2"/>
<nd ref="1"/>
<tag k="ext:demolished:building" v="yes"/>
</way>
</osm>
28 changes: 28 additions & 0 deletions tests/unit_tests/test_osm2osw/test_osm2osw.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
TEST_INCLINE_FILE = os.path.join(ROOT_DIR, 'test_files/incline-test.xml')
TEST_INVALID_NODE_TAGS_FILE = os.path.join(ROOT_DIR, 'test_files/node_with_invalid_tags.xml')
TEST_TREE_FILE = os.path.join(ROOT_DIR, 'test_files/tree-test.xml')
TEST_BUG_3477_FILE = os.path.join(ROOT_DIR, 'test_files/bug_3477.xml')


def is_valid_float(value):
Expand Down Expand Up @@ -322,6 +323,33 @@ async def run_test():

asyncio.run(run_test())

def test_bug_3477_ext_only_closed_way_emits_polygon(self):
osm_file_path = TEST_BUG_3477_FILE

async def run_test():
osm2osw = OSM2OSW(osm_file=osm_file_path, workdir=OUTPUT_DIR, prefix='bug3477')
result = await osm2osw.convert()
self.assertTrue(result.status)
self.assertEqual(len(result.generated_files), 1)

polygon_file = result.generated_files[0]
self.assertTrue(polygon_file.endswith('.graph.polygons.geojson'))

with open(polygon_file) as f:
geojson = json.load(f)

self.assertEqual(geojson.get("$schema"), OSW_SCHEMA_ID)
self.assertEqual(len(geojson.get("features", [])), 1)

feature = geojson["features"][0]
self.assertEqual(feature["geometry"]["type"], "Polygon")
self.assertEqual(feature["properties"].get("ext:demolished:building"), "yes")

for file_path in result.generated_files:
os.remove(file_path)

asyncio.run(run_test())


if __name__ == '__main__':
unittest.main()
Loading
Loading