fastquadtree 0.6.1__cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl
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.
- fastquadtree/__init__.py +381 -0
- fastquadtree/__init__.pyi +70 -0
- fastquadtree/_bimap.py +112 -0
- fastquadtree/_item.py +24 -0
- fastquadtree/_native.abi3.so +0 -0
- fastquadtree/py.typed +0 -0
- fastquadtree-0.6.1.dist-info/METADATA +303 -0
- fastquadtree-0.6.1.dist-info/RECORD +10 -0
- fastquadtree-0.6.1.dist-info/WHEEL +4 -0
- fastquadtree-0.6.1.dist-info/licenses/LICENSE +21 -0
fastquadtree/__init__.py
ADDED
@@ -0,0 +1,381 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from typing import Any, Literal, Tuple, overload
|
4
|
+
|
5
|
+
from ._bimap import BiMap # type: ignore[attr-defined]
|
6
|
+
from ._item import Item
|
7
|
+
|
8
|
+
# Compiled Rust module is provided by maturin (tool.maturin.module-name)
|
9
|
+
from ._native import QuadTree as _RustQuadTree
|
10
|
+
|
11
|
+
Bounds = Tuple[float, float, float, float]
|
12
|
+
"""Axis-aligned rectangle as (min_x, min_y, max_x, max_y)."""
|
13
|
+
|
14
|
+
Point = Tuple[float, float]
|
15
|
+
"""2D point as (x, y)."""
|
16
|
+
|
17
|
+
_IdCoord = Tuple[int, float, float]
|
18
|
+
"""Result tuple as (id, x, y)."""
|
19
|
+
|
20
|
+
|
21
|
+
class QuadTree:
|
22
|
+
"""
|
23
|
+
High-level Python wrapper over the Rust quadtree engine.
|
24
|
+
|
25
|
+
The quadtree stores points with integer IDs. You may attach an arbitrary
|
26
|
+
Python object per ID when object tracking is enabled.
|
27
|
+
|
28
|
+
Performance characteristics:
|
29
|
+
Inserts: average O(log n)
|
30
|
+
Rect queries: average O(log n + k) where k is matches returned
|
31
|
+
Nearest neighbor: average O(log n)
|
32
|
+
|
33
|
+
Thread-safety:
|
34
|
+
Instances are not thread-safe. Use external synchronization if you
|
35
|
+
mutate the same tree from multiple threads.
|
36
|
+
|
37
|
+
Args:
|
38
|
+
bounds: World bounds as (min_x, min_y, max_x, max_y).
|
39
|
+
capacity: Max number of points per node before splitting.
|
40
|
+
max_depth: Optional max tree depth. If omitted, engine decides.
|
41
|
+
track_objects: Enable id <-> object mapping inside Python.
|
42
|
+
start_id: Starting auto-assigned id when you omit id on insert.
|
43
|
+
|
44
|
+
Raises:
|
45
|
+
ValueError: If parameters are invalid or inserts are out of bounds.
|
46
|
+
"""
|
47
|
+
|
48
|
+
__slots__ = ("_bounds", "_count", "_items", "_native", "_next_id")
|
49
|
+
|
50
|
+
def __init__(
|
51
|
+
self,
|
52
|
+
bounds: Bounds,
|
53
|
+
capacity: int,
|
54
|
+
*,
|
55
|
+
max_depth: int | None = None,
|
56
|
+
track_objects: bool = False,
|
57
|
+
start_id: int = 1,
|
58
|
+
):
|
59
|
+
if max_depth is None:
|
60
|
+
self._native = _RustQuadTree(bounds, capacity)
|
61
|
+
else:
|
62
|
+
self._native = _RustQuadTree(bounds, capacity, max_depth=max_depth)
|
63
|
+
self._items: BiMap | None = BiMap() if track_objects else None
|
64
|
+
self._next_id: int = int(start_id)
|
65
|
+
self._count: int = 0
|
66
|
+
self._bounds = bounds
|
67
|
+
|
68
|
+
# ---------- inserts ----------
|
69
|
+
|
70
|
+
def insert(self, xy: Point, *, id_: int | None = None, obj: Any = None) -> int:
|
71
|
+
"""
|
72
|
+
Insert a single point.
|
73
|
+
|
74
|
+
Args:
|
75
|
+
xy: Point (x, y).
|
76
|
+
id: Optional integer id. If None, an auto id is assigned.
|
77
|
+
obj: Optional Python object to associate with id. Stored only if
|
78
|
+
object tracking is enabled.
|
79
|
+
|
80
|
+
Returns:
|
81
|
+
The id used for this insert.
|
82
|
+
|
83
|
+
Raises:
|
84
|
+
ValueError: If the point is outside tree bounds.
|
85
|
+
"""
|
86
|
+
if id_ is None:
|
87
|
+
id_ = self._next_id
|
88
|
+
self._next_id += 1
|
89
|
+
# ensure future auto-ids do not collide
|
90
|
+
elif id_ >= self._next_id:
|
91
|
+
self._next_id = id_ + 1
|
92
|
+
|
93
|
+
if not self._native.insert(id_, xy):
|
94
|
+
x, y = xy
|
95
|
+
bx0, by0, bx1, by1 = self._bounds
|
96
|
+
raise ValueError(
|
97
|
+
f"Point ({x}, {y}) is outside bounds ({bx0}, {by0}, {bx1}, {by1})"
|
98
|
+
)
|
99
|
+
|
100
|
+
if self._items is not None:
|
101
|
+
self._items.add(Item(id_, xy[0], xy[1], obj))
|
102
|
+
|
103
|
+
self._count += 1
|
104
|
+
return id_
|
105
|
+
|
106
|
+
def insert_many_points(self, points: list[Point]) -> int:
|
107
|
+
"""
|
108
|
+
Bulk insert points with auto-assigned ids.
|
109
|
+
|
110
|
+
Args:
|
111
|
+
points: List of (x, y) points.
|
112
|
+
|
113
|
+
Returns:
|
114
|
+
The number of points inserted
|
115
|
+
"""
|
116
|
+
start_id = self._next_id
|
117
|
+
last_id = self._native.insert_many_points(start_id, points)
|
118
|
+
|
119
|
+
num_inserted = last_id - start_id + 1
|
120
|
+
|
121
|
+
if num_inserted < len(points):
|
122
|
+
raise ValueError("One or more points are outside tree bounds")
|
123
|
+
|
124
|
+
self._next_id = last_id + 1
|
125
|
+
|
126
|
+
# Update the item tracker if needed
|
127
|
+
if self._items is not None:
|
128
|
+
for i, id_ in enumerate(range(start_id, last_id + 1)):
|
129
|
+
x, y = points[i]
|
130
|
+
self._items.add(Item(id_, x, y, None))
|
131
|
+
|
132
|
+
return num_inserted
|
133
|
+
|
134
|
+
def attach(self, id_: int, obj: Any) -> None:
|
135
|
+
"""
|
136
|
+
Attach or replace the Python object for an existing id.
|
137
|
+
Tracking must be enabled.
|
138
|
+
|
139
|
+
Args:
|
140
|
+
id_: Target id.
|
141
|
+
obj: Object to associate with id.
|
142
|
+
"""
|
143
|
+
if self._items is None:
|
144
|
+
raise ValueError("Cannot attach objects when track_objects=False")
|
145
|
+
|
146
|
+
item = self._items.by_id(id_)
|
147
|
+
if item is None:
|
148
|
+
raise KeyError(f"Id {id_} not found in quadtree")
|
149
|
+
self._items.add(Item(id_, item.x, item.y, obj))
|
150
|
+
|
151
|
+
def delete(self, id_: int, xy: Point) -> bool:
|
152
|
+
"""
|
153
|
+
Delete an item by id and exact coordinates.
|
154
|
+
|
155
|
+
Args:
|
156
|
+
id_: Integer id to remove.
|
157
|
+
xy: Coordinates (x, y) of the item.
|
158
|
+
|
159
|
+
Returns:
|
160
|
+
True if the item was found and deleted, else False.
|
161
|
+
"""
|
162
|
+
deleted = self._native.delete(id_, xy)
|
163
|
+
if deleted:
|
164
|
+
self._count -= 1
|
165
|
+
if self._items is not None:
|
166
|
+
self._items.pop_id(id_) # ignore result
|
167
|
+
return deleted
|
168
|
+
|
169
|
+
def delete_by_object(self, obj: Any) -> bool:
|
170
|
+
"""
|
171
|
+
Delete an item by Python object.
|
172
|
+
|
173
|
+
Requires object tracking to be enabled. Performs an O(1) reverse
|
174
|
+
lookup to get the id, then deletes that entry at the given location.
|
175
|
+
|
176
|
+
Args:
|
177
|
+
obj: The tracked Python object to remove.
|
178
|
+
|
179
|
+
Returns:
|
180
|
+
True if the item was found and deleted, else False.
|
181
|
+
|
182
|
+
Raises:
|
183
|
+
ValueError: If object tracking is disabled.
|
184
|
+
"""
|
185
|
+
if self._items is None:
|
186
|
+
raise ValueError(
|
187
|
+
"Cannot delete by object when track_objects=False. Use delete(id, xy) instead."
|
188
|
+
)
|
189
|
+
|
190
|
+
item = self._items.by_obj(obj)
|
191
|
+
if item is None:
|
192
|
+
return False
|
193
|
+
|
194
|
+
return self.delete(item.id_, (item.x, item.y))
|
195
|
+
|
196
|
+
# ---------- queries ----------
|
197
|
+
|
198
|
+
@overload
|
199
|
+
def query(
|
200
|
+
self, rect: Bounds, *, as_items: Literal[False] = ...
|
201
|
+
) -> list[_IdCoord]: ...
|
202
|
+
|
203
|
+
@overload
|
204
|
+
def query(self, rect: Bounds, *, as_items: Literal[True]) -> list[Item]: ...
|
205
|
+
|
206
|
+
def query(
|
207
|
+
self, rect: Bounds, *, as_items: bool = False
|
208
|
+
) -> list[_IdCoord] | list[Item]:
|
209
|
+
"""
|
210
|
+
Return all points inside an axis-aligned rectangle.
|
211
|
+
|
212
|
+
Args:
|
213
|
+
rect: Query rectangle as (min_x, min_y, max_x, max_y).
|
214
|
+
as_items: If True, return Item wrappers. If False, return raw tuples.
|
215
|
+
|
216
|
+
Returns:
|
217
|
+
If as_items is False: list of (id, x, y) tuples.
|
218
|
+
If as_items is True: list of Item objects.
|
219
|
+
"""
|
220
|
+
raw = self._native.query(rect)
|
221
|
+
if not as_items:
|
222
|
+
return raw
|
223
|
+
|
224
|
+
if self._items is None:
|
225
|
+
raise ValueError("Cannot return results as items with track_objects=False")
|
226
|
+
out: list[Item] = []
|
227
|
+
for id_, _, _ in raw:
|
228
|
+
item = self._items.by_id(id_)
|
229
|
+
if item is None:
|
230
|
+
raise RuntimeError(
|
231
|
+
f"Internal error: id {id_} found in native tree but missing from object tracker. "
|
232
|
+
f"Ensure all inserts/deletes are done via this wrapper."
|
233
|
+
)
|
234
|
+
out.append(item)
|
235
|
+
return out
|
236
|
+
|
237
|
+
@overload
|
238
|
+
def nearest_neighbor(
|
239
|
+
self, xy: Point, *, as_item: Literal[False] = ...
|
240
|
+
) -> _IdCoord | None: ...
|
241
|
+
|
242
|
+
@overload
|
243
|
+
def nearest_neighbor(self, xy: Point, *, as_item: Literal[True]) -> Item | None: ...
|
244
|
+
|
245
|
+
def nearest_neighbor(self, xy: Point, *, as_item: bool = False):
|
246
|
+
"""
|
247
|
+
Return the single nearest neighbor to the query point.
|
248
|
+
|
249
|
+
Args:
|
250
|
+
xy: Query point (x, y).
|
251
|
+
as_item: If True, return Item. If False, return (id, x, y).
|
252
|
+
|
253
|
+
Returns:
|
254
|
+
The nearest neighbor or None if the tree is empty.
|
255
|
+
"""
|
256
|
+
t = self._native.nearest_neighbor(xy)
|
257
|
+
if t is None or not as_item:
|
258
|
+
return t
|
259
|
+
|
260
|
+
if self._items is None:
|
261
|
+
raise ValueError("Cannot return result as item with track_objects=False")
|
262
|
+
id_, _x, _y = t
|
263
|
+
item = self._items.by_id(id_)
|
264
|
+
if item is None:
|
265
|
+
raise RuntimeError(
|
266
|
+
f"Internal error: id {id_} found in native tree but missing from object tracker. "
|
267
|
+
f"Ensure all inserts/deletes are done via this wrapper."
|
268
|
+
)
|
269
|
+
return item
|
270
|
+
|
271
|
+
@overload
|
272
|
+
def nearest_neighbors(
|
273
|
+
self, xy: Point, k: int, *, as_items: Literal[False] = ...
|
274
|
+
) -> list[_IdCoord]: ...
|
275
|
+
|
276
|
+
@overload
|
277
|
+
def nearest_neighbors(
|
278
|
+
self, xy: Point, k: int, *, as_items: Literal[True]
|
279
|
+
) -> list[Item]: ...
|
280
|
+
|
281
|
+
def nearest_neighbors(self, xy: Point, k: int, *, as_items: bool = False):
|
282
|
+
"""
|
283
|
+
Return the k nearest neighbors to the query point.
|
284
|
+
|
285
|
+
Args:
|
286
|
+
xy: Query point (x, y).
|
287
|
+
k: Number of neighbors to return.
|
288
|
+
as_items: If True, return Item wrappers. If False, return raw tuples.
|
289
|
+
|
290
|
+
Returns:
|
291
|
+
List of results in ascending distance order.
|
292
|
+
"""
|
293
|
+
raw = self._native.nearest_neighbors(xy, k)
|
294
|
+
if not as_items:
|
295
|
+
return raw
|
296
|
+
if self._items is None:
|
297
|
+
raise ValueError("Cannot return results as items with track_objects=False")
|
298
|
+
|
299
|
+
out: list[Item] = []
|
300
|
+
for id_, _, _ in raw:
|
301
|
+
item = self._items.by_id(id_)
|
302
|
+
if item is None:
|
303
|
+
raise RuntimeError(
|
304
|
+
f"Internal error: id {id_} found in native tree but missing from object tracker. "
|
305
|
+
f"Ensure all inserts/deletes are done via this wrapper."
|
306
|
+
)
|
307
|
+
out.append(item)
|
308
|
+
return out
|
309
|
+
|
310
|
+
# ---------- misc ----------
|
311
|
+
|
312
|
+
def get(self, id_: int) -> Any | None:
|
313
|
+
"""
|
314
|
+
Return the object associated with id.
|
315
|
+
|
316
|
+
Returns:
|
317
|
+
The tracked object if present and tracking is enabled, else None.
|
318
|
+
"""
|
319
|
+
if self._items is None:
|
320
|
+
raise ValueError("Cannot get objects when track_objects=False")
|
321
|
+
item = self._items.by_id(id_)
|
322
|
+
if item is None:
|
323
|
+
return None
|
324
|
+
return item.obj
|
325
|
+
|
326
|
+
def get_all_rectangles(self) -> list[Bounds]:
|
327
|
+
"""
|
328
|
+
Return all node rectangles in the current quadtree.
|
329
|
+
|
330
|
+
Returns:
|
331
|
+
List of (min_x, min_y, max_x, max_y) for each node in the tree.
|
332
|
+
"""
|
333
|
+
return self._native.get_all_rectangles()
|
334
|
+
|
335
|
+
def get_all_objects(self) -> list[Any]:
|
336
|
+
"""
|
337
|
+
Return all tracked objects.
|
338
|
+
|
339
|
+
Returns:
|
340
|
+
List of objects if tracking is enabled, else an empty list.
|
341
|
+
"""
|
342
|
+
if self._items is None:
|
343
|
+
raise ValueError("Cannot get objects when track_objects=False")
|
344
|
+
return [t.obj for t in self._items.items() if t.obj is not None]
|
345
|
+
|
346
|
+
def get_all_items(self) -> list[Item]:
|
347
|
+
"""
|
348
|
+
Return all tracked items.
|
349
|
+
|
350
|
+
Returns:
|
351
|
+
List of Item if tracking is enabled, else an empty list.
|
352
|
+
"""
|
353
|
+
if self._items is None:
|
354
|
+
raise ValueError("Cannot get items when track_objects=False")
|
355
|
+
return list(self._items.items())
|
356
|
+
|
357
|
+
def count_items(self) -> int:
|
358
|
+
"""
|
359
|
+
Return the number of items stored in the native tree.
|
360
|
+
|
361
|
+
Notes:
|
362
|
+
This calls the native engine and may differ from len(self) if
|
363
|
+
you create multiple wrappers around the same native structure.
|
364
|
+
"""
|
365
|
+
return self._native.count_items()
|
366
|
+
|
367
|
+
def __len__(self) -> int:
|
368
|
+
"""
|
369
|
+
Return the number of successful inserts done via this wrapper.
|
370
|
+
|
371
|
+
Notes:
|
372
|
+
This is the Python-side counter that tracks calls that returned True.
|
373
|
+
use count_items() to get the authoritative native-side count.
|
374
|
+
"""
|
375
|
+
return self._count
|
376
|
+
|
377
|
+
# Power users can access the raw class
|
378
|
+
NativeQuadTree = _RustQuadTree
|
379
|
+
|
380
|
+
|
381
|
+
__all__ = ["Bounds", "Item", "Point", "QuadTree"]
|
@@ -0,0 +1,70 @@
|
|
1
|
+
from typing import (
|
2
|
+
Any,
|
3
|
+
Iterable,
|
4
|
+
Literal as _Literal, # avoid polluting public namespace
|
5
|
+
overload,
|
6
|
+
)
|
7
|
+
|
8
|
+
Bounds = tuple[float, float, float, float]
|
9
|
+
Point = tuple[float, float]
|
10
|
+
|
11
|
+
class Item:
|
12
|
+
id_: int
|
13
|
+
x: float
|
14
|
+
y: float
|
15
|
+
obj: Any | None
|
16
|
+
|
17
|
+
class QuadTree:
|
18
|
+
# Expose the raw native class for power users
|
19
|
+
NativeQuadTree: type
|
20
|
+
|
21
|
+
def __init__(
|
22
|
+
self,
|
23
|
+
bounds: Bounds,
|
24
|
+
capacity: int,
|
25
|
+
*,
|
26
|
+
max_depth: int | None = None,
|
27
|
+
track_objects: bool = False,
|
28
|
+
start_id: int = 1,
|
29
|
+
) -> None: ...
|
30
|
+
|
31
|
+
# Inserts
|
32
|
+
def insert(self, xy: Point, *, id_: int | None = ..., obj: Any = ...) -> int: ...
|
33
|
+
def insert_many_points(self, points: Iterable[Point]) -> int: ...
|
34
|
+
def attach(self, id_: int, obj: Any) -> None: ...
|
35
|
+
|
36
|
+
# Deletions
|
37
|
+
def delete(self, id_: int, xy: Point) -> bool: ...
|
38
|
+
def delete_by_object(self, obj: Any) -> bool: ...
|
39
|
+
|
40
|
+
# Queries
|
41
|
+
@overload
|
42
|
+
def query(
|
43
|
+
self, rect: Bounds, *, as_items: _Literal[False] = ...
|
44
|
+
) -> list[tuple[int, float, float]]: ...
|
45
|
+
@overload
|
46
|
+
def query(self, rect: Bounds, *, as_items: _Literal[True]) -> list[Item]: ...
|
47
|
+
@overload
|
48
|
+
def nearest_neighbor(
|
49
|
+
self, xy: Point, *, as_item: _Literal[False] = ...
|
50
|
+
) -> tuple[int, float, float] | None: ...
|
51
|
+
@overload
|
52
|
+
def nearest_neighbor(
|
53
|
+
self, xy: Point, *, as_item: _Literal[True]
|
54
|
+
) -> Item | None: ...
|
55
|
+
@overload
|
56
|
+
def nearest_neighbors(
|
57
|
+
self, xy: Point, k: int, *, as_items: _Literal[False] = ...
|
58
|
+
) -> list[tuple[int, float, float]]: ...
|
59
|
+
@overload
|
60
|
+
def nearest_neighbors(
|
61
|
+
self, xy: Point, k: int, *, as_items: _Literal[True]
|
62
|
+
) -> list[Item]: ...
|
63
|
+
|
64
|
+
# Misc
|
65
|
+
def get(self, id_: int) -> Any | None: ...
|
66
|
+
def get_all_rectangles(self) -> list[Bounds]: ...
|
67
|
+
def get_all_objects(self) -> list[Any]: ...
|
68
|
+
def get_all_items(self) -> list[Item]: ...
|
69
|
+
def count_items(self) -> int: ...
|
70
|
+
def __len__(self) -> int: ...
|
fastquadtree/_bimap.py
ADDED
@@ -0,0 +1,112 @@
|
|
1
|
+
# _bimap.py
|
2
|
+
from __future__ import annotations
|
3
|
+
|
4
|
+
from typing import Any, Iterable, Iterator
|
5
|
+
|
6
|
+
from ._item import Item
|
7
|
+
|
8
|
+
|
9
|
+
class BiMap:
|
10
|
+
"""
|
11
|
+
Bidirectional map to the same Item:
|
12
|
+
id -> Item
|
13
|
+
obj -> Item (uses object identity)
|
14
|
+
|
15
|
+
Rules:
|
16
|
+
- One-to-one: an id maps to exactly one Item, and an object maps to exactly one Item.
|
17
|
+
- add(item): inserts or replaces both sides so they point to 'item'.
|
18
|
+
- If item.obj is None, only id -> Item is stored.
|
19
|
+
"""
|
20
|
+
|
21
|
+
__slots__ = ("_id_to_item", "_objid_to_item")
|
22
|
+
|
23
|
+
def __init__(self, items: Iterable[Item] | None = None) -> None:
|
24
|
+
self._id_to_item: dict[int, Item] = {}
|
25
|
+
self._objid_to_item: dict[int, Item] = {}
|
26
|
+
if items:
|
27
|
+
for it in items:
|
28
|
+
self.add(it)
|
29
|
+
|
30
|
+
# - core -
|
31
|
+
|
32
|
+
def add(self, item: Item) -> None:
|
33
|
+
"""
|
34
|
+
Insert or replace mapping for this Item.
|
35
|
+
Handles conflicts so that both id and obj point to this exact Item.
|
36
|
+
"""
|
37
|
+
id_ = item.id_
|
38
|
+
obj = item.obj
|
39
|
+
|
40
|
+
# Unlink any old item currently bound to this id
|
41
|
+
old = self._id_to_item.get(id_)
|
42
|
+
if old is not None and old is not item:
|
43
|
+
old_obj = old.obj
|
44
|
+
if old_obj is not None:
|
45
|
+
self._objid_to_item.pop(id(old_obj), None)
|
46
|
+
|
47
|
+
# Unlink any old item currently bound to this obj
|
48
|
+
if obj is not None:
|
49
|
+
prev = self._objid_to_item.get(id(obj))
|
50
|
+
if prev is not None and prev is not item:
|
51
|
+
self._id_to_item.pop(prev.id_, None)
|
52
|
+
|
53
|
+
# Link new
|
54
|
+
self._id_to_item[id_] = item
|
55
|
+
if obj is not None:
|
56
|
+
self._objid_to_item[id(obj)] = item
|
57
|
+
|
58
|
+
def by_id(self, id_: int) -> Item | None:
|
59
|
+
return self._id_to_item.get(id_)
|
60
|
+
|
61
|
+
def by_obj(self, obj: Any) -> Item | None:
|
62
|
+
return self._objid_to_item.get(id(obj))
|
63
|
+
|
64
|
+
def pop_id(self, id_: int) -> Item | None:
|
65
|
+
it = self._id_to_item.pop(id_, None)
|
66
|
+
if it is not None:
|
67
|
+
obj = it.obj
|
68
|
+
if obj is not None:
|
69
|
+
self._objid_to_item.pop(id(obj), None)
|
70
|
+
return it
|
71
|
+
|
72
|
+
def pop_obj(self, obj: Any) -> Item | None:
|
73
|
+
it = self._objid_to_item.pop(id(obj), None)
|
74
|
+
if it is not None:
|
75
|
+
self._id_to_item.pop(it.id_, None)
|
76
|
+
return it
|
77
|
+
|
78
|
+
def pop_item(self, item: Item) -> Item | None:
|
79
|
+
"""
|
80
|
+
Remove this exact Item if present on either side.
|
81
|
+
"""
|
82
|
+
removed = None
|
83
|
+
# Remove by id first
|
84
|
+
removed = self._id_to_item.pop(item.id_)
|
85
|
+
|
86
|
+
# Remove by obj side
|
87
|
+
obj = item.obj
|
88
|
+
if obj is not None:
|
89
|
+
self._objid_to_item.pop(id(obj), None)
|
90
|
+
removed = removed or item
|
91
|
+
return removed
|
92
|
+
|
93
|
+
# - convenience -
|
94
|
+
|
95
|
+
def __len__(self) -> int:
|
96
|
+
return len(self._id_to_item)
|
97
|
+
|
98
|
+
def clear(self) -> None:
|
99
|
+
self._id_to_item.clear()
|
100
|
+
self._objid_to_item.clear()
|
101
|
+
|
102
|
+
def contains_id(self, id_: int) -> bool:
|
103
|
+
return id_ in self._id_to_item
|
104
|
+
|
105
|
+
def contains_obj(self, obj: Any) -> bool:
|
106
|
+
return id(obj) in self._objid_to_item
|
107
|
+
|
108
|
+
def items_by_id(self) -> Iterator[tuple[int, Item]]:
|
109
|
+
return iter(self._id_to_item.items())
|
110
|
+
|
111
|
+
def items(self) -> Iterator[Item]:
|
112
|
+
return iter(self._id_to_item.values())
|
fastquadtree/_item.py
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
# item.py
|
2
|
+
from __future__ import annotations
|
3
|
+
|
4
|
+
from typing import Any
|
5
|
+
|
6
|
+
|
7
|
+
class Item:
|
8
|
+
"""
|
9
|
+
Lightweight view of an index entry.
|
10
|
+
|
11
|
+
Attributes:
|
12
|
+
id_: Integer identifier.
|
13
|
+
x: X coordinate.
|
14
|
+
y: Y coordinate.
|
15
|
+
obj: The attached Python object if available, else None.
|
16
|
+
"""
|
17
|
+
|
18
|
+
__slots__ = ("id_", "obj", "x", "y")
|
19
|
+
|
20
|
+
def __init__(self, id_: int, x: float, y: float, obj: Any | None = None):
|
21
|
+
self.id_ = id_
|
22
|
+
self.x = x
|
23
|
+
self.y = y
|
24
|
+
self.obj = obj
|
Binary file
|
fastquadtree/py.typed
ADDED
File without changes
|
@@ -0,0 +1,303 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: fastquadtree
|
3
|
+
Version: 0.6.1
|
4
|
+
Classifier: Programming Language :: Python :: 3
|
5
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
6
|
+
Classifier: Programming Language :: Rust
|
7
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
8
|
+
Classifier: Operating System :: OS Independent
|
9
|
+
Classifier: Topic :: Scientific/Engineering
|
10
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
11
|
+
Classifier: Topic :: Software Development :: Libraries
|
12
|
+
Classifier: Typing :: Typed
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
14
|
+
Requires-Dist: ruff>=0.6.0 ; extra == 'dev'
|
15
|
+
Requires-Dist: pytest>=7.0 ; extra == 'dev'
|
16
|
+
Requires-Dist: pytest-cov>=4.1 ; extra == 'dev'
|
17
|
+
Requires-Dist: coverage>=7.5 ; extra == 'dev'
|
18
|
+
Requires-Dist: mypy>=1.10 ; extra == 'dev'
|
19
|
+
Requires-Dist: build>=1.2.1 ; extra == 'dev'
|
20
|
+
Provides-Extra: dev
|
21
|
+
License-File: LICENSE
|
22
|
+
Summary: Rust-accelerated quadtree for Python with fast inserts, range queries, and k-NN search.
|
23
|
+
Keywords: quadtree,spatial-index,geometry,rust,pyo3,nearest-neighbor,k-nn
|
24
|
+
Author: Ethan Anderson
|
25
|
+
Requires-Python: >=3.8
|
26
|
+
Description-Content-Type: text/markdown
|
27
|
+
Project-URL: Homepage, https://github.com/Elan456/fastquadtree
|
28
|
+
Project-URL: Repository, https://github.com/Elan456/fastquadtree
|
29
|
+
Project-URL: Issues, https://github.com/Elan456/fastquadtree/issues
|
30
|
+
|
31
|
+
# fastquadtree
|
32
|
+
|
33
|
+
[](https://pypi.org/project/fastquadtree/)
|
34
|
+
[](https://pypi.org/project/fastquadtree/)
|
35
|
+
[](https://pypi.org/project/fastquadtree/#files)
|
36
|
+
[](LICENSE)
|
37
|
+
|
38
|
+
[](https://pepy.tech/projects/fastquadtree)
|
39
|
+
|
40
|
+
[](https://github.com/Elan456/fastquadtree/actions/workflows/ci.yml)
|
41
|
+
[](https://codecov.io/gh/Elan456/fastquadtree)
|
42
|
+
|
43
|
+
[](https://pyo3.rs/)
|
44
|
+
[](https://www.maturin.rs/)
|
45
|
+
[](https://github.com/astral-sh/ruff)
|
46
|
+
|
47
|
+
|
48
|
+
|
49
|
+

|
50
|
+
|
51
|
+
|
52
|
+
Rust-optimized quadtree with a simple Python API.
|
53
|
+
|
54
|
+
- Python package: **`fastquadtree`**
|
55
|
+
- Python ≥ 3.8
|
56
|
+
- Import path: `from fastquadtree import QuadTree`
|
57
|
+
|
58
|
+
## Benchmarks
|
59
|
+
|
60
|
+
fastquadtree **outperforms** all other quadtree Python packages, including the Rtree spatial index.
|
61
|
+
|
62
|
+
### Library comparison
|
63
|
+
|
64
|
+

|
65
|
+

|
66
|
+
|
67
|
+
### Summary (largest dataset, PyQtree baseline)
|
68
|
+
- Points: **250,000**, Queries: **500**
|
69
|
+
--------------------
|
70
|
+
- Fastest total: **fastquadtree** at **0.120 s**
|
71
|
+
|
72
|
+
| Library | Build (s) | Query (s) | Total (s) | Speed vs PyQtree |
|
73
|
+
|---|---:|---:|---:|---:|
|
74
|
+
| fastquadtree | 0.031 | 0.089 | 0.120 | 14.64× |
|
75
|
+
| Shapely STRtree | 0.179 | 0.100 | 0.279 | 6.29× |
|
76
|
+
| nontree-QuadTree | 0.595 | 0.605 | 1.200 | 1.46× |
|
77
|
+
| Rtree | 0.961 | 0.300 | 1.261 | 1.39× |
|
78
|
+
| e-pyquadtree | 1.005 | 0.660 | 1.665 | 1.05× |
|
79
|
+
| PyQtree | 1.492 | 0.263 | 1.755 | 1.00× |
|
80
|
+
| quads | 1.407 | 0.484 | 1.890 | 0.93× |
|
81
|
+
|
82
|
+
#### Benchmark Configuration
|
83
|
+
| Parameter | Value |
|
84
|
+
|---|---:|
|
85
|
+
| Bounds | (0, 0, 1000, 1000) |
|
86
|
+
| Max points per node | 128 |
|
87
|
+
| Max depth | 16 |
|
88
|
+
| Queries per experiment | 500 |
|
89
|
+
|
90
|
+
## Install
|
91
|
+
|
92
|
+
```bash
|
93
|
+
pip install fastquadtree
|
94
|
+
````
|
95
|
+
|
96
|
+
If you are developing locally:
|
97
|
+
|
98
|
+
```bash
|
99
|
+
# optimized dev install
|
100
|
+
maturin develop --release
|
101
|
+
```
|
102
|
+
|
103
|
+
## Quickstart
|
104
|
+
|
105
|
+
```python
|
106
|
+
from fastquadtree import QuadTree
|
107
|
+
|
108
|
+
# Bounds are (min_x, min_y, max_x, max_y)
|
109
|
+
qt = QuadTree(bounds=(0, 0, 1000, 1000), capacity=20) # max_depth is optional
|
110
|
+
|
111
|
+
# Insert points with auto ids
|
112
|
+
id1 = qt.insert((10, 10))
|
113
|
+
id2 = qt.insert((200, 300))
|
114
|
+
id3 = qt.insert((999, 500), id=42) # you can supply your own id
|
115
|
+
|
116
|
+
# Axis-aligned rectangle query
|
117
|
+
hits = qt.query((0, 0, 250, 350)) # returns [(id, x, y), ...] by default
|
118
|
+
print(hits) # e.g. [(1, 10.0, 10.0), (2, 200.0, 300.0)]
|
119
|
+
|
120
|
+
# Nearest neighbor
|
121
|
+
best = qt.nearest_neighbor((210, 310)) # -> (id, x, y) or None
|
122
|
+
print(best)
|
123
|
+
|
124
|
+
# k-nearest neighbors
|
125
|
+
top3 = qt.nearest_neighbors((210, 310), 3)
|
126
|
+
print(top3) # list of up to 3 (id, x, y) tuples
|
127
|
+
|
128
|
+
# Delete items by ID and location
|
129
|
+
deleted = qt.delete(id2, (200, 300)) # True if found and deleted
|
130
|
+
print(f"Deleted: {deleted}")
|
131
|
+
print(f"Remaining items: {qt.count_items()}")
|
132
|
+
|
133
|
+
# For object tracking with track_objects=True
|
134
|
+
qt_tracked = QuadTree((0, 0, 1000, 1000), capacity=4, track_objects=True)
|
135
|
+
player1 = {"name": "Alice", "score": 100}
|
136
|
+
player2 = {"name": "Bob", "score": 200}
|
137
|
+
|
138
|
+
id1 = qt_tracked.insert((50, 50), obj=player1)
|
139
|
+
id2 = qt_tracked.insert((150, 150), obj=player2)
|
140
|
+
|
141
|
+
# Delete by object reference (O(1) lookup!)
|
142
|
+
deleted = qt_tracked.delete_by_object(player1)
|
143
|
+
print(f"Deleted player: {deleted}") # True
|
144
|
+
```
|
145
|
+
|
146
|
+
### Working with Python objects
|
147
|
+
|
148
|
+
You can keep the tree pure and manage your own id → object map, or let the wrapper manage it.
|
149
|
+
|
150
|
+
**Wrapper Managed Objects**
|
151
|
+
|
152
|
+
```python
|
153
|
+
from fastquadtree import QuadTree
|
154
|
+
|
155
|
+
qt = QuadTree((0, 0, 1000, 1000), capacity=16, track_objects=True)
|
156
|
+
|
157
|
+
# Store the object alongside the point
|
158
|
+
qt.insert((25, 40), obj={"name": "apple"})
|
159
|
+
|
160
|
+
# Ask for Item objects within a bounding box
|
161
|
+
items = qt.query((0, 0, 100, 100), as_items=True)
|
162
|
+
for it in items:
|
163
|
+
print(it.id, it.x, it.y, it.obj)
|
164
|
+
```
|
165
|
+
|
166
|
+
You can also attach or replace an object later:
|
167
|
+
|
168
|
+
```python
|
169
|
+
qt.attach(123, my_object) # binds object to id 123
|
170
|
+
```
|
171
|
+
|
172
|
+
## API
|
173
|
+
|
174
|
+
### `QuadTree(bounds, capacity, max_depth=None, track_objects=False, start_id=1)`
|
175
|
+
|
176
|
+
* `bounds` — tuple `(min_x, min_y, max_x, max_y)` defines the 2D area covered by the quadtree
|
177
|
+
* `capacity` — max number of points kept in a leaf before splitting
|
178
|
+
* `max_depth` — optional depth cap. If omitted, the tree can keep splitting as needed
|
179
|
+
* `track_objects` — if `True`, the wrapper maintains an id → object map for convenience.
|
180
|
+
* `start_id` — starting value for auto-assigned ids
|
181
|
+
|
182
|
+
### Core Methods
|
183
|
+
|
184
|
+
Full docs are in the docstrings of the [Python Shim](pysrc/fastquadtree/__init__.py)
|
185
|
+
|
186
|
+
- `insert(xy, *, id=None, obj=None) -> int`
|
187
|
+
|
188
|
+
- `insert_many_points(points) -> int`
|
189
|
+
|
190
|
+
- `query(rect, *, as_items=False) -> list`
|
191
|
+
|
192
|
+
- `nearest_neighbor(xy, *, as_item=False) -> (id, x, y) | Item | None`
|
193
|
+
|
194
|
+
- `nearest_neighbors(xy, k, *, as_items=False) -> list`
|
195
|
+
|
196
|
+
- `delete(id, xy) -> bool`
|
197
|
+
|
198
|
+
- `delete_by_object(obj) -> bool (requires track_objects=True)`
|
199
|
+
|
200
|
+
- `attach(id, obj) -> None (requires track_objects=True)`
|
201
|
+
|
202
|
+
- `count_items() -> int`
|
203
|
+
|
204
|
+
- `get(id) -> object | None`
|
205
|
+
|
206
|
+
- `get_all_rectangles() -> list[tuple] (for visualization)`
|
207
|
+
|
208
|
+
### `Item` (returned when `as_items=True`)
|
209
|
+
|
210
|
+
* Attributes: `id`, `x`, `y`, and a lazy `obj` property
|
211
|
+
* Accessing `obj` performs a dictionary lookup only if tracking is enabled
|
212
|
+
|
213
|
+
### Geometric conventions
|
214
|
+
|
215
|
+
* Rectangles are `(min_x, min_y, max_x, max_y)`.
|
216
|
+
* Containment rule is closed on the min edge and open on the max edge
|
217
|
+
`(x >= min_x and x < max_x and y >= min_y and y < max_y)`.
|
218
|
+
This only matters for points exactly on edges.
|
219
|
+
|
220
|
+
## Performance tips
|
221
|
+
|
222
|
+
* Choose `capacity` so that leaves keep a small batch of points. Typical values are 8 to 64.
|
223
|
+
* If your data is very skewed, set a `max_depth` to prevent long chains.
|
224
|
+
* For fastest local runs, use `maturin develop --release`.
|
225
|
+
* The wrapper keeps Python overhead low: raw tuple results by default, `Item` wrappers only when requested.
|
226
|
+
|
227
|
+
|
228
|
+
### Native vs Shim Benchmark
|
229
|
+
|
230
|
+
**Setup**
|
231
|
+
- Points: 500,000
|
232
|
+
- Queries: 500
|
233
|
+
- Repeats: 5
|
234
|
+
|
235
|
+
**Timing (seconds)**
|
236
|
+
|
237
|
+
| Variant | Build | Query | Total |
|
238
|
+
|---|---:|---:|---:|
|
239
|
+
| Native | 0.483 | 4.380 | 4.863 |
|
240
|
+
| Shim (no map) | 0.668 | 4.167 | 4.835 |
|
241
|
+
| Shim (track+objs) | 1.153 | 4.458 | 5.610 |
|
242
|
+
|
243
|
+
**Overhead vs Native**
|
244
|
+
|
245
|
+
- No map: build 1.38x, query 0.95x, total 0.99x
|
246
|
+
- Track + objs: build 2.39x, query 1.02x, total 1.15x
|
247
|
+
|
248
|
+
### Run benchmarks
|
249
|
+
To run the benchmarks yourself, first install the dependencies:
|
250
|
+
|
251
|
+
```bash
|
252
|
+
pip install -r benchmarks/requirements.txt
|
253
|
+
```
|
254
|
+
|
255
|
+
Then run:
|
256
|
+
|
257
|
+
```bash
|
258
|
+
python benchmarks/cross_library_bench.py
|
259
|
+
python benchmarks/benchmark_native_vs_shim.py
|
260
|
+
```
|
261
|
+
|
262
|
+
## Run Visualizer
|
263
|
+
A visualizer is included to help you understand how the quadtree subdivides space.
|
264
|
+
|
265
|
+
```bash
|
266
|
+
pip install -r interactive/requirements.txt
|
267
|
+
python interactive/interactive_v2.py
|
268
|
+
```
|
269
|
+
|
270
|
+
Check the CLI arguments for the cross-library benchmark in `benchmarks/quadtree_bench/main.py`.
|
271
|
+
|
272
|
+
## FAQ
|
273
|
+
|
274
|
+
**What happens if I insert the same id more than once?**
|
275
|
+
Allowed. For k-nearest, duplicates are de-duplicated by id. For range queries you will see every inserted point.
|
276
|
+
|
277
|
+
**Can I delete items from the quadtree?**
|
278
|
+
Yes! Use `delete(id, xy)` to remove specific items. You must provide both the ID and exact location for precise deletion. This handles cases where multiple items exist at the same location. If you're using `track_objects=True`, you can also use `delete_by_object(obj)` for convenient object-based deletion with O(1) lookup. The tree automatically merges nodes when item counts drop below capacity.
|
279
|
+
|
280
|
+
**Can I store rectangles or circles?**
|
281
|
+
The core stores points. To index objects with extent, insert whatever representative point you choose. For rectangles you can insert centers or build an AABB tree separately.
|
282
|
+
|
283
|
+
**Threading**
|
284
|
+
Use one tree per thread if you need heavy parallel inserts from Python.
|
285
|
+
|
286
|
+
## License
|
287
|
+
|
288
|
+
MIT. See `LICENSE`.
|
289
|
+
|
290
|
+
## Acknowledgments
|
291
|
+
|
292
|
+
* Python libraries compared: [PyQtree], [e-pyquadtree], [Rtree], [nontree], [quads], [Shapely]
|
293
|
+
* Built with [PyO3] and [maturin]
|
294
|
+
|
295
|
+
[PyQtree]: https://pypi.org/project/pyqtree/
|
296
|
+
[e-pyquadtree]: https://pypi.org/project/e-pyquadtree/
|
297
|
+
[PyO3]: https://pyo3.rs/
|
298
|
+
[maturin]: https://www.maturin.rs/
|
299
|
+
[Rtree]: https://pypi.org/project/Rtree/
|
300
|
+
[nontree]: https://pypi.org/project/nontree/
|
301
|
+
[quads]: https://pypi.org/project/quads/
|
302
|
+
[Shapely]: https://pypi.org/project/Shapely/
|
303
|
+
|
@@ -0,0 +1,10 @@
|
|
1
|
+
fastquadtree-0.6.1.dist-info/METADATA,sha256=ttnNs9M-mAPdUlOLj23Bq1Ewa_FwxB_pZDQevu3k1Ho,10474
|
2
|
+
fastquadtree-0.6.1.dist-info/WHEEL,sha256=2vnU-32JXJk0SYcmjtO3vqL3704xvTJTvVuuZZ_A2lA,127
|
3
|
+
fastquadtree-0.6.1.dist-info/licenses/LICENSE,sha256=pRuvcuqIMtEUBMgvP1Bc4fOHydzeuA61c6DQoQ1pb1w,1071
|
4
|
+
fastquadtree/__init__.py,sha256=1hCXNpz85X_UC4JqKj4AseFe0HNMM6yFWH3r8rNgX9U,12054
|
5
|
+
fastquadtree/__init__.pyi,sha256=NkLrhMy9HkcqdeEfmK6D9c01w-chiNope7DA4PyTeNQ,1992
|
6
|
+
fastquadtree/_bimap.py,sha256=pWY4O4BmOa78r4MnBMfpb-hYZ222U8mEMy4pnosgXLQ,3290
|
7
|
+
fastquadtree/_item.py,sha256=9Tk4wUZLFsjIhWALJ9xGW4bknvU_xfy2Na3ixWxh_xI,509
|
8
|
+
fastquadtree/_native.abi3.so,sha256=toVPA2B7hHGYGIxrAZZio_vM-B3SlqqckPBqhALWo7I,442716
|
9
|
+
fastquadtree/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
10
|
+
fastquadtree-0.6.1.dist-info/RECORD,,
|
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2025 Ethan Anderson
|
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.
|