Skip to content

Commit 73ab310

Browse files
committed
Add tin_json_to_tin_gpkg.py
1 parent eed8a07 commit 73ab310

2 files changed

Lines changed: 289 additions & 0 deletions

File tree

grid_tools/README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,3 +190,18 @@ such as [gdal_translate](https://gdal.org/programs/gdal_translate.html) or
190190
[gdal_edit.py](https://gdal.org/programs/gdal_edit.html)
191191

192192
Note: this script will not compress data. This must be done in a prior step.
193+
194+
## Converting a TIN JSON file to a TIN GeoPackage file
195+
196+
With the [tin_json_to_tin_gpkg.py](tin_json_to_tin_gpkg.py) script.
197+
198+
Both formats may be used by the +proj=tinshift operation.
199+
200+
Usage:
201+
```
202+
usage: tin_json_to_tin_gpkg.py [-h] source dest
203+
```
204+
205+
See https://proj.org/en/latest/specifications/tin_json.html and
206+
https://proj.org/en/latest/specifications/tin_gpkg.html for the specification
207+
of both format.

grid_tools/tin_json_to_tin_gpkg.py

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
###############################################################################
4+
#
5+
# Project: PROJ
6+
# Purpose: Convert a TIN JSON to a TIN GeoPackage
7+
# Author: Even Rouault <even.rouault at spatialys.com>
8+
#
9+
###############################################################################
10+
# Copyright (c) 2025, Even Rouault <even.rouault at spatialys.com>
11+
#
12+
# SPDX-License-Identifier: MIT
13+
###############################################################################
14+
15+
import argparse
16+
import json
17+
import struct
18+
19+
from osgeo import gdal, ogr, osr
20+
21+
22+
def get_args():
23+
parser = argparse.ArgumentParser(
24+
description="Convert a TIN JSON to a TIN GeoPackage."
25+
)
26+
parser.add_argument("source", help="Source JSON file")
27+
parser.add_argument("dest", help="Destination GeoPackage file")
28+
return parser.parse_args()
29+
30+
31+
def as_i32le_hex(v):
32+
return "".join(["%02X" % b for b in struct.pack("<i", v)])
33+
34+
35+
def convert_json_to_gpkg(source, dest):
36+
37+
j = json.loads(open(source, "rb").read())
38+
39+
if "file_type" not in j:
40+
raise Exception(f"'file_type' missing in {source}")
41+
file_type = j["file_type"]
42+
if file_type != "triangulation_file":
43+
raise Exception(f"file_type={file_type} not handled")
44+
45+
if "vertices" not in j or not isinstance(j["vertices"], list):
46+
raise Exception(f"'vertices' array missing in {source}")
47+
vertices = j["vertices"]
48+
49+
if "vertices_columns" not in j or not isinstance(j["vertices_columns"], list):
50+
raise Exception(f"'vertices_columns' array missing in {source}")
51+
vertices_columns = j["vertices_columns"]
52+
idx_source_x = vertices_columns.index("source_x")
53+
idx_source_y = vertices_columns.index("source_y")
54+
55+
if "triangles" not in j or not isinstance(j["triangles"], list):
56+
raise Exception(f"'triangles' array missing in {source}")
57+
triangles = j["triangles"]
58+
59+
if "triangles_columns" not in j or not isinstance(j["triangles_columns"], list):
60+
raise Exception(f"'triangles_columns' array missing in {source}")
61+
triangles_columns = j["triangles_columns"]
62+
idx_vertex1 = triangles_columns.index("idx_vertex1")
63+
idx_vertex2 = triangles_columns.index("idx_vertex2")
64+
idx_vertex3 = triangles_columns.index("idx_vertex3")
65+
66+
with gdal.config_options(
67+
{
68+
"OGR_SQLITE_PRAGMA": "page_size=1024",
69+
"CREATE_TRIGGERS": "NO",
70+
"CREATE_RASTER_TABLES": "NO",
71+
}
72+
):
73+
ds = gdal.GetDriverByName("GPKG").CreateVector(dest)
74+
ds.StartTransaction()
75+
76+
srs = None
77+
if "input_crs" in j:
78+
srs = osr.SpatialReference()
79+
srs.SetAxisMappingStrategy(osr.OAMS_TRADITIONAL_GIS_ORDER)
80+
srs.SetFromUserInput(j["input_crs"])
81+
82+
metadata = {}
83+
for key, value in j.items():
84+
if key not in (
85+
"vertices_columns",
86+
"triangles_columns",
87+
"vertices",
88+
"triangles",
89+
):
90+
metadata[key] = value
91+
92+
if "target_x" in vertices_columns and "target_y" in vertices_columns:
93+
idx_target_x = vertices_columns.index("target_x")
94+
idx_target_y = vertices_columns.index("target_y")
95+
96+
shift_x = [vertex[idx_target_x] - vertex[idx_source_x] for vertex in vertices]
97+
shift_y = [vertex[idx_target_y] - vertex[idx_source_y] for vertex in vertices]
98+
metadata["min_shift_x"] = min(shift_x)
99+
metadata["max_shift_x"] = max(shift_x)
100+
metadata["min_shift_y"] = min(shift_y)
101+
metadata["max_shift_y"] = max(shift_y)
102+
103+
metadata["num_vertices"] = len(vertices)
104+
105+
ds.ExecuteSQL(
106+
"""CREATE TABLE gpkg_metadata (
107+
id INTEGER PRIMARY KEY AUTOINCREMENT,
108+
md_scope TEXT NOT NULL DEFAULT 'dataset',
109+
md_standard_uri TEXT NOT NULL,
110+
mime_type TEXT NOT NULL DEFAULT 'text/xml',
111+
metadata TEXT NOT NULL DEFAULT ''
112+
)"""
113+
)
114+
ds.ExecuteSQL(
115+
"""CREATE TABLE gpkg_metadata_reference (
116+
reference_scope TEXT NOT NULL,
117+
table_name TEXT,
118+
column_name TEXT,
119+
row_id_value INTEGER,
120+
timestamp DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
121+
md_file_id INTEGER NOT NULL,
122+
md_parent_id INTEGER,
123+
CONSTRAINT crmr_mfi_fk FOREIGN KEY (md_file_id) REFERENCES gpkg_metadata(id),
124+
CONSTRAINT crmr_mpi_fk FOREIGN KEY (md_parent_id) REFERENCES gpkg_metadata(id)
125+
)"""
126+
)
127+
128+
serialized_json = json.dumps(metadata).replace("'", "''")
129+
ds.ExecuteSQL(
130+
f"INSERT INTO gpkg_metadata (id, md_scope, md_standard_uri, mime_type, metadata) VALUES (1, 'dataset', 'https://proj.org', 'application/json', '{serialized_json}')"
131+
)
132+
ds.ExecuteSQL(
133+
"INSERT INTO gpkg_metadata_reference (reference_scope, table_name, column_name, row_id_value, md_file_id, md_parent_id) VALUES ('geopackage', NULL, NULL, NULL, 1, NULL)"
134+
)
135+
136+
ds.ExecuteSQL("CREATE TABLE gpkg_extensions (table_name TEXT,column_name TEXT,extension_name TEXT NOT NULL,definition TEXT NOT NULL,scope TEXT NOT NULL,CONSTRAINT ge_tce UNIQUE (table_name, column_name, extension_name))")
137+
ds.ExecuteSQL("INSERT INTO gpkg_extensions VALUES('gpkg_metadata', NULL, 'gpkg_metadata', 'http://www.geopackage.org/spec120/#extension_metadata', 'read-write')")
138+
ds.ExecuteSQL("INSERT INTO gpkg_extensions VALUES('gpkg_metadata_reference', NULL, 'gpkg_metadata', 'http://www.geopackage.org/spec120/#extension_metadata', 'read-write')")
139+
140+
lyr = ds.CreateLayer(
141+
"vertices",
142+
geom_type=ogr.wkbPoint,
143+
srs=srs,
144+
options=["SPATIAL_INDEX=NO", "GEOMETRY_NULLABLE=NO"],
145+
)
146+
147+
ogr_to_vertices_column_idx = []
148+
for idx, col in enumerate(vertices_columns):
149+
if idx in (idx_source_x, idx_source_y):
150+
continue
151+
is_reserved_col = col in (
152+
"target_x",
153+
"target_y",
154+
"source_z",
155+
"target_z",
156+
"offset_z",
157+
)
158+
if isinstance(vertices[0][idx], float) or is_reserved_col:
159+
ogr_type = ogr.OFTReal
160+
elif isinstance(vertices[0][idx], int):
161+
ogr_type = ogr.OFTInteger
162+
else:
163+
ogr_type = ogr.OFTString
164+
fld_defn = ogr.FieldDefn(col, ogr_type)
165+
if is_reserved_col:
166+
fld_defn.SetNullable(False)
167+
lyr.CreateField(fld_defn)
168+
ogr_to_vertices_column_idx.append(idx)
169+
for fid, v in enumerate(vertices):
170+
f = ogr.Feature(lyr.GetLayerDefn())
171+
for ogr_idx, idx in enumerate(ogr_to_vertices_column_idx):
172+
f.SetField(ogr_idx, v[idx])
173+
p = ogr.Geometry(ogr.wkbPoint)
174+
p.SetPoint_2D(0, v[idx_source_x], v[idx_source_y])
175+
f.SetGeometryDirectly(p)
176+
f.SetFID(fid)
177+
lyr.CreateFeature(f)
178+
179+
lyr = ds.CreateLayer("triangles_def", geom_type=ogr.wkbNone)
180+
other_fields = ""
181+
for idx, col in enumerate(triangles_columns):
182+
if isinstance(triangles[0][idx], float):
183+
ogr_type = ogr.OFTReal
184+
elif isinstance(triangles[0][idx], int):
185+
ogr_type = ogr.OFTInteger
186+
else:
187+
ogr_type = ogr.OFTString
188+
fld_defn = ogr.FieldDefn(col, ogr_type)
189+
if col in ("idx_vertex1", "idx_vertex2", "idx_vertex3"):
190+
fld_defn.SetNullable(False)
191+
lyr.CreateField(fld_defn)
192+
other_fields += ", "
193+
other_fields += col
194+
for fid, triangle in enumerate(triangles):
195+
f = ogr.Feature(lyr.GetLayerDefn())
196+
for idx in range(len(triangles_columns)):
197+
f.SetField(idx, triangle[idx])
198+
f.SetFID(fid)
199+
lyr.CreateFeature(f)
200+
201+
with ds.ExecuteSQL(
202+
"SELECT srs_id FROM gpkg_contents WHERE table_name = 'vertices'"
203+
) as sql_lyr:
204+
f = sql_lyr.GetNextFeature()
205+
srs_id = f["srs_id"]
206+
207+
min_x = min([v[idx_source_x] for v in vertices])
208+
max_x = max([v[idx_source_x] for v in vertices])
209+
min_y = min([v[idx_source_y] for v in vertices])
210+
max_y = max([v[idx_source_y] for v in vertices])
211+
212+
# We use a trick to generate GPKG polygon geometries from GPKG point geometries
213+
# of the vertices.
214+
srs_id_i32le = as_i32le_hex(srs_id)
215+
wkb_polygon_i32le = as_i32le_hex(3)
216+
number_rings_i32le = as_i32le_hex(1)
217+
number_vertices_i32le = as_i32le_hex(4)
218+
triangle_gpkg_prefix = f"47500001{srs_id_i32le}01{wkb_polygon_i32le}{number_rings_i32le}{number_vertices_i32le}"
219+
# 14 = GPKG_header_size_without_envelope (8) + WKB point header (5) + base_one_index (1)
220+
ds.ExecuteSQL(
221+
f"CREATE VIEW triangles AS SELECT triangles_def.fid AS OGC_FID{other_fields}, CAST(X'{triangle_gpkg_prefix}' || substr(v1.geom, 14) || substr(v2.geom, 14) || substr(v3.geom, 14) || substr(v1.geom, 14) AS BLOB) AS geom FROM triangles_def LEFT JOIN vertices v1 ON idx_vertex1 = v1.fid LEFT JOIN vertices v2 ON idx_vertex2 = v2.fid LEFT JOIN vertices v3 ON idx_vertex3 = v3.fid"
222+
)
223+
ds.ExecuteSQL(
224+
f"INSERT INTO gpkg_contents (table_name, identifier, data_type, srs_id, min_x, min_y, max_x, max_y) VALUES ('triangles', 'triangles', 'features', {srs_id}, {min_x}, {min_y}, {max_x}, {max_y})"
225+
)
226+
ds.ExecuteSQL(
227+
f"INSERT INTO gpkg_geometry_columns (table_name, column_name, geometry_type_name, srs_id, z, m) values ('triangles', 'geom', 'POLYGON', {srs_id}, 0, 0)"
228+
)
229+
230+
ds.ExecuteSQL(
231+
"CREATE VIRTUAL TABLE rtree_triangles_geom USING rtree(id, minx, maxx, miny, maxy)"
232+
)
233+
for fid, triangle in enumerate(triangles):
234+
v1 = vertices[triangle[idx_vertex1]]
235+
v2 = vertices[triangle[idx_vertex2]]
236+
v3 = vertices[triangle[idx_vertex3]]
237+
tab_x = [v1[idx_source_x], v2[idx_source_x], v3[idx_source_x]]
238+
tab_y = [v1[idx_source_y], v2[idx_source_y], v3[idx_source_y]]
239+
minx = min(tab_x)
240+
miny = min(tab_y)
241+
maxx = max(tab_x)
242+
maxy = max(tab_y)
243+
ds.ExecuteSQL(
244+
f"INSERT INTO rtree_triangles_geom VALUES ({fid}, {minx}, {maxx}, {miny}, {maxy})"
245+
)
246+
247+
ds.CommitTransaction()
248+
ds.ExecuteSQL("DELETE FROM sqlite_sequence")
249+
ds.ExecuteSQL("DROP TRIGGER trigger_insert_feature_count_vertices")
250+
ds.ExecuteSQL("DROP TRIGGER trigger_delete_feature_count_vertices")
251+
ds.ExecuteSQL("DROP TRIGGER trigger_insert_feature_count_triangles_def")
252+
ds.ExecuteSQL("DROP TRIGGER trigger_delete_feature_count_triangles_def")
253+
ds.ExecuteSQL("DROP TABLE gpkg_ogr_contents")
254+
ds.ExecuteSQL("VACUUM")
255+
ds.Close()
256+
257+
# Check that the triangle coverage is OK (no overlaps)
258+
if gdal.VersionInfo(None) >= "3120000":
259+
with gdal.alg.vector.check_coverage(
260+
input=dest, output="", output_format="MEM", input_layer="triangles"
261+
) as alg:
262+
out_ds = alg.Output()
263+
out_lyr = out_ds.GetLayer(0)
264+
if out_lyr.GetFeatureCount():
265+
print("Coverage of triangles has issues. Invalid edges:")
266+
for f in out_lyr:
267+
f.DumpReadable()
268+
269+
270+
if __name__ == "__main__":
271+
272+
gdal.UseExceptions()
273+
args = get_args()
274+
convert_json_to_gpkg(args.source, args.dest)

0 commit comments

Comments
 (0)