isolines 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [0.1.0] - 2026-11-09
8
+ ### Added
9
+ - `contour_lines`: marching squares with linear interpolation along cell edges and
10
+ centre-average saddle disambiguation; segments joined into continuous, consistently
11
+ oriented LineStrings (higher values on the left) with closed-ring detection; level and
12
+ label attributes; minimum-length filter.
13
+ - `isobands`: filled contours as MultiPolygons with holes, traced from oriented contour
14
+ segments and the boundary of the valid grid domain, with correct nesting (islands in
15
+ holes) and RFC 7946 orientation; open-ended bands with `-inf`/`inf`; bands tile the valid
16
+ domain without gaps or overlaps.
17
+ - No-data handling (NaN, masked arrays, `nodata` value); domain pinches split into valid
18
+ rings.
19
+ - Georeferencing by GDAL-style `GeoTransform` (or rasterio `Affine`, or 1-D x/y
20
+ coordinates), pixel-is-area or pixel-is-point.
21
+ - Chaikin smoothing and Douglas-Peucker simplification; `validate_polygon` checker.
22
+ - Scattered points to grid: IDW (power, radius, k nearest), nearest neighbour, and linear
23
+ TIN interpolation via the optional `scipy` extra; `GridSpec`.
24
+ - Level helpers: `interval_levels`, `nice_interval`, `nice_levels`.
25
+ - I/O: Esri ASCII grid read/write, `.npy`, any raster through the optional `raster`
26
+ extra (rasterio), point CSV reader, GeoJSON writer with coordinate rounding.
27
+ - CLI: `isolines lines`, `isolines bands`, `isolines grid`.
@@ -0,0 +1,21 @@
1
+ cff-version: 1.2.0
2
+ message: "If you use this software, please cite it as below."
3
+ authors:
4
+ - family-names: "Eminoğlu"
5
+ given-names: "Yusuf"
6
+ email: "geospacephilo@gmail.com"
7
+ orcid: "https://orcid.org/0009-0005-6000-2934"
8
+ title: "isolines"
9
+ version: "0.1.0"
10
+ date-released: "2026-11-09"
11
+ license: "MIT"
12
+ repository-code: "https://gitlab.com/geophilo1/isolines_sdk"
13
+ url: "https://pypi.org/project/isolines/"
14
+ abstract: "Contour lines and filled contours (isobands) from gridded data and scattered points: marching squares with saddle disambiguation and linear interpolation, joined LineStrings with closed-ring detection, valid RFC 7946 polygons with holes, Chaikin smoothing, Douglas-Peucker simplification, IDW/nearest/TIN gridding, affine georeferencing and GeoJSON output, with numpy as the only dependency."
15
+ keywords:
16
+ - contours
17
+ - isolines
18
+ - isobands
19
+ - marching squares
20
+ - interpolation
21
+ - gis
isolines-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yusuf Eminoğlu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,9 @@
1
+ include LICENSE
2
+ include README.md
3
+ include CHANGELOG.md
4
+ include CITATION.cff
5
+ include pyproject.toml
6
+ include src/isolines/py.typed
7
+ recursive-include src/isolines *.py
8
+ recursive-include tests *.py
9
+ recursive-include examples *.py
@@ -0,0 +1,269 @@
1
+ Metadata-Version: 2.4
2
+ Name: isolines
3
+ Version: 0.1.0
4
+ Summary: Contour lines and filled contours (isobands) from grids and scattered points: marching squares with saddle disambiguation, valid polygons with holes, smoothing, simplification, IDW/TIN gridding and GeoJSON output, numpy only.
5
+ Author-email: Yusuf Eminoğlu <geospacephilo@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://gitlab.com/geophilo1/isolines_sdk
8
+ Project-URL: Repository, https://gitlab.com/geophilo1/isolines_sdk
9
+ Project-URL: Issues, https://gitlab.com/geophilo1/isolines_sdk/-/work_items
10
+ Project-URL: Changelog, https://gitlab.com/geophilo1/isolines_sdk/-/blob/main/CHANGELOG.md
11
+ Keywords: contour,contours,isolines,isobands,marching-squares,filled-contours,dem,isochrone,noise-map,idw,interpolation,geojson,gis,raster
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Operating System :: OS Independent
20
+ Classifier: Environment :: Console
21
+ Classifier: Topic :: Scientific/Engineering :: GIS
22
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
23
+ Classifier: Intended Audience :: Science/Research
24
+ Classifier: Intended Audience :: Developers
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.9
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE
29
+ Requires-Dist: numpy>=1.21
30
+ Provides-Extra: scipy
31
+ Requires-Dist: scipy>=1.7; extra == "scipy"
32
+ Provides-Extra: raster
33
+ Requires-Dist: rasterio>=1.3; extra == "raster"
34
+ Provides-Extra: dev
35
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
36
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
37
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
38
+ Requires-Dist: mypy>=1.0.0; extra == "dev"
39
+ Requires-Dist: shapely>=2.0; extra == "dev"
40
+ Dynamic: license-file
41
+
42
+ # isolines
43
+
44
+ [![PyPI version](https://img.shields.io/pypi/v/isolines.svg?color=3b82f6)](https://pypi.org/project/isolines/)
45
+ [![Python version support](https://img.shields.io/pypi/pyversions/isolines.svg?color=10b981)](https://pypi.org/project/isolines/)
46
+ [![pipeline](https://gitlab.com/geophilo1/isolines_sdk/badges/main/pipeline.svg)](https://gitlab.com/geophilo1/isolines_sdk/-/pipelines)
47
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://gitlab.com/geophilo1/isolines_sdk/-/blob/main/LICENSE)
48
+
49
+ **Contour lines and filled contours (isobands) from grids and scattered points, numpy only.**
50
+
51
+ `isolines` turns a 2-D grid (a DEM, a travel-time surface, a noise
52
+ raster) or a set of scattered measurements into:
53
+
54
+ - **contour lines**: continuous `LineString`s, one per connected piece, with
55
+ closed-ring detection, `level` and `label` attributes, and a consistent
56
+ direction (higher values on the left);
57
+ - **isobands**: `MultiPolygon`s with holes for each value interval, with
58
+ correct nesting (islands inside holes inside polygons), RFC 7946 ring
59
+ orientation, and bands that tile the valid grid without gaps or overlaps.
60
+
61
+ It handles no-data (NaN, masked arrays, a `nodata` value), georeferences
62
+ through an affine geotransform, smooths (Chaikin) and simplifies
63
+ (Douglas-Peucker), interpolates scattered points to a grid (IDW, nearest,
64
+ linear TIN), and writes GeoJSON. Its only dependency is numpy.
65
+
66
+ ```bash
67
+ pip install isolines # numpy only
68
+ pip install "isolines[scipy]" # + linear (Delaunay TIN) interpolation
69
+ pip install "isolines[raster]" # + GeoTIFF and other rasters through rasterio
70
+ ```
71
+
72
+ Requires Python 3.9 or newer.
73
+
74
+ Typical uses: DEM contours, travel-time isochrones from sampled network
75
+ times, noise maps from receiver levels, any "contour this surface" job
76
+ where you don't want a GDAL install.
77
+
78
+ ---
79
+
80
+ ## Quick start (Python)
81
+
82
+ ```python
83
+ import math
84
+ import numpy as np
85
+ import isolines
86
+
87
+ # A gridded surface with georeferencing (GDAL-style, north-up, 25 m cells)
88
+ yy, xx = np.mgrid[0:200, 0:300]
89
+ dem = 50 + 40 * np.exp(-((xx - 150) ** 2 + (yy - 100) ** 2) / 2000)
90
+ gt = isolines.GeoTransform.north_up(500000.0, 4200000.0, 25.0)
91
+
92
+ levels = isolines.interval_levels(dem.min(), dem.max(), 5) # 55, 60, ..., 90
93
+ lines = isolines.contour_lines(dem, levels, transform=gt, smooth=2, label_format="{:g} m")
94
+ bands = isolines.isobands(dem, [-math.inf, 60, 70, 80, math.inf], transform=gt)
95
+
96
+ for band in bands:
97
+ print(band.label, round(band.area / 1e4, 1), "ha")
98
+
99
+ isolines.write_geojson("contours.geojson", lines, precision=2)
100
+ isolines.write_geojson("bands.geojson", bands, precision=2)
101
+ ```
102
+
103
+ From scattered points (for example travel times at network nodes):
104
+
105
+ ```python
106
+ z, gt = isolines.points_to_grid(x, y, minutes, cell_size=50.0, method="idw", k=8)
107
+ isochrones = isolines.isobands(z, [0, 5, 10, 15], transform=gt)
108
+ ```
109
+
110
+ A rasterio dataset can be used directly:
111
+
112
+ ```python
113
+ r = isolines.read_raster("dem.tif") # needs the "raster" extra; NaN for no-data
114
+ lines = isolines.contour_lines(r.z, isolines.nice_levels(r.z), transform=r.transform)
115
+ ```
116
+
117
+ See [`examples/quickstart.py`](examples/quickstart.py) for a DEM, a noise
118
+ map and an isochrone example.
119
+
120
+ ## Quick start (command line)
121
+
122
+ ```bash
123
+ # contour lines every 10 m from a GeoTIFF (or an Esri ASCII .asc grid)
124
+ isolines lines dem.tif --interval 10 --smooth 2 -o contours.geojson
125
+
126
+ # filled bands with explicit breaks, open-ended below and above
127
+ isolines bands noise.asc --breaks 55,60,65,70 --open-ends -o noise.geojson
128
+
129
+ # bands straight from a point CSV (interpolated to a 50 m grid first)
130
+ isolines bands times.csv --value-col minutes --cell-size 50 --method idw \
131
+ --breaks 0,5,10,15 -o isochrones.geojson
132
+
133
+ # just the interpolation, written as an Esri ASCII grid
134
+ isolines grid times.csv --value-col minutes --cell-size 50 -o times.asc
135
+ ```
136
+
137
+ Without `--levels`/`--breaks`/`--interval`, about ten round-numbered levels
138
+ are chosen. `--simplify`, `--precision`, `--label-format`, `--min-length`
139
+ and `--min-area` are available; `isolines <command> --help` lists all
140
+ options.
141
+
142
+ ## API overview
143
+
144
+ | Function / class | Purpose |
145
+ |---|---|
146
+ | `contour_lines(z, levels, *, x, y, transform, nodata, pixel_is_area, smooth, simplify, min_length, label_format)` | List of `ContourLine(level, coords, closed, label)` |
147
+ | `isobands(z, breaks, *, ..., min_area, keep_empty)` | List of `IsoBand(lower, upper, polygons, label)`, one per interval `[lower, upper)` |
148
+ | `GeoTransform` / `as_geotransform` | GDAL 6-coefficient transform; accepts GDAL tuples and rasterio `Affine` |
149
+ | `idw`, `nearest`, `linear`, `points_to_grid`, `GridSpec` | Scattered points to grid |
150
+ | `interval_levels`, `nice_interval`, `nice_levels` | Level selection |
151
+ | `chaikin`, `douglas_peucker`, `signed_area`, `line_length`, `point_in_ring` | Geometry helpers |
152
+ | `assemble_polygons`, `orient_polygon`, `validate_polygon` | Polygon building and validity checking |
153
+ | `read_ascii_grid`, `write_ascii_grid`, `read_raster`, `load_grid`, `read_points_csv` | Input |
154
+ | `to_geojson`, `write_geojson`, `.to_feature()` | GeoJSON output |
155
+
156
+ Coordinates: with `transform`, grid value `z[row, col]` is placed at the
157
+ pixel centre (`pixel_is_area=True`, the GDAL/rasterio convention) or the
158
+ pixel corner (`False`). With `x`/`y` (1-D, strictly monotonic), at
159
+ `(x[col], y[row])`. With neither, at `(col, row)`.
160
+
161
+ GeoJSON properties: lines carry `level`, `label`, `closed`, `length`;
162
+ bands carry `lower`, `upper` (`null` for infinite), `label`, `area`.
163
+
164
+ ## Method and sources
165
+
166
+ - **Marching squares.** Each grid cell is classified by which corners are
167
+ `>= level` (16 cases); crossing points are placed on cell edges by linear
168
+ interpolation between the two edge nodes. This is the 2-D case of
169
+ Lorensen, W. E. & Cline, H. E. (1987), *Marching cubes: a high resolution
170
+ 3D surface construction algorithm*, SIGGRAPH Computer Graphics 21(4),
171
+ 163-169; see also Maple, C. (2003), *Geometric design and space planning
172
+ using the marching squares and marching cube algorithms*, Proc.
173
+ GMAG 2003, 90-95.
174
+ - **Saddle disambiguation.** In the two ambiguous cases (diagonal corners
175
+ above), the mean of the four corner values stands in for the value at the
176
+ cell centre: when it is `>= level` the high corners are joined through the
177
+ cell, otherwise the low corners are. (The asymptotic decider of Nielson,
178
+ G. M. & Hamann, B. (1991), *The asymptotic decider*, Proc. IEEE
179
+ Visualization '91, 83-91, is the bilinear-exact alternative; it is not
180
+ used here.)
181
+ - **Joining and orientation.** Every segment is directed so that the
182
+ `>= level` side lies on its left. Each edge crossing then starts exactly
183
+ one segment and ends exactly one, so joining is unambiguous and closed
184
+ rings are detected exactly by shared crossing identities, not by
185
+ coordinate tolerance.
186
+ - **Isobands.** The boundary of the band `lower <= z < upper` is made of
187
+ the oriented contour segments of `lower`, the reversed contour segments of
188
+ `upper`, and the parts of the valid-domain boundary (grid edge and no-data
189
+ cells) whose values fall in the band. These are traced into closed rings;
190
+ counter-clockwise rings are exteriors, clockwise rings are holes and go to
191
+ the smallest exterior containing them. Rings that touch themselves where
192
+ the no-data domain pinches are split, so polygons are valid under the OGC
193
+ Simple Features rules. Output rings follow **RFC 7946, section 3.1.6**
194
+ (exterior counter-clockwise, holes clockwise; first and last positions
195
+ equal).
196
+ - **Exact level values.** A node equal to a level counts as above it.
197
+ Crossing parameters are clamped to `[1e-7, 1 - 1e-7]` of the edge, so a
198
+ contour through a node never produces coincident points on two edges;
199
+ the microscopic rings this would leave around an isolated node equal to
200
+ the level (for example a peak equal to the top level) are dropped.
201
+ - **Smoothing:** Chaikin, G. M. (1974), *An algorithm for high-speed curve
202
+ generation*, Computer Graphics and Image Processing 3(4), 346-349.
203
+ - **Simplification:** Douglas, D. H. & Peucker, T. K. (1973), *Algorithms
204
+ for the reduction of the number of points required to represent a
205
+ digitized line or its caricature*, Cartographica 10(2), 112-122.
206
+ - **IDW:** Shepard, D. (1968), *A two-dimensional interpolation function
207
+ for irregularly-spaced data*, Proc. 23rd ACM National Conference,
208
+ 517-524. Weights `d ** -power`; a query on a sample takes its value.
209
+ - **Linear TIN:** Delaunay triangulation and barycentric interpolation via
210
+ `scipy.interpolate.LinearNDInterpolator`; NaN outside the convex hull.
211
+ - **Geotransform:** the GDAL six-coefficient affine model (GDAL Raster Data
212
+ Model documentation). **Esri ASCII grid:** header keys and row order as in
213
+ the Esri "Esri ASCII raster format" documentation.
214
+
215
+ The test suite checks analytic surfaces with known answers: a plane
216
+ (contour position and band area exact), a cone (contours within the
217
+ `1/(8r)` interpolation error of a circle, annulus area), the saddle `z = x*y`
218
+ (contour on the axes, saddle-cell areas computed by hand), nested
219
+ peak/pit/plateau surfaces (island in a hole), no-data holes and pinches,
220
+ plus 40 random integer grids whose values sit exactly on the breaks. Every
221
+ band polygon is validated, and the bands are checked to tile the valid
222
+ domain; shapely (`is_valid`, areas) is used as an independent check when
223
+ installed.
224
+
225
+ ## Limitations
226
+
227
+ - Linear interpolation along edges means contours of curved surfaces are
228
+ chords; refine the grid for more accuracy.
229
+ - The saddle rule uses the cell mean, not the bilinear asymptotic decider;
230
+ in rare saddle cells the topology can differ from bilinear interpolation.
231
+ - `smooth` and `simplify` work ring by ring and are not topology-preserving:
232
+ neighbouring bands may slightly overlap or leave slivers, and a smoothed
233
+ line can cross a neighbouring level. Leave them off when exact tiling
234
+ matters.
235
+ - Rounding coordinates (`precision`) can merge points that are only `1e-7`
236
+ cells apart at exact-level nodes; keep enough decimals for your units.
237
+ - `idw` and `nearest` are brute force (chunked numpy), fine for tens of
238
+ thousands of samples; IDW is an exact interpolator but not a statistical
239
+ one (no kriging, no barriers). A noise or travel-time surface interpolated
240
+ from points is only as good as the sample coverage.
241
+ - GeoJSON output carries no CRS (RFC 7946 assumes WGS 84); projected
242
+ coordinates are written as given.
243
+ - Lines and polygons are built in memory; very large grids (10^8 cells)
244
+ need tiling.
245
+
246
+ ## Development
247
+
248
+ ```bash
249
+ python -m pip install -e ".[dev,scipy,raster]"
250
+ python -m pytest tests
251
+ python -m ruff check . && python -m ruff format --check src tests
252
+ python -m mypy src
253
+ ```
254
+
255
+ ## Academic citation
256
+
257
+ ```bibtex
258
+ @software{eminoglu_isolines,
259
+ author = {Eminoğlu, Yusuf},
260
+ title = {isolines},
261
+ year = {2026},
262
+ version = {0.1.0},
263
+ url = {https://gitlab.com/geophilo1/isolines_sdk}
264
+ }
265
+ ```
266
+
267
+ ## License
268
+
269
+ MIT License. Copyright (c) 2026 Yusuf Eminoğlu. See [LICENSE](LICENSE).
@@ -0,0 +1,228 @@
1
+ # isolines
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/isolines.svg?color=3b82f6)](https://pypi.org/project/isolines/)
4
+ [![Python version support](https://img.shields.io/pypi/pyversions/isolines.svg?color=10b981)](https://pypi.org/project/isolines/)
5
+ [![pipeline](https://gitlab.com/geophilo1/isolines_sdk/badges/main/pipeline.svg)](https://gitlab.com/geophilo1/isolines_sdk/-/pipelines)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://gitlab.com/geophilo1/isolines_sdk/-/blob/main/LICENSE)
7
+
8
+ **Contour lines and filled contours (isobands) from grids and scattered points, numpy only.**
9
+
10
+ `isolines` turns a 2-D grid (a DEM, a travel-time surface, a noise
11
+ raster) or a set of scattered measurements into:
12
+
13
+ - **contour lines**: continuous `LineString`s, one per connected piece, with
14
+ closed-ring detection, `level` and `label` attributes, and a consistent
15
+ direction (higher values on the left);
16
+ - **isobands**: `MultiPolygon`s with holes for each value interval, with
17
+ correct nesting (islands inside holes inside polygons), RFC 7946 ring
18
+ orientation, and bands that tile the valid grid without gaps or overlaps.
19
+
20
+ It handles no-data (NaN, masked arrays, a `nodata` value), georeferences
21
+ through an affine geotransform, smooths (Chaikin) and simplifies
22
+ (Douglas-Peucker), interpolates scattered points to a grid (IDW, nearest,
23
+ linear TIN), and writes GeoJSON. Its only dependency is numpy.
24
+
25
+ ```bash
26
+ pip install isolines # numpy only
27
+ pip install "isolines[scipy]" # + linear (Delaunay TIN) interpolation
28
+ pip install "isolines[raster]" # + GeoTIFF and other rasters through rasterio
29
+ ```
30
+
31
+ Requires Python 3.9 or newer.
32
+
33
+ Typical uses: DEM contours, travel-time isochrones from sampled network
34
+ times, noise maps from receiver levels, any "contour this surface" job
35
+ where you don't want a GDAL install.
36
+
37
+ ---
38
+
39
+ ## Quick start (Python)
40
+
41
+ ```python
42
+ import math
43
+ import numpy as np
44
+ import isolines
45
+
46
+ # A gridded surface with georeferencing (GDAL-style, north-up, 25 m cells)
47
+ yy, xx = np.mgrid[0:200, 0:300]
48
+ dem = 50 + 40 * np.exp(-((xx - 150) ** 2 + (yy - 100) ** 2) / 2000)
49
+ gt = isolines.GeoTransform.north_up(500000.0, 4200000.0, 25.0)
50
+
51
+ levels = isolines.interval_levels(dem.min(), dem.max(), 5) # 55, 60, ..., 90
52
+ lines = isolines.contour_lines(dem, levels, transform=gt, smooth=2, label_format="{:g} m")
53
+ bands = isolines.isobands(dem, [-math.inf, 60, 70, 80, math.inf], transform=gt)
54
+
55
+ for band in bands:
56
+ print(band.label, round(band.area / 1e4, 1), "ha")
57
+
58
+ isolines.write_geojson("contours.geojson", lines, precision=2)
59
+ isolines.write_geojson("bands.geojson", bands, precision=2)
60
+ ```
61
+
62
+ From scattered points (for example travel times at network nodes):
63
+
64
+ ```python
65
+ z, gt = isolines.points_to_grid(x, y, minutes, cell_size=50.0, method="idw", k=8)
66
+ isochrones = isolines.isobands(z, [0, 5, 10, 15], transform=gt)
67
+ ```
68
+
69
+ A rasterio dataset can be used directly:
70
+
71
+ ```python
72
+ r = isolines.read_raster("dem.tif") # needs the "raster" extra; NaN for no-data
73
+ lines = isolines.contour_lines(r.z, isolines.nice_levels(r.z), transform=r.transform)
74
+ ```
75
+
76
+ See [`examples/quickstart.py`](examples/quickstart.py) for a DEM, a noise
77
+ map and an isochrone example.
78
+
79
+ ## Quick start (command line)
80
+
81
+ ```bash
82
+ # contour lines every 10 m from a GeoTIFF (or an Esri ASCII .asc grid)
83
+ isolines lines dem.tif --interval 10 --smooth 2 -o contours.geojson
84
+
85
+ # filled bands with explicit breaks, open-ended below and above
86
+ isolines bands noise.asc --breaks 55,60,65,70 --open-ends -o noise.geojson
87
+
88
+ # bands straight from a point CSV (interpolated to a 50 m grid first)
89
+ isolines bands times.csv --value-col minutes --cell-size 50 --method idw \
90
+ --breaks 0,5,10,15 -o isochrones.geojson
91
+
92
+ # just the interpolation, written as an Esri ASCII grid
93
+ isolines grid times.csv --value-col minutes --cell-size 50 -o times.asc
94
+ ```
95
+
96
+ Without `--levels`/`--breaks`/`--interval`, about ten round-numbered levels
97
+ are chosen. `--simplify`, `--precision`, `--label-format`, `--min-length`
98
+ and `--min-area` are available; `isolines <command> --help` lists all
99
+ options.
100
+
101
+ ## API overview
102
+
103
+ | Function / class | Purpose |
104
+ |---|---|
105
+ | `contour_lines(z, levels, *, x, y, transform, nodata, pixel_is_area, smooth, simplify, min_length, label_format)` | List of `ContourLine(level, coords, closed, label)` |
106
+ | `isobands(z, breaks, *, ..., min_area, keep_empty)` | List of `IsoBand(lower, upper, polygons, label)`, one per interval `[lower, upper)` |
107
+ | `GeoTransform` / `as_geotransform` | GDAL 6-coefficient transform; accepts GDAL tuples and rasterio `Affine` |
108
+ | `idw`, `nearest`, `linear`, `points_to_grid`, `GridSpec` | Scattered points to grid |
109
+ | `interval_levels`, `nice_interval`, `nice_levels` | Level selection |
110
+ | `chaikin`, `douglas_peucker`, `signed_area`, `line_length`, `point_in_ring` | Geometry helpers |
111
+ | `assemble_polygons`, `orient_polygon`, `validate_polygon` | Polygon building and validity checking |
112
+ | `read_ascii_grid`, `write_ascii_grid`, `read_raster`, `load_grid`, `read_points_csv` | Input |
113
+ | `to_geojson`, `write_geojson`, `.to_feature()` | GeoJSON output |
114
+
115
+ Coordinates: with `transform`, grid value `z[row, col]` is placed at the
116
+ pixel centre (`pixel_is_area=True`, the GDAL/rasterio convention) or the
117
+ pixel corner (`False`). With `x`/`y` (1-D, strictly monotonic), at
118
+ `(x[col], y[row])`. With neither, at `(col, row)`.
119
+
120
+ GeoJSON properties: lines carry `level`, `label`, `closed`, `length`;
121
+ bands carry `lower`, `upper` (`null` for infinite), `label`, `area`.
122
+
123
+ ## Method and sources
124
+
125
+ - **Marching squares.** Each grid cell is classified by which corners are
126
+ `>= level` (16 cases); crossing points are placed on cell edges by linear
127
+ interpolation between the two edge nodes. This is the 2-D case of
128
+ Lorensen, W. E. & Cline, H. E. (1987), *Marching cubes: a high resolution
129
+ 3D surface construction algorithm*, SIGGRAPH Computer Graphics 21(4),
130
+ 163-169; see also Maple, C. (2003), *Geometric design and space planning
131
+ using the marching squares and marching cube algorithms*, Proc.
132
+ GMAG 2003, 90-95.
133
+ - **Saddle disambiguation.** In the two ambiguous cases (diagonal corners
134
+ above), the mean of the four corner values stands in for the value at the
135
+ cell centre: when it is `>= level` the high corners are joined through the
136
+ cell, otherwise the low corners are. (The asymptotic decider of Nielson,
137
+ G. M. & Hamann, B. (1991), *The asymptotic decider*, Proc. IEEE
138
+ Visualization '91, 83-91, is the bilinear-exact alternative; it is not
139
+ used here.)
140
+ - **Joining and orientation.** Every segment is directed so that the
141
+ `>= level` side lies on its left. Each edge crossing then starts exactly
142
+ one segment and ends exactly one, so joining is unambiguous and closed
143
+ rings are detected exactly by shared crossing identities, not by
144
+ coordinate tolerance.
145
+ - **Isobands.** The boundary of the band `lower <= z < upper` is made of
146
+ the oriented contour segments of `lower`, the reversed contour segments of
147
+ `upper`, and the parts of the valid-domain boundary (grid edge and no-data
148
+ cells) whose values fall in the band. These are traced into closed rings;
149
+ counter-clockwise rings are exteriors, clockwise rings are holes and go to
150
+ the smallest exterior containing them. Rings that touch themselves where
151
+ the no-data domain pinches are split, so polygons are valid under the OGC
152
+ Simple Features rules. Output rings follow **RFC 7946, section 3.1.6**
153
+ (exterior counter-clockwise, holes clockwise; first and last positions
154
+ equal).
155
+ - **Exact level values.** A node equal to a level counts as above it.
156
+ Crossing parameters are clamped to `[1e-7, 1 - 1e-7]` of the edge, so a
157
+ contour through a node never produces coincident points on two edges;
158
+ the microscopic rings this would leave around an isolated node equal to
159
+ the level (for example a peak equal to the top level) are dropped.
160
+ - **Smoothing:** Chaikin, G. M. (1974), *An algorithm for high-speed curve
161
+ generation*, Computer Graphics and Image Processing 3(4), 346-349.
162
+ - **Simplification:** Douglas, D. H. & Peucker, T. K. (1973), *Algorithms
163
+ for the reduction of the number of points required to represent a
164
+ digitized line or its caricature*, Cartographica 10(2), 112-122.
165
+ - **IDW:** Shepard, D. (1968), *A two-dimensional interpolation function
166
+ for irregularly-spaced data*, Proc. 23rd ACM National Conference,
167
+ 517-524. Weights `d ** -power`; a query on a sample takes its value.
168
+ - **Linear TIN:** Delaunay triangulation and barycentric interpolation via
169
+ `scipy.interpolate.LinearNDInterpolator`; NaN outside the convex hull.
170
+ - **Geotransform:** the GDAL six-coefficient affine model (GDAL Raster Data
171
+ Model documentation). **Esri ASCII grid:** header keys and row order as in
172
+ the Esri "Esri ASCII raster format" documentation.
173
+
174
+ The test suite checks analytic surfaces with known answers: a plane
175
+ (contour position and band area exact), a cone (contours within the
176
+ `1/(8r)` interpolation error of a circle, annulus area), the saddle `z = x*y`
177
+ (contour on the axes, saddle-cell areas computed by hand), nested
178
+ peak/pit/plateau surfaces (island in a hole), no-data holes and pinches,
179
+ plus 40 random integer grids whose values sit exactly on the breaks. Every
180
+ band polygon is validated, and the bands are checked to tile the valid
181
+ domain; shapely (`is_valid`, areas) is used as an independent check when
182
+ installed.
183
+
184
+ ## Limitations
185
+
186
+ - Linear interpolation along edges means contours of curved surfaces are
187
+ chords; refine the grid for more accuracy.
188
+ - The saddle rule uses the cell mean, not the bilinear asymptotic decider;
189
+ in rare saddle cells the topology can differ from bilinear interpolation.
190
+ - `smooth` and `simplify` work ring by ring and are not topology-preserving:
191
+ neighbouring bands may slightly overlap or leave slivers, and a smoothed
192
+ line can cross a neighbouring level. Leave them off when exact tiling
193
+ matters.
194
+ - Rounding coordinates (`precision`) can merge points that are only `1e-7`
195
+ cells apart at exact-level nodes; keep enough decimals for your units.
196
+ - `idw` and `nearest` are brute force (chunked numpy), fine for tens of
197
+ thousands of samples; IDW is an exact interpolator but not a statistical
198
+ one (no kriging, no barriers). A noise or travel-time surface interpolated
199
+ from points is only as good as the sample coverage.
200
+ - GeoJSON output carries no CRS (RFC 7946 assumes WGS 84); projected
201
+ coordinates are written as given.
202
+ - Lines and polygons are built in memory; very large grids (10^8 cells)
203
+ need tiling.
204
+
205
+ ## Development
206
+
207
+ ```bash
208
+ python -m pip install -e ".[dev,scipy,raster]"
209
+ python -m pytest tests
210
+ python -m ruff check . && python -m ruff format --check src tests
211
+ python -m mypy src
212
+ ```
213
+
214
+ ## Academic citation
215
+
216
+ ```bibtex
217
+ @software{eminoglu_isolines,
218
+ author = {Eminoğlu, Yusuf},
219
+ title = {isolines},
220
+ year = {2026},
221
+ version = {0.1.0},
222
+ url = {https://gitlab.com/geophilo1/isolines_sdk}
223
+ }
224
+ ```
225
+
226
+ ## License
227
+
228
+ MIT License. Copyright (c) 2026 Yusuf Eminoğlu. See [LICENSE](LICENSE).
@@ -0,0 +1,74 @@
1
+ """isolines quick start: DEM contours, a noise map and a travel-time isochrone.
2
+
3
+ Run from the repository root:
4
+
5
+ python examples/quickstart.py
6
+
7
+ Writes three GeoJSON files next to this script (coordinates in a projected,
8
+ metre-based CRS; tell your GIS which one, RFC 7946 assumes WGS 84).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ from pathlib import Path
15
+
16
+ import numpy as np
17
+
18
+ import isolines
19
+
20
+ HERE = Path(__file__).parent
21
+
22
+
23
+ def dem_contours() -> None:
24
+ # A synthetic 25 m DEM: two hills on a gentle slope, with a no-data lake.
25
+ yy, xx = np.mgrid[0:120, 0:160].astype(float)
26
+ z = (
27
+ 40
28
+ + 0.15 * xx
29
+ + 60 * np.exp(-((xx - 50) ** 2 + (yy - 60) ** 2) / 600)
30
+ + 35 * np.exp(-((xx - 115) ** 2 + (yy - 40) ** 2) / 300)
31
+ )
32
+ z[90:105, 120:140] = np.nan
33
+ gt = isolines.GeoTransform.north_up(500000.0, 4200000.0, 25.0)
34
+
35
+ levels = isolines.interval_levels(float(np.nanmin(z)), float(np.nanmax(z)), 10)
36
+ lines = isolines.contour_lines(
37
+ z, levels, transform=gt, smooth=2, simplify=2.0, label_format="{:g} m"
38
+ )
39
+ isolines.write_geojson(HERE / "dem_contours.geojson", lines, precision=2)
40
+ print(f"DEM: {len(lines)} contour lines at {levels[0]:g}..{levels[-1]:g} m")
41
+
42
+
43
+ def noise_map() -> None:
44
+ # Sound level (dB) at scattered receivers near a road along y = 0,
45
+ # free-field line-source decay of 10*log10(r) dB (illustrative only).
46
+ rng = np.random.default_rng(42)
47
+ x = rng.uniform(-500, 500, 300)
48
+ y = rng.uniform(5, 400, 300)
49
+ db = 75 - 10 * np.log10(y / 5)
50
+ z, gt = isolines.points_to_grid(x, y, db, 10.0, method="idw", k=12)
51
+ bands = isolines.isobands(z, [-math.inf, 55, 60, 65, 70, math.inf], transform=gt)
52
+ isolines.write_geojson(HERE / "noise_bands.geojson", bands, precision=1)
53
+ for band in bands:
54
+ print(f"noise {band.label:>8} dB: {band.area / 1e4:8.1f} ha")
55
+
56
+
57
+ def isochrones() -> None:
58
+ # Walking time (minutes) sampled at network nodes around a station.
59
+ rng = np.random.default_rng(7)
60
+ x = rng.uniform(-2000, 2000, 500)
61
+ y = rng.uniform(-2000, 2000, 500)
62
+ detour = 1.25 # network distance / straight-line distance
63
+ minutes = np.hypot(x, y) * detour / 80.0 # 80 m per minute
64
+ z, gt = isolines.points_to_grid(x, y, minutes, 50.0, method="nearest")
65
+ bands = isolines.isobands(z, [0, 5, 10, 15], transform=gt, smooth=1)
66
+ isolines.write_geojson(HERE / "isochrones.geojson", bands, precision=1)
67
+ for band in bands:
68
+ print(f"isochrone {band.label:>6} min: {band.area / 1e6:6.2f} km2")
69
+
70
+
71
+ if __name__ == "__main__":
72
+ dem_contours()
73
+ noise_map()
74
+ isochrones()