locateobject 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,9 @@
1
+
2
+
3
+ Copyright 2026 Joost Postma
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,194 @@
1
+ Metadata-Version: 2.4
2
+ Name: locateobject
3
+ Version: 0.1.0
4
+ Summary: Locate real-world objects by triangulating bounding boxes from multiple geotagged images
5
+ Author: Joost Postma
6
+ License-Expression: MIT
7
+ Keywords: triangulation,locateobject,geospatial,photogrammetry,panoramax,gis
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Intended Audience :: Science/Research
10
+ Classifier: Topic :: Scientific/Engineering :: GIS
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=3.11
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: numpy>=2.4
16
+ Requires-Dist: pandas>=3.0
17
+ Requires-Dist: shapely>=2.1
18
+ Requires-Dist: geopandas>=1.1
19
+ Requires-Dist: pyproj>=3.7
20
+ Requires-Dist: scipy>=1.17
21
+ Requires-Dist: scikit-learn>=1.8
22
+ Requires-Dist: geopy>=2.4
23
+ Requires-Dist: haversine>=2.9
24
+ Dynamic: license-file
25
+
26
+ # locateobject
27
+
28
+ Locate real-world objects by triangulating bounding boxes from multiple geotagged images.
29
+
30
+ You give `locateobject` a set of annotations (one bounding box of one object in one image, plus the camera position and orientation). It works out where the objects are, groups annotations that belong to the same object, and returns the results as GeoDataFrames you can save straight to GeoPackage.
31
+
32
+ The package exposes a single function: `locate_object()`.
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install locateobject
38
+ ```
39
+
40
+ Requires Python 3.11 or newer.
41
+
42
+ ## How it works
43
+
44
+ 1. **Bearing line per annotation.** The horizontal centre of the bounding box gives a compass bearing from the camera (using the image width, field of view and camera azimuth). The vertical size of the bounding box gives the angular size of the object. Combined with the minimum and maximum real-world size of the object, this gives a *line segment* along that bearing where the object can be.
45
+ 2. **Intersections.** Segments from different annotations that cross are candidate object locations.
46
+ 3. **Filtering.** An intersection is kept only if the angle between the two bearings is within the allowed range (a very small angle gives poor location precision) and the two annotations estimate a similar object size.
47
+ 4. **Clustering.** Intersections are grouped with a recursive DBSCAN clustering until every cluster is tighter than `cluster_radius_m_max`. Each annotation line can belong to at most one cluster.
48
+ 5. **Centroids.** Each cluster becomes a centroid (the object location). Annotation lines close to a centroid are assigned to the best-scoring centroid, and centroids supported by fewer than two annotations are dropped.
49
+
50
+ ## Quick start
51
+
52
+ ```python
53
+ from shapely.geometry import Point
54
+ from locateobject import locate_object
55
+
56
+ # A 360 degree panorama, 8192 px wide. The object is a 1 m tall sign
57
+ # about 10 m north of camera 1.
58
+ records = [
59
+ {
60
+ "origin": Point(5.0000, 52.0000), # (longitude, latitude)
61
+ "picture_field_of_view": 360,
62
+ "picture_azimuth": 0,
63
+ "picture_size_horizontal_px": 8192,
64
+ "picture_size_vertical_px": 4096,
65
+ "bbox": [[4046, 1000], [4146, 1000], [4146, 1130], [4046, 1130], [4046, 1000]],
66
+ "object_minimum_size_meters": 0.5,
67
+ "object_maximum_size_meters": 1.5,
68
+ },
69
+ {
70
+ "origin": Point(5.000146, 52.0000), # about 10 m east of camera 1
71
+ "picture_field_of_view": 360,
72
+ "picture_azimuth": 0,
73
+ "picture_size_horizontal_px": 8192,
74
+ "picture_size_vertical_px": 4096,
75
+ "bbox": [[3026, 1000], [3118, 1000], [3118, 1092], [3026, 1092], [3026, 1000]],
76
+ "object_minimum_size_meters": 0.5,
77
+ "object_maximum_size_meters": 1.5,
78
+ },
79
+ ]
80
+
81
+ labels, centroids, clusters, inter_reliable, inter_all, annotations = locate_object(records)
82
+
83
+ print(labels) # centroid id for each input record, -1 if unassigned
84
+ print(centroids) # one row per located object
85
+ ```
86
+
87
+ Save the results for use in QGIS or other GIS software:
88
+
89
+ ```python
90
+ centroids.to_file("result.gpkg", layer="centroids", driver="GPKG")
91
+ annotations.to_file("result.gpkg", layer="annotations", driver="GPKG")
92
+ ```
93
+
94
+ ## Input
95
+
96
+ `records_input` is a list of dictionaries (`ObjectAnnotationInput`), one per object in one image:
97
+
98
+ | Key | Type | Description |
99
+ |---|---|---|
100
+ | `origin` | `shapely.geometry.Point` | Camera position. `x` = longitude, `y` = latitude (EPSG:4326). |
101
+ | `picture_field_of_view` | `float` or `int` | Horizontal field of view of the image in degrees (360 for a full panorama). |
102
+ | `picture_azimuth` | `float` or `int` | Compass direction of the image centre in degrees (0 = north, clockwise). |
103
+ | `picture_size_horizontal_px` | `int` | Image width in pixels. |
104
+ | `picture_size_vertical_px` | `int` | Image height in pixels. |
105
+ | `bbox` | `list[list[int]]` | Polygon of the object in pixel coordinates, `[[x1, y1], [x2, y2], ...]`, at least 3 points, integers only. If the first and last point differ, the polygon is closed automatically. |
106
+ | `object_minimum_size_meters` | `float` or `int` | Smallest plausible real-world height of the object, in metres. |
107
+ | `object_maximum_size_meters` | `float` or `int` | Largest plausible real-world height of the object, in metres. |
108
+
109
+ The size of the object is taken from the **vertical** extent of the bounding box, so the minimum and maximum sizes refer to the object's height. The wider the range between minimum and maximum, the longer the line along which the object can be located.
110
+
111
+ Annotations with an invalid bounding box or a line that cannot be calculated are skipped with a warning.
112
+
113
+ ## Parameters
114
+
115
+ | Parameter | Default | Description |
116
+ |---|---|---|
117
+ | `intersection_angle_deg_min` | `15` | Minimum angle in degrees between two bearings. Small angles give imprecise locations. |
118
+ | `intersection_angle_deg_max` | `None` (360) | Maximum angle between two bearings. Use it to exclude cameras that cannot plausibly see the same object. |
119
+ | `object_size_score` | `0.8` | Minimum size agreement (0 to 1) between the two annotations of an intersection. A score of 1 means identical size estimates. |
120
+ | `cluster_radius_m_max` | `1` | Maximum distance in metres between a cluster centre and its intersections. Also the maximum distance from a centroid to an annotation line for it to be assigned. |
121
+ | `cluster_min_size` | `1` | Minimum number of intersections for a cluster (DBSCAN `min_samples`). |
122
+ | `cluster_max_depth` | `None` | Maximum recursion depth of the clustering. By default it is derived from `cluster_epsilon_decrease`. |
123
+ | `cluster_epsilon_start` | `None` | Starting DBSCAN radius in metres. Defaults to `cluster_radius_m_max`. |
124
+ | `cluster_epsilon_decrease` | `0.8` | Factor by which the DBSCAN radius shrinks at each recursion level. |
125
+
126
+ ## Output
127
+
128
+ `locate_object()` returns a tuple of six items:
129
+
130
+ ```python
131
+ labels, centroids, clusters, intersections_reliable, intersections, annotations = locate_object(records)
132
+ ```
133
+
134
+ 1. **`labels`**: list of integers, one per input record, in the same order. The `id` of the centroid the record belongs to, or `-1` if it was not assigned.
135
+ 2. **`centroids`**: GeoDataFrame (points) with the final object locations.
136
+ 3. **`clusters`**: GeoDataFrame (points) with the cluster centres before the final assignment.
137
+ 4. **`intersections_reliable`**: GeoDataFrame (points) with the intersections that passed the angle and size filters.
138
+ 5. **`intersections`**: GeoDataFrame (points) with all intersections found.
139
+ 6. **`annotations`**: GeoDataFrame (lines) with the location line of every valid annotation.
140
+
141
+ All GeoDataFrames use `EPSG:4326`. If there is no result, empty GeoDataFrames are returned and `labels` contains only `-1`.
142
+
143
+ ### Columns
144
+
145
+ **`centroids`**
146
+
147
+ | Column | Description |
148
+ |---|---|
149
+ | `id` | Centroid id, matches `labels` and `annotations.centroid_id`. |
150
+ | `object_size_meter` | Mean estimated object size in metres. |
151
+ | `object_size_meter_sd` | Standard deviation of the size estimates. |
152
+ | `object_azimuth` | Direction in degrees from which the object is seen head-on (the average viewing direction plus 180). |
153
+
154
+ **`clusters`**: `object_size_meter`, `object_size_meter_sd`, `object_azimuth` (as above).
155
+
156
+ **`intersections_reliable`** and **`intersections`**
157
+
158
+ | Column | Description |
159
+ |---|---|
160
+ | `angle` | Angle in degrees between the two bearings. |
161
+ | `object_size_average_meter` | Average of the two size estimates at the intersection. |
162
+ | `object_size_score` | Size agreement between the two annotations (0 to 1). |
163
+ | `line1`, `line2` | Index in `records_input` of the two annotations. |
164
+
165
+ **`annotations`**
166
+
167
+ | Column | Description |
168
+ |---|---|
169
+ | `id` | Index of the record in `records_input`. |
170
+ | `centroid_id` | Assigned centroid, or missing if the annotation was not assigned. |
171
+ | `origin_longitude`, `origin_latitude` | Camera position. |
172
+ | `line_azimuth` | Compass bearing of the line in degrees. |
173
+ | `object_distance_meter` | Distance from camera to the centroid (only if assigned). |
174
+ | `object_size_meter` | Object size estimated from this annotation at the centroid (only if assigned). |
175
+
176
+ ## Notes and limitations
177
+
178
+ - The distance estimate assumes the field of view and pixel size describe an undistorted horizontal view (as in equirectangular panoramas). Strongly distorted images give less accurate results.
179
+ - Pixel coordinates have `x` increasing to the right, and `x` wraps around the image seam for panoramas.
180
+ - The intersection search works in a local metric projection and is meant for areas of limited size (around 100 km by 100 km).
181
+ - At least two annotations from different positions are needed to locate an object.
182
+
183
+ ## Logging
184
+
185
+ The package uses the standard `logging` module and attaches a `NullHandler`, so it is silent by default. To see progress and warnings:
186
+
187
+ ```python
188
+ import logging
189
+ logging.basicConfig(level=logging.INFO)
190
+ ```
191
+
192
+ ## License
193
+
194
+ MIT, see the `LICENSE` file.
@@ -0,0 +1,169 @@
1
+ # locateobject
2
+
3
+ Locate real-world objects by triangulating bounding boxes from multiple geotagged images.
4
+
5
+ You give `locateobject` a set of annotations (one bounding box of one object in one image, plus the camera position and orientation). It works out where the objects are, groups annotations that belong to the same object, and returns the results as GeoDataFrames you can save straight to GeoPackage.
6
+
7
+ The package exposes a single function: `locate_object()`.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pip install locateobject
13
+ ```
14
+
15
+ Requires Python 3.11 or newer.
16
+
17
+ ## How it works
18
+
19
+ 1. **Bearing line per annotation.** The horizontal centre of the bounding box gives a compass bearing from the camera (using the image width, field of view and camera azimuth). The vertical size of the bounding box gives the angular size of the object. Combined with the minimum and maximum real-world size of the object, this gives a *line segment* along that bearing where the object can be.
20
+ 2. **Intersections.** Segments from different annotations that cross are candidate object locations.
21
+ 3. **Filtering.** An intersection is kept only if the angle between the two bearings is within the allowed range (a very small angle gives poor location precision) and the two annotations estimate a similar object size.
22
+ 4. **Clustering.** Intersections are grouped with a recursive DBSCAN clustering until every cluster is tighter than `cluster_radius_m_max`. Each annotation line can belong to at most one cluster.
23
+ 5. **Centroids.** Each cluster becomes a centroid (the object location). Annotation lines close to a centroid are assigned to the best-scoring centroid, and centroids supported by fewer than two annotations are dropped.
24
+
25
+ ## Quick start
26
+
27
+ ```python
28
+ from shapely.geometry import Point
29
+ from locateobject import locate_object
30
+
31
+ # A 360 degree panorama, 8192 px wide. The object is a 1 m tall sign
32
+ # about 10 m north of camera 1.
33
+ records = [
34
+ {
35
+ "origin": Point(5.0000, 52.0000), # (longitude, latitude)
36
+ "picture_field_of_view": 360,
37
+ "picture_azimuth": 0,
38
+ "picture_size_horizontal_px": 8192,
39
+ "picture_size_vertical_px": 4096,
40
+ "bbox": [[4046, 1000], [4146, 1000], [4146, 1130], [4046, 1130], [4046, 1000]],
41
+ "object_minimum_size_meters": 0.5,
42
+ "object_maximum_size_meters": 1.5,
43
+ },
44
+ {
45
+ "origin": Point(5.000146, 52.0000), # about 10 m east of camera 1
46
+ "picture_field_of_view": 360,
47
+ "picture_azimuth": 0,
48
+ "picture_size_horizontal_px": 8192,
49
+ "picture_size_vertical_px": 4096,
50
+ "bbox": [[3026, 1000], [3118, 1000], [3118, 1092], [3026, 1092], [3026, 1000]],
51
+ "object_minimum_size_meters": 0.5,
52
+ "object_maximum_size_meters": 1.5,
53
+ },
54
+ ]
55
+
56
+ labels, centroids, clusters, inter_reliable, inter_all, annotations = locate_object(records)
57
+
58
+ print(labels) # centroid id for each input record, -1 if unassigned
59
+ print(centroids) # one row per located object
60
+ ```
61
+
62
+ Save the results for use in QGIS or other GIS software:
63
+
64
+ ```python
65
+ centroids.to_file("result.gpkg", layer="centroids", driver="GPKG")
66
+ annotations.to_file("result.gpkg", layer="annotations", driver="GPKG")
67
+ ```
68
+
69
+ ## Input
70
+
71
+ `records_input` is a list of dictionaries (`ObjectAnnotationInput`), one per object in one image:
72
+
73
+ | Key | Type | Description |
74
+ |---|---|---|
75
+ | `origin` | `shapely.geometry.Point` | Camera position. `x` = longitude, `y` = latitude (EPSG:4326). |
76
+ | `picture_field_of_view` | `float` or `int` | Horizontal field of view of the image in degrees (360 for a full panorama). |
77
+ | `picture_azimuth` | `float` or `int` | Compass direction of the image centre in degrees (0 = north, clockwise). |
78
+ | `picture_size_horizontal_px` | `int` | Image width in pixels. |
79
+ | `picture_size_vertical_px` | `int` | Image height in pixels. |
80
+ | `bbox` | `list[list[int]]` | Polygon of the object in pixel coordinates, `[[x1, y1], [x2, y2], ...]`, at least 3 points, integers only. If the first and last point differ, the polygon is closed automatically. |
81
+ | `object_minimum_size_meters` | `float` or `int` | Smallest plausible real-world height of the object, in metres. |
82
+ | `object_maximum_size_meters` | `float` or `int` | Largest plausible real-world height of the object, in metres. |
83
+
84
+ The size of the object is taken from the **vertical** extent of the bounding box, so the minimum and maximum sizes refer to the object's height. The wider the range between minimum and maximum, the longer the line along which the object can be located.
85
+
86
+ Annotations with an invalid bounding box or a line that cannot be calculated are skipped with a warning.
87
+
88
+ ## Parameters
89
+
90
+ | Parameter | Default | Description |
91
+ |---|---|---|
92
+ | `intersection_angle_deg_min` | `15` | Minimum angle in degrees between two bearings. Small angles give imprecise locations. |
93
+ | `intersection_angle_deg_max` | `None` (360) | Maximum angle between two bearings. Use it to exclude cameras that cannot plausibly see the same object. |
94
+ | `object_size_score` | `0.8` | Minimum size agreement (0 to 1) between the two annotations of an intersection. A score of 1 means identical size estimates. |
95
+ | `cluster_radius_m_max` | `1` | Maximum distance in metres between a cluster centre and its intersections. Also the maximum distance from a centroid to an annotation line for it to be assigned. |
96
+ | `cluster_min_size` | `1` | Minimum number of intersections for a cluster (DBSCAN `min_samples`). |
97
+ | `cluster_max_depth` | `None` | Maximum recursion depth of the clustering. By default it is derived from `cluster_epsilon_decrease`. |
98
+ | `cluster_epsilon_start` | `None` | Starting DBSCAN radius in metres. Defaults to `cluster_radius_m_max`. |
99
+ | `cluster_epsilon_decrease` | `0.8` | Factor by which the DBSCAN radius shrinks at each recursion level. |
100
+
101
+ ## Output
102
+
103
+ `locate_object()` returns a tuple of six items:
104
+
105
+ ```python
106
+ labels, centroids, clusters, intersections_reliable, intersections, annotations = locate_object(records)
107
+ ```
108
+
109
+ 1. **`labels`**: list of integers, one per input record, in the same order. The `id` of the centroid the record belongs to, or `-1` if it was not assigned.
110
+ 2. **`centroids`**: GeoDataFrame (points) with the final object locations.
111
+ 3. **`clusters`**: GeoDataFrame (points) with the cluster centres before the final assignment.
112
+ 4. **`intersections_reliable`**: GeoDataFrame (points) with the intersections that passed the angle and size filters.
113
+ 5. **`intersections`**: GeoDataFrame (points) with all intersections found.
114
+ 6. **`annotations`**: GeoDataFrame (lines) with the location line of every valid annotation.
115
+
116
+ All GeoDataFrames use `EPSG:4326`. If there is no result, empty GeoDataFrames are returned and `labels` contains only `-1`.
117
+
118
+ ### Columns
119
+
120
+ **`centroids`**
121
+
122
+ | Column | Description |
123
+ |---|---|
124
+ | `id` | Centroid id, matches `labels` and `annotations.centroid_id`. |
125
+ | `object_size_meter` | Mean estimated object size in metres. |
126
+ | `object_size_meter_sd` | Standard deviation of the size estimates. |
127
+ | `object_azimuth` | Direction in degrees from which the object is seen head-on (the average viewing direction plus 180). |
128
+
129
+ **`clusters`**: `object_size_meter`, `object_size_meter_sd`, `object_azimuth` (as above).
130
+
131
+ **`intersections_reliable`** and **`intersections`**
132
+
133
+ | Column | Description |
134
+ |---|---|
135
+ | `angle` | Angle in degrees between the two bearings. |
136
+ | `object_size_average_meter` | Average of the two size estimates at the intersection. |
137
+ | `object_size_score` | Size agreement between the two annotations (0 to 1). |
138
+ | `line1`, `line2` | Index in `records_input` of the two annotations. |
139
+
140
+ **`annotations`**
141
+
142
+ | Column | Description |
143
+ |---|---|
144
+ | `id` | Index of the record in `records_input`. |
145
+ | `centroid_id` | Assigned centroid, or missing if the annotation was not assigned. |
146
+ | `origin_longitude`, `origin_latitude` | Camera position. |
147
+ | `line_azimuth` | Compass bearing of the line in degrees. |
148
+ | `object_distance_meter` | Distance from camera to the centroid (only if assigned). |
149
+ | `object_size_meter` | Object size estimated from this annotation at the centroid (only if assigned). |
150
+
151
+ ## Notes and limitations
152
+
153
+ - The distance estimate assumes the field of view and pixel size describe an undistorted horizontal view (as in equirectangular panoramas). Strongly distorted images give less accurate results.
154
+ - Pixel coordinates have `x` increasing to the right, and `x` wraps around the image seam for panoramas.
155
+ - The intersection search works in a local metric projection and is meant for areas of limited size (around 100 km by 100 km).
156
+ - At least two annotations from different positions are needed to locate an object.
157
+
158
+ ## Logging
159
+
160
+ The package uses the standard `logging` module and attaches a `NullHandler`, so it is silent by default. To see progress and warnings:
161
+
162
+ ```python
163
+ import logging
164
+ logging.basicConfig(level=logging.INFO)
165
+ ```
166
+
167
+ ## License
168
+
169
+ MIT, see the `LICENSE` file.
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "locateobject"
7
+ dynamic = ["version"] # read from __version__ in __init__.py
8
+ description = "Locate real-world objects by triangulating bounding boxes from multiple geotagged images"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Joost Postma" }]
14
+ keywords = ["triangulation", "locateobject", "geospatial", "photogrammetry", "panoramax", "gis"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Intended Audience :: Science/Research",
18
+ "Topic :: Scientific/Engineering :: GIS",
19
+ "Operating System :: OS Independent",
20
+ ]
21
+ dependencies = [
22
+ "numpy>=2.4",
23
+ "pandas>=3.0",
24
+ "shapely>=2.1",
25
+ "geopandas>=1.1",
26
+ "pyproj>=3.7",
27
+ "scipy>=1.17",
28
+ "scikit-learn>=1.8",
29
+ "geopy>=2.4",
30
+ "haversine>=2.9",
31
+ ]
32
+
33
+ # [project.urls]
34
+ # Source = "https://github.com/joost_72/objectlocation"
35
+
36
+ [tool.setuptools.dynamic]
37
+ version = { attr = "locateobject.__version__" }
38
+
39
+ [tool.setuptools.packages.find]
40
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,22 @@
1
+ import logging
2
+ # from importlib import resources
3
+ # import tomllib
4
+
5
+ from .locate import locate_object
6
+
7
+
8
+ # Version of the locateobject package
9
+ __version__ = "0.1.0"
10
+
11
+ # Library rule: attach a NullHandler to the package's top-level logger.
12
+ # This silences "no handlers found" warnings if the caller configures nothing,
13
+ # and leaves all real handler/level setup to the application.
14
+ logging.getLogger(__name__).addHandler(logging.NullHandler())
15
+
16
+ __all__ = ["locate_object"]
17
+
18
+
19
+ # Read URL of the Real Python feed from config file
20
+ # _cfg = tomllib.loads(resources.read_text("triangulation", "config.toml"))
21
+ # EARTH_RADIUS_M = _cfg["general"]["EARTH_RADIUS_M"]
22
+ # print(f'EARTH_RADIUS_M: {EARTH_RADIUS_M}')