fastquadtree 0.9.1__cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl → 1.0.1__cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.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/_base_quadtree.py +101 -106
- fastquadtree/_native.abi3.so +0 -0
- fastquadtree/_obj_store.py +167 -0
- fastquadtree/point_quadtree.py +7 -19
- fastquadtree/pyqtree.py +48 -17
- fastquadtree/rect_quadtree.py +4 -19
- {fastquadtree-0.9.1.dist-info → fastquadtree-1.0.1.dist-info}/METADATA +3 -1
- fastquadtree-1.0.1.dist-info/RECORD +13 -0
- fastquadtree/_bimap.py +0 -112
- fastquadtree-0.9.1.dist-info/RECORD +0 -13
- {fastquadtree-0.9.1.dist-info → fastquadtree-1.0.1.dist-info}/WHEEL +0 -0
- {fastquadtree-0.9.1.dist-info → fastquadtree-1.0.1.dist-info}/licenses/LICENSE +0 -0
fastquadtree/_base_quadtree.py
CHANGED
@@ -2,36 +2,36 @@
|
|
2
2
|
from __future__ import annotations
|
3
3
|
|
4
4
|
from abc import ABC, abstractmethod
|
5
|
-
from typing import Any, Generic, Tuple, TypeVar
|
5
|
+
from typing import Any, Generic, Iterable, Tuple, TypeVar
|
6
6
|
|
7
|
-
from ._bimap import BiMap
|
8
7
|
from ._item import Item # base class for PointItem and RectItem
|
8
|
+
from ._obj_store import ObjStore
|
9
9
|
|
10
10
|
Bounds = Tuple[float, float, float, float]
|
11
11
|
|
12
12
|
# Generic parameters
|
13
13
|
G = TypeVar("G") # geometry type, e.g. Point or Bounds
|
14
14
|
HitT = TypeVar("HitT") # raw native tuple, e.g. (id,x,y) or (id,x0,y0,x1,y1)
|
15
|
-
ItemType = TypeVar(
|
16
|
-
"ItemType", bound=Item
|
17
|
-
) # Python Item subtype, e.g. PointItem or RectItem
|
15
|
+
ItemType = TypeVar("ItemType", bound=Item) # e.g. PointItem or RectItem
|
18
16
|
|
19
17
|
|
20
18
|
class _BaseQuadTree(Generic[G, HitT, ItemType], ABC):
|
21
19
|
"""
|
22
20
|
Shared logic for Python QuadTree wrappers over native Rust engines.
|
23
21
|
|
24
|
-
Concrete subclasses must implement
|
22
|
+
Concrete subclasses must implement:
|
23
|
+
- _new_native(bounds, capacity, max_depth)
|
24
|
+
- _make_item(id_, geom, obj)
|
25
25
|
"""
|
26
26
|
|
27
27
|
__slots__ = (
|
28
28
|
"_bounds",
|
29
29
|
"_capacity",
|
30
30
|
"_count",
|
31
|
-
"_items",
|
32
31
|
"_max_depth",
|
33
32
|
"_native",
|
34
33
|
"_next_id",
|
34
|
+
"_store",
|
35
35
|
"_track_objects",
|
36
36
|
)
|
37
37
|
|
@@ -54,78 +54,115 @@ class _BaseQuadTree(Generic[G, HitT, ItemType], ABC):
|
|
54
54
|
*,
|
55
55
|
max_depth: int | None = None,
|
56
56
|
track_objects: bool = False,
|
57
|
-
start_id: int = 1,
|
58
57
|
):
|
59
58
|
self._bounds = bounds
|
60
59
|
self._max_depth = max_depth
|
61
60
|
self._capacity = capacity
|
62
61
|
self._native = self._new_native(bounds, capacity, max_depth)
|
63
|
-
|
62
|
+
|
64
63
|
self._track_objects = bool(track_objects)
|
65
|
-
self.
|
66
|
-
|
64
|
+
self._store: ObjStore[ItemType] | None = ObjStore() if track_objects else None
|
65
|
+
|
66
|
+
# Auto ids when not using ObjStore.free slots
|
67
|
+
self._next_id = 0
|
67
68
|
self._count = 0
|
68
69
|
|
69
|
-
# ----
|
70
|
+
# ---- internal helper ----
|
70
71
|
|
71
|
-
def
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
if id_ >= self._next_id:
|
77
|
-
self._next_id = id_ + 1
|
78
|
-
return id_
|
72
|
+
def _ids_to_objects(self, ids: Iterable[int]) -> list[Any]:
|
73
|
+
"""Map ids -> Python objects via ObjStore in a batched way."""
|
74
|
+
if self._store is None:
|
75
|
+
raise ValueError("Cannot map ids to objects when track_objects=False")
|
76
|
+
return self._store.get_many_objects(list(ids))
|
79
77
|
|
80
|
-
|
78
|
+
# ---- shared API ----
|
79
|
+
|
80
|
+
def insert(self, geom: G, *, obj: Any | None = None) -> int:
|
81
81
|
"""
|
82
|
-
Insert a single item
|
82
|
+
Insert a single item.
|
83
83
|
|
84
84
|
Args:
|
85
85
|
geom: Point (x, y) or Rect (x0, y0, x1, y1) depending on quadtree type.
|
86
|
-
|
87
|
-
obj: Optional Python object to associate with id. Stored only if
|
88
|
-
object tracking is enabled.
|
86
|
+
obj: Optional Python object to associate with id if tracking is enabled.
|
89
87
|
|
90
88
|
Returns:
|
91
89
|
The id used for this insert.
|
92
90
|
|
93
91
|
Raises:
|
94
|
-
ValueError: If
|
92
|
+
ValueError: If geometry is outside the tree bounds.
|
95
93
|
"""
|
96
|
-
|
97
|
-
|
94
|
+
if self._store is not None:
|
95
|
+
# Reuse a dense free slot if available, else append
|
96
|
+
rid = self._store.alloc_id()
|
97
|
+
else:
|
98
|
+
rid = self._next_id
|
99
|
+
self._next_id += 1
|
100
|
+
|
101
|
+
if not self._native.insert(rid, geom):
|
98
102
|
bx0, by0, bx1, by1 = self._bounds
|
99
103
|
raise ValueError(
|
100
104
|
f"Geometry {geom!r} is outside bounds ({bx0}, {by0}, {bx1}, {by1})"
|
101
105
|
)
|
102
106
|
|
103
|
-
if self.
|
104
|
-
self.
|
107
|
+
if self._store is not None:
|
108
|
+
self._store.add(self._make_item(rid, geom, obj))
|
105
109
|
|
106
110
|
self._count += 1
|
107
|
-
return
|
111
|
+
return rid
|
108
112
|
|
109
|
-
def insert_many(self, geoms: list[G]) -> int:
|
113
|
+
def insert_many(self, geoms: list[G], objs: list[Any] | None = None) -> int:
|
110
114
|
"""
|
111
|
-
Bulk insert
|
115
|
+
Bulk insert with auto-assigned contiguous ids. Faster than inserting one-by-one.<br>
|
116
|
+
|
117
|
+
If tracking is enabled, the objects will be bulk stored internally.
|
118
|
+
If no objects are provided, the items will have obj=None (if tracking).
|
112
119
|
|
113
120
|
Args:
|
114
|
-
geoms: List of geometries.
|
121
|
+
geoms: List of geometries.
|
122
|
+
objs: Optional list of Python objects aligned with geoms.
|
115
123
|
|
116
124
|
Returns:
|
117
|
-
|
125
|
+
Number of items inserted.
|
126
|
+
|
127
|
+
Raises:
|
128
|
+
ValueError: If any geometry is outside bounds.
|
118
129
|
"""
|
119
|
-
|
130
|
+
if not geoms:
|
131
|
+
return 0
|
132
|
+
|
133
|
+
if self._store is None:
|
134
|
+
# Simple contiguous path with native bulk insert
|
135
|
+
start_id = self._next_id
|
136
|
+
last_id = self._native.insert_many(start_id, geoms)
|
137
|
+
num = last_id - start_id + 1
|
138
|
+
if num < len(geoms):
|
139
|
+
raise ValueError("One or more items are outside tree bounds")
|
140
|
+
self._next_id = last_id + 1
|
141
|
+
self._count += num
|
142
|
+
return num
|
143
|
+
|
144
|
+
# With tracking enabled:
|
145
|
+
start_id = len(self._store._arr) # contiguous tail position
|
120
146
|
last_id = self._native.insert_many(start_id, geoms)
|
121
147
|
num = last_id - start_id + 1
|
122
148
|
if num < len(geoms):
|
123
149
|
raise ValueError("One or more items are outside tree bounds")
|
124
150
|
|
125
|
-
|
126
|
-
if
|
127
|
-
for
|
128
|
-
|
151
|
+
# Add items to the store in one pass
|
152
|
+
if objs is None:
|
153
|
+
for off, geom in enumerate(geoms):
|
154
|
+
id_ = start_id + off
|
155
|
+
self._store.add(self._make_item(id_, geom, None))
|
156
|
+
else:
|
157
|
+
if len(objs) != len(geoms):
|
158
|
+
raise ValueError("objs length must match geoms length")
|
159
|
+
for off, (geom, o) in enumerate(zip(geoms, objs)):
|
160
|
+
id_ = start_id + off
|
161
|
+
self._store.add(self._make_item(id_, geom, o))
|
162
|
+
|
163
|
+
# Keep _next_id monotonic for the non-tracking path
|
164
|
+
self._next_id = max(self._next_id, last_id + 1)
|
165
|
+
|
129
166
|
self._count += num
|
130
167
|
return num
|
131
168
|
|
@@ -133,107 +170,72 @@ class _BaseQuadTree(Generic[G, HitT, ItemType], ABC):
|
|
133
170
|
"""
|
134
171
|
Delete an item by id and exact geometry.
|
135
172
|
|
136
|
-
Args:
|
137
|
-
id_: Integer id to remove.
|
138
|
-
geom: Exact geometry to remove. Either Point (x, y) or Rect (x0, y0, x1, y1) depending on quadtree type.
|
139
|
-
|
140
173
|
Returns:
|
141
|
-
True if the item was found and deleted
|
174
|
+
True if the item was found and deleted.
|
142
175
|
"""
|
143
176
|
deleted = self._native.delete(id_, geom)
|
144
177
|
if deleted:
|
145
178
|
self._count -= 1
|
146
|
-
if self.
|
147
|
-
self.
|
179
|
+
if self._store is not None:
|
180
|
+
self._store.pop_id(id_)
|
148
181
|
return deleted
|
149
182
|
|
150
183
|
def attach(self, id_: int, obj: Any) -> None:
|
151
184
|
"""
|
152
185
|
Attach or replace the Python object for an existing id.
|
153
186
|
Tracking must be enabled.
|
154
|
-
|
155
|
-
Args:
|
156
|
-
id_: Target id.
|
157
|
-
obj: Object to associate with id.
|
158
187
|
"""
|
159
|
-
if self.
|
188
|
+
if self._store is None:
|
160
189
|
raise ValueError("Cannot attach objects when track_objects=False")
|
161
|
-
it = self.
|
190
|
+
it = self._store.by_id(id_)
|
162
191
|
if it is None:
|
163
192
|
raise KeyError(f"Id {id_} not found in quadtree")
|
164
193
|
# Preserve geometry from existing item
|
165
|
-
self.
|
194
|
+
self._store.add(self._make_item(id_, it.geom, obj)) # type: ignore[attr-defined]
|
166
195
|
|
167
196
|
def delete_by_object(self, obj: Any) -> bool:
|
168
197
|
"""
|
169
|
-
Delete an item by Python object.
|
170
|
-
|
171
|
-
Requires object tracking to be enabled. Performs an O(1) reverse
|
172
|
-
lookup to get the id, then deletes that entry at the given location.
|
173
|
-
|
174
|
-
Args:
|
175
|
-
obj: The tracked Python object to remove.
|
176
|
-
|
177
|
-
Returns:
|
178
|
-
True if the item was found and deleted, else False.
|
179
|
-
|
180
|
-
Raises:
|
181
|
-
ValueError: If object tracking is disabled.
|
198
|
+
Delete an item by Python object identity. Tracking must be enabled.
|
182
199
|
"""
|
183
|
-
if self.
|
200
|
+
if self._store is None:
|
184
201
|
raise ValueError("Cannot delete by object when track_objects=False")
|
185
|
-
it = self.
|
202
|
+
it = self._store.by_obj(obj)
|
186
203
|
if it is None:
|
187
204
|
return False
|
188
|
-
# type of geom is determined by concrete Item subtype
|
189
205
|
return self.delete(it.id_, it.geom) # type: ignore[arg-type]
|
190
206
|
|
191
|
-
def clear(self
|
207
|
+
def clear(self) -> None:
|
192
208
|
"""
|
193
|
-
Empty the tree in place, preserving bounds
|
209
|
+
Empty the tree in place, preserving bounds, capacity, and max_depth.
|
194
210
|
|
195
|
-
|
196
|
-
|
211
|
+
If tracking is enabled, the id -> object mapping is also cleared.
|
212
|
+
The ids are reset to start at zero again.
|
197
213
|
"""
|
198
214
|
self._native = self._new_native(self._bounds, self._capacity, self._max_depth)
|
199
215
|
self._count = 0
|
200
|
-
if self.
|
201
|
-
self.
|
202
|
-
|
203
|
-
self._next_id = 1
|
216
|
+
if self._store is not None:
|
217
|
+
self._store.clear()
|
218
|
+
self._next_id = 0
|
204
219
|
|
205
220
|
def get_all_objects(self) -> list[Any]:
|
206
221
|
"""
|
207
222
|
Return all tracked Python objects in the tree.
|
208
|
-
|
209
|
-
Returns:
|
210
|
-
List of objects.
|
211
|
-
Raises:
|
212
|
-
ValueError: If object tracking is disabled.
|
213
223
|
"""
|
214
|
-
if self.
|
224
|
+
if self._store is None:
|
215
225
|
raise ValueError("Cannot get objects when track_objects=False")
|
216
|
-
return [t.obj for t in self.
|
226
|
+
return [t.obj for t in self._store.items() if t.obj is not None]
|
217
227
|
|
218
228
|
def get_all_items(self) -> list[ItemType]:
|
219
229
|
"""
|
220
230
|
Return all Item wrappers in the tree.
|
221
|
-
|
222
|
-
Returns:
|
223
|
-
List of Item objects.
|
224
|
-
Raises:
|
225
|
-
ValueError: If object tracking is disabled.
|
226
231
|
"""
|
227
|
-
if self.
|
232
|
+
if self._store is None:
|
228
233
|
raise ValueError("Cannot get items when track_objects=False")
|
229
|
-
return list(self.
|
234
|
+
return list(self._store.items())
|
230
235
|
|
231
236
|
def get_all_node_boundaries(self) -> list[Bounds]:
|
232
237
|
"""
|
233
|
-
Return all node boundaries in the tree.
|
234
|
-
|
235
|
-
Returns:
|
236
|
-
List of (min_x, min_y, max_x, max_y) for each node in the tree.
|
238
|
+
Return all node boundaries in the tree. Useful for visualization.
|
237
239
|
"""
|
238
240
|
return self._native.get_all_node_boundaries()
|
239
241
|
|
@@ -241,21 +243,14 @@ class _BaseQuadTree(Generic[G, HitT, ItemType], ABC):
|
|
241
243
|
"""
|
242
244
|
Return the object associated with id, if tracking is enabled.
|
243
245
|
"""
|
244
|
-
if self.
|
246
|
+
if self._store is None:
|
245
247
|
raise ValueError("Cannot get objects when track_objects=False")
|
246
|
-
item = self.
|
248
|
+
item = self._store.by_id(id_)
|
247
249
|
return None if item is None else item.obj
|
248
250
|
|
249
251
|
def count_items(self) -> int:
|
250
252
|
"""
|
251
|
-
Return the number of items currently in the tree.
|
252
|
-
|
253
|
-
Note:
|
254
|
-
Performs a full scan of tree to count up every item.
|
255
|
-
Use the `len()` function or `len(tree)` for O(1) access.
|
256
|
-
|
257
|
-
Returns:
|
258
|
-
Number of items in the tree.
|
253
|
+
Return the number of items currently in the tree (native count).
|
259
254
|
"""
|
260
255
|
return self._native.count_items()
|
261
256
|
|
fastquadtree/_native.abi3.so
CHANGED
Binary file
|
@@ -0,0 +1,167 @@
|
|
1
|
+
# _bimap.py
|
2
|
+
from __future__ import annotations
|
3
|
+
|
4
|
+
from operator import itemgetter
|
5
|
+
from typing import Any, Generic, Iterable, Iterator, TypeVar
|
6
|
+
|
7
|
+
from ._item import Item # base class for PointItem and RectItem
|
8
|
+
|
9
|
+
TItem = TypeVar("TItem", bound=Item)
|
10
|
+
|
11
|
+
|
12
|
+
class ObjStore(Generic[TItem]):
|
13
|
+
"""
|
14
|
+
High-performance id <-> object store for dense, auto-assigned ids.
|
15
|
+
|
16
|
+
Storage
|
17
|
+
- _arr[id] -> Item or None
|
18
|
+
- _objs[id] -> Python object or None
|
19
|
+
- _obj_to_id: reverse identity map id(obj) -> id
|
20
|
+
- _free: LIFO free-list of reusable ids
|
21
|
+
|
22
|
+
Assumptions
|
23
|
+
- Ids are assigned by the shim and are dense [0..len) with possible holes
|
24
|
+
created by deletes. New inserts reuse holes via the free-list.
|
25
|
+
"""
|
26
|
+
|
27
|
+
__slots__ = ("_arr", "_free", "_len", "_obj_to_id", "_objs")
|
28
|
+
|
29
|
+
def __init__(self, items: Iterable[TItem] | None = None) -> None:
|
30
|
+
self._arr: list[TItem | None] = []
|
31
|
+
self._objs: list[Any | None] = []
|
32
|
+
self._obj_to_id: dict[int, int] = {}
|
33
|
+
self._free: list[int] = [] # LIFO
|
34
|
+
self._len: int = 0 # live items
|
35
|
+
|
36
|
+
if items:
|
37
|
+
for it in items:
|
38
|
+
self.add(it)
|
39
|
+
|
40
|
+
# -------- core --------
|
41
|
+
|
42
|
+
def add(self, item: TItem) -> None:
|
43
|
+
"""
|
44
|
+
Insert or replace the mapping at item.id_. Reverse map updated so obj points to id.
|
45
|
+
"""
|
46
|
+
id_ = item.id_
|
47
|
+
obj = item.obj
|
48
|
+
|
49
|
+
# ids must be dense and assigned by the caller
|
50
|
+
if id_ > len(self._arr):
|
51
|
+
raise AssertionError(
|
52
|
+
"ObjStore.add received an out-of-order id, use alloc_id() to get the next available id"
|
53
|
+
)
|
54
|
+
|
55
|
+
if id_ == len(self._arr):
|
56
|
+
# append
|
57
|
+
self._arr.append(item)
|
58
|
+
self._objs.append(obj)
|
59
|
+
self._len += 1
|
60
|
+
else:
|
61
|
+
# replace or fill a hole
|
62
|
+
old = self._arr[id_]
|
63
|
+
if old is None:
|
64
|
+
self._len += 1
|
65
|
+
elif old.obj is not None:
|
66
|
+
self._obj_to_id.pop(id(old.obj), None)
|
67
|
+
self._arr[id_] = item
|
68
|
+
self._objs[id_] = obj
|
69
|
+
|
70
|
+
if obj is not None:
|
71
|
+
self._obj_to_id[id(obj)] = id_
|
72
|
+
|
73
|
+
def by_id(self, id_: int) -> TItem | None:
|
74
|
+
return self._arr[id_] if 0 <= id_ < len(self._arr) else None
|
75
|
+
|
76
|
+
def by_obj(self, obj: Any) -> TItem | None:
|
77
|
+
id_ = self._obj_to_id.get(id(obj))
|
78
|
+
return self.by_id(id_) if id_ is not None else None
|
79
|
+
|
80
|
+
def pop_id(self, id_: int) -> TItem | None:
|
81
|
+
"""Remove by id. Dense ids go to the free-list for reuse."""
|
82
|
+
if not (0 <= id_ < len(self._arr)):
|
83
|
+
return None
|
84
|
+
it = self._arr[id_]
|
85
|
+
if it is None:
|
86
|
+
return None
|
87
|
+
self._arr[id_] = None
|
88
|
+
self._objs[id_] = None
|
89
|
+
if it.obj is not None:
|
90
|
+
self._obj_to_id.pop(id(it.obj), None)
|
91
|
+
self._free.append(id_)
|
92
|
+
self._len -= 1
|
93
|
+
return it
|
94
|
+
|
95
|
+
# -------- allocation --------
|
96
|
+
|
97
|
+
def alloc_id(self) -> int:
|
98
|
+
"""
|
99
|
+
Get a reusable dense id. Uses free-list else appends at the tail.
|
100
|
+
Build your Item with this id then call add(item).
|
101
|
+
"""
|
102
|
+
return self._free.pop() if self._free else len(self._arr)
|
103
|
+
|
104
|
+
# -------- fast batch gathers --------
|
105
|
+
|
106
|
+
def get_many_by_ids(self, ids: Iterable[int], *, chunk: int = 2048) -> list[TItem]:
|
107
|
+
"""
|
108
|
+
Batch: return Items for ids, preserving order.
|
109
|
+
Uses C-level itemgetter on the dense array in chunks.
|
110
|
+
"""
|
111
|
+
ids_list: list[int] = list(ids)
|
112
|
+
if not ids_list:
|
113
|
+
return []
|
114
|
+
|
115
|
+
out: list[TItem] = []
|
116
|
+
extend = out.extend
|
117
|
+
arr = self._arr
|
118
|
+
for i in range(0, len(ids_list), chunk):
|
119
|
+
block = ids_list[i : i + chunk]
|
120
|
+
vals = itemgetter(*block)(arr) # tuple or single item
|
121
|
+
extend(vals if isinstance(vals, tuple) else (vals,))
|
122
|
+
return out
|
123
|
+
|
124
|
+
def get_many_objects(self, ids: Iterable[int], *, chunk: int = 2048) -> list[Any]:
|
125
|
+
"""
|
126
|
+
Batch: return Python objects for ids, preserving order.
|
127
|
+
Mirrors get_many_by_ids but reads from _objs.
|
128
|
+
"""
|
129
|
+
ids_list: list[int] = list(ids)
|
130
|
+
if not ids_list:
|
131
|
+
return []
|
132
|
+
|
133
|
+
out: list[Any] = []
|
134
|
+
extend = out.extend
|
135
|
+
objs = self._objs
|
136
|
+
for i in range(0, len(ids_list), chunk):
|
137
|
+
block = ids_list[i : i + chunk]
|
138
|
+
vals = itemgetter(*block)(objs) # tuple or single object
|
139
|
+
extend(vals if isinstance(vals, tuple) else (vals,))
|
140
|
+
return out
|
141
|
+
|
142
|
+
# -------- convenience and iteration --------
|
143
|
+
|
144
|
+
def __len__(self) -> int:
|
145
|
+
return self._len
|
146
|
+
|
147
|
+
def clear(self) -> None:
|
148
|
+
self._arr.clear()
|
149
|
+
self._objs.clear()
|
150
|
+
self._obj_to_id.clear()
|
151
|
+
self._free.clear()
|
152
|
+
self._len = 0
|
153
|
+
|
154
|
+
def contains_id(self, id_: int) -> bool:
|
155
|
+
return 0 <= id_ < len(self._arr) and self._arr[id_] is not None
|
156
|
+
|
157
|
+
def contains_obj(self, obj: Any) -> bool:
|
158
|
+
return id(obj) in self._obj_to_id
|
159
|
+
|
160
|
+
def items_by_id(self) -> Iterator[tuple[int, TItem]]:
|
161
|
+
for i, it in enumerate(self._arr):
|
162
|
+
if it is not None:
|
163
|
+
yield i, it
|
164
|
+
|
165
|
+
def items(self) -> Iterator[TItem]:
|
166
|
+
for _, it in self.items_by_id():
|
167
|
+
yield it
|
fastquadtree/point_quadtree.py
CHANGED
@@ -29,7 +29,6 @@ class QuadTree(_BaseQuadTree[Point, _IdCoord, PointItem]):
|
|
29
29
|
capacity: Max number of points per node before splitting.
|
30
30
|
max_depth: Optional max tree depth. If omitted, engine decides.
|
31
31
|
track_objects: Enable id <-> object mapping inside Python.
|
32
|
-
start_id: Starting auto-assigned id when you omit id on insert.
|
33
32
|
|
34
33
|
Raises:
|
35
34
|
ValueError: If parameters are invalid or inserts are out of bounds.
|
@@ -42,14 +41,12 @@ class QuadTree(_BaseQuadTree[Point, _IdCoord, PointItem]):
|
|
42
41
|
*,
|
43
42
|
max_depth: int | None = None,
|
44
43
|
track_objects: bool = False,
|
45
|
-
start_id: int = 1,
|
46
44
|
):
|
47
45
|
super().__init__(
|
48
46
|
bounds,
|
49
47
|
capacity,
|
50
48
|
max_depth=max_depth,
|
51
49
|
track_objects=track_objects,
|
52
|
-
start_id=start_id,
|
53
50
|
)
|
54
51
|
|
55
52
|
@overload
|
@@ -72,20 +69,11 @@ class QuadTree(_BaseQuadTree[Point, _IdCoord, PointItem]):
|
|
72
69
|
If as_items is False: list of (id, x, y) tuples.
|
73
70
|
If as_items is True: list of Item objects.
|
74
71
|
"""
|
75
|
-
raw = self._native.query(rect)
|
76
72
|
if not as_items:
|
77
|
-
return
|
78
|
-
if self.
|
73
|
+
return self._native.query(rect)
|
74
|
+
if self._store is None:
|
79
75
|
raise ValueError("Cannot return results as items with track_objects=False")
|
80
|
-
|
81
|
-
for id_, _x, _y in raw:
|
82
|
-
it = self._items.by_id(id_)
|
83
|
-
if it is None:
|
84
|
-
raise RuntimeError(
|
85
|
-
f"Internal error: id {id_} present in native tree but missing from tracker."
|
86
|
-
)
|
87
|
-
out.append(it)
|
88
|
-
return out
|
76
|
+
return self._store.get_many_by_ids(self._native.query_ids(rect))
|
89
77
|
|
90
78
|
@overload
|
91
79
|
def nearest_neighbor(
|
@@ -111,10 +99,10 @@ class QuadTree(_BaseQuadTree[Point, _IdCoord, PointItem]):
|
|
111
99
|
t = self._native.nearest_neighbor(xy)
|
112
100
|
if t is None or not as_item:
|
113
101
|
return t
|
114
|
-
if self.
|
102
|
+
if self._store is None:
|
115
103
|
raise ValueError("Cannot return result as item with track_objects=False")
|
116
104
|
id_, _x, _y = t
|
117
|
-
it = self.
|
105
|
+
it = self._store.by_id(id_)
|
118
106
|
if it is None:
|
119
107
|
raise RuntimeError("Internal error: missing tracked item")
|
120
108
|
return it
|
@@ -142,11 +130,11 @@ class QuadTree(_BaseQuadTree[Point, _IdCoord, PointItem]):
|
|
142
130
|
raw = self._native.nearest_neighbors(xy, k)
|
143
131
|
if not as_items:
|
144
132
|
return raw
|
145
|
-
if self.
|
133
|
+
if self._store is None:
|
146
134
|
raise ValueError("Cannot return results as items with track_objects=False")
|
147
135
|
out: list[PointItem] = []
|
148
136
|
for id_, _x, _y in raw:
|
149
|
-
it = self.
|
137
|
+
it = self._store.by_id(id_)
|
150
138
|
if it is None:
|
151
139
|
raise RuntimeError("Internal error: missing tracked item")
|
152
140
|
out.append(it)
|
fastquadtree/pyqtree.py
CHANGED
@@ -5,6 +5,7 @@ drop-in replacement to fastquadtree.
|
|
5
5
|
|
6
6
|
from __future__ import annotations
|
7
7
|
|
8
|
+
from operator import itemgetter
|
8
9
|
from typing import Any, Tuple
|
9
10
|
|
10
11
|
from ._native import RectQuadTree
|
@@ -16,11 +17,28 @@ MAX_ITEMS = 10
|
|
16
17
|
MAX_DEPTH = 20
|
17
18
|
|
18
19
|
|
20
|
+
# Helper to gather objects by ids in chunks
|
21
|
+
# Performance improvement over list comprehension for large result sets
|
22
|
+
# 2.945 median query time --> 2.030 median query time (500k items, 500 queries)
|
23
|
+
def gather_objs(objs, ids, chunk=2048):
|
24
|
+
out = []
|
25
|
+
for i in range(0, len(ids), chunk):
|
26
|
+
getter = itemgetter(*ids[i : i + chunk])
|
27
|
+
vals = getter(objs) # tuple or single object
|
28
|
+
if isinstance(vals, tuple):
|
29
|
+
out.extend(vals)
|
30
|
+
else:
|
31
|
+
out.append(vals)
|
32
|
+
return out
|
33
|
+
|
34
|
+
|
19
35
|
class Index:
|
20
36
|
"""
|
21
37
|
The class below is taken from the pyqtree package, but the implementation
|
22
38
|
has been modified to use the fastquadtree package as a backend instead of
|
23
39
|
the original pure-python implementation.
|
40
|
+
Based on the benchmarks, this gives a overall performance boost of 6.514x.
|
41
|
+
See the benchmark section of the docs for more details and the latest numbers.
|
24
42
|
|
25
43
|
|
26
44
|
The top spatial index to be created by the user. Once created it can be
|
@@ -30,16 +48,21 @@ class Index:
|
|
30
48
|
all the quad instances and lets you access their properties.
|
31
49
|
|
32
50
|
Example usage:
|
51
|
+
```python
|
52
|
+
from fastquadtree.pyqtree import Index
|
53
|
+
|
33
54
|
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
55
|
+
spindex = Index(bbox=(0, 0, 100, 100))
|
56
|
+
spindex.insert('duck', (50, 30, 53, 60))
|
57
|
+
spindex.insert('cookie', (10, 20, 15, 25))
|
58
|
+
spindex.insert('python', (40, 50, 95, 90))
|
59
|
+
results = spindex.intersect((51, 51, 86, 86))
|
60
|
+
sorted(results) # ['duck', 'python']
|
61
|
+
```
|
41
62
|
"""
|
42
63
|
|
64
|
+
__slots__ = ("_free", "_item_to_id", "_objects", "_qt")
|
65
|
+
|
43
66
|
def __init__(
|
44
67
|
self,
|
45
68
|
bbox=None,
|
@@ -87,7 +110,9 @@ class Index:
|
|
87
110
|
"Either the bbox argument must be set, or the x, y, width, and height arguments must be set"
|
88
111
|
)
|
89
112
|
|
90
|
-
self.
|
113
|
+
self._objects = []
|
114
|
+
self._free = []
|
115
|
+
self._item_to_id = {}
|
91
116
|
|
92
117
|
def insert(self, item: Any, bbox): # pyright: ignore[reportIncompatibleMethodOverride]
|
93
118
|
"""
|
@@ -97,8 +122,14 @@ class Index:
|
|
97
122
|
- **item**: The item to insert into the index, which will be returned by the intersection method
|
98
123
|
- **bbox**: The spatial bounding box tuple of the item, with four members (xmin,ymin,xmax,ymax)
|
99
124
|
"""
|
100
|
-
self.
|
101
|
-
|
125
|
+
if self._free:
|
126
|
+
rid = self._free.pop()
|
127
|
+
self._objects[rid] = item
|
128
|
+
else:
|
129
|
+
rid = len(self._objects)
|
130
|
+
self._objects.append(item)
|
131
|
+
self._qt.insert(rid, bbox)
|
132
|
+
self._item_to_id[id(item)] = rid
|
102
133
|
|
103
134
|
def remove(self, item, bbox):
|
104
135
|
"""
|
@@ -110,10 +141,10 @@ class Index:
|
|
110
141
|
|
111
142
|
Both parameters need to exactly match the parameters provided to the insert method.
|
112
143
|
"""
|
113
|
-
self.
|
114
|
-
|
115
|
-
|
116
|
-
self.
|
144
|
+
rid = self._item_to_id.pop(id(item))
|
145
|
+
self._qt.delete(rid, bbox)
|
146
|
+
self._objects[rid] = None
|
147
|
+
self._free.append(rid)
|
117
148
|
|
118
149
|
def intersect(self, bbox):
|
119
150
|
"""
|
@@ -126,6 +157,6 @@ class Index:
|
|
126
157
|
Returns:
|
127
158
|
- A list of inserted items whose bounding boxes intersect with the input bbox.
|
128
159
|
"""
|
129
|
-
|
130
|
-
# result =
|
131
|
-
return
|
160
|
+
result = self._qt.query_ids(bbox)
|
161
|
+
# result = [id1, id2, ...]
|
162
|
+
return gather_objs(self._objects, result)
|
fastquadtree/rect_quadtree.py
CHANGED
@@ -30,7 +30,6 @@ class RectQuadTree(_BaseQuadTree[Bounds, _IdRect, RectItem]):
|
|
30
30
|
capacity: Max number of points per node before splitting.
|
31
31
|
max_depth: Optional max tree depth. If omitted, engine decides.
|
32
32
|
track_objects: Enable id <-> object mapping inside Python.
|
33
|
-
start_id: Starting auto-assigned id when you omit id on insert.
|
34
33
|
|
35
34
|
Raises:
|
36
35
|
ValueError: If parameters are invalid or inserts are out of bounds.
|
@@ -43,14 +42,12 @@ class RectQuadTree(_BaseQuadTree[Bounds, _IdRect, RectItem]):
|
|
43
42
|
*,
|
44
43
|
max_depth: int | None = None,
|
45
44
|
track_objects: bool = False,
|
46
|
-
start_id: int = 1,
|
47
45
|
):
|
48
46
|
super().__init__(
|
49
47
|
bounds,
|
50
48
|
capacity,
|
51
49
|
max_depth=max_depth,
|
52
50
|
track_objects=track_objects,
|
53
|
-
start_id=start_id,
|
54
51
|
)
|
55
52
|
|
56
53
|
@overload
|
@@ -73,23 +70,11 @@ class RectQuadTree(_BaseQuadTree[Bounds, _IdRect, RectItem]):
|
|
73
70
|
If as_items is False: list of (id, x0, y0, x1, y1) tuples.
|
74
71
|
If as_items is True: list of Item objects.
|
75
72
|
"""
|
76
|
-
raw = self._native.query(rect)
|
77
73
|
if not as_items:
|
78
|
-
return
|
79
|
-
if self.
|
80
|
-
|
81
|
-
|
82
|
-
RectItem(id_, (x0, y0, x1, y1), None) for (id_, x0, y0, x1, y1) in raw
|
83
|
-
]
|
84
|
-
out: list[RectItem] = []
|
85
|
-
for id_, _x0, _y0, _x1, _y1 in raw:
|
86
|
-
it = self._items.by_id(id_)
|
87
|
-
if it is None:
|
88
|
-
raise RuntimeError(
|
89
|
-
f"Internal error: id {id_} present in native tree but missing from tracker."
|
90
|
-
)
|
91
|
-
out.append(it)
|
92
|
-
return out
|
74
|
+
return self._native.query(rect)
|
75
|
+
if self._store is None:
|
76
|
+
raise ValueError("Cannot return results as items with track_objects=False")
|
77
|
+
return self._store.get_many_by_ids(self._native.query_ids(rect))
|
93
78
|
|
94
79
|
def _new_native(self, bounds: Bounds, capacity: int, max_depth: int | None) -> Any:
|
95
80
|
if max_depth is None:
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: fastquadtree
|
3
|
-
Version: 0.
|
3
|
+
Version: 1.0.1
|
4
4
|
Classifier: Programming Language :: Python :: 3
|
5
5
|
Classifier: Programming Language :: Python :: 3 :: Only
|
6
6
|
Classifier: Programming Language :: Rust
|
@@ -69,6 +69,7 @@ Rust-optimized quadtree with a clean Python API
|
|
69
69
|
- Fast KNN and range queries
|
70
70
|
- Optional object tracking for id ↔ object mapping
|
71
71
|
- [100% test coverage](https://codecov.io/gh/Elan456/fastquadtree) and CI on GitHub Actions
|
72
|
+
- Offers a drop-in [pyqtree shim](https://elan456.github.io/fastquadtree/benchmark/#pyqtree-drop-in-shim-performance-gains) that is 6.567x faster while keeping the same API
|
72
73
|
|
73
74
|
----
|
74
75
|
|
@@ -86,6 +87,7 @@ pip install fastquadtree
|
|
86
87
|
```python
|
87
88
|
from fastquadtree import QuadTree # Point handling
|
88
89
|
from fastquadtree import RectQuadTree # Bounding box handling
|
90
|
+
from fastquadtree.pyqtree import Index # Drop-in pyqtree shim (6.567x faster while keeping the same API)
|
89
91
|
```
|
90
92
|
|
91
93
|
## Benchmarks
|
@@ -0,0 +1,13 @@
|
|
1
|
+
fastquadtree-1.0.1.dist-info/METADATA,sha256=L9xfkCTXamefk_ZfyX8caAg6_Su65hYzQlElY8gV-ws,9367
|
2
|
+
fastquadtree-1.0.1.dist-info/WHEEL,sha256=cqfH6P_NujaeOc1olR46J5a7YgoxWJnrr5iZ1_DMqps,129
|
3
|
+
fastquadtree-1.0.1.dist-info/licenses/LICENSE,sha256=pRuvcuqIMtEUBMgvP1Bc4fOHydzeuA61c6DQoQ1pb1w,1071
|
4
|
+
fastquadtree/__init__.py,sha256=rtkveNz7rScRasTRGu1yEqzeoJfLfreJNxg21orPL-U,195
|
5
|
+
fastquadtree/_base_quadtree.py,sha256=tP6pymgV6s29C_1TjCZ6u0XKb6NFq3tQ7tk6REKS020,8576
|
6
|
+
fastquadtree/_item.py,sha256=0dxvqAMa94Zg-ZjYVMeZJt-irpafalrXph_Qeqp40AU,1348
|
7
|
+
fastquadtree/_native.abi3.so,sha256=MGZCykMCkA4Y_KngL5RlYDV1QhE_AuSPojtbD5JsEKc,470240
|
8
|
+
fastquadtree/_obj_store.py,sha256=vmhZGdzEoTQHvRbFjTne_0X2Z1l48SXyB6I9SAjjbiM,5267
|
9
|
+
fastquadtree/point_quadtree.py,sha256=Pz8ZS7N3kYSYJJYGa3ghKzy7d3JCA1dbi9nfEwwpF_k,5178
|
10
|
+
fastquadtree/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
11
|
+
fastquadtree/pyqtree.py,sha256=W5akt9gua7mr51eI-I1hqbZJMRdcNKWzpeaZhlOvhb0,5781
|
12
|
+
fastquadtree/rect_quadtree.py,sha256=7F-JceCHn5RLhztxSYCIJEZ_e2TV-NeobobbrdauJQA,3024
|
13
|
+
fastquadtree-1.0.1.dist-info/RECORD,,
|
fastquadtree/_bimap.py
DELETED
@@ -1,112 +0,0 @@
|
|
1
|
-
# _bimap.py
|
2
|
-
from __future__ import annotations
|
3
|
-
|
4
|
-
from typing import Any, Generic, Iterable, Iterator, TypeVar
|
5
|
-
|
6
|
-
from ._item import Item # base class for PointItem and RectItem
|
7
|
-
|
8
|
-
TItem = TypeVar("TItem", bound=Item)
|
9
|
-
|
10
|
-
|
11
|
-
class BiMap(Generic[TItem]):
|
12
|
-
"""
|
13
|
-
Bidirectional map to the same Item subtype:
|
14
|
-
id -> Item
|
15
|
-
obj -> Item (uses object identity)
|
16
|
-
|
17
|
-
Rules:
|
18
|
-
- One-to-one: an id maps to exactly one Item, and an object maps to exactly one Item.
|
19
|
-
- add(item): inserts or replaces both sides so they point to 'item'.
|
20
|
-
- If item.obj is None, only id -> Item is stored.
|
21
|
-
"""
|
22
|
-
|
23
|
-
__slots__ = ("_id_to_item", "_objid_to_item")
|
24
|
-
|
25
|
-
def __init__(
|
26
|
-
self,
|
27
|
-
items: Iterable[TItem] | None = None,
|
28
|
-
) -> None:
|
29
|
-
self._id_to_item: dict[int, TItem] = {}
|
30
|
-
self._objid_to_item: dict[int, TItem] = {}
|
31
|
-
if items:
|
32
|
-
for it in items:
|
33
|
-
self.add(it)
|
34
|
-
|
35
|
-
# - core -
|
36
|
-
|
37
|
-
def add(self, item: TItem) -> None:
|
38
|
-
"""
|
39
|
-
Insert or replace mapping for this Item.
|
40
|
-
Handles conflicts so both id and obj point to this exact Item.
|
41
|
-
"""
|
42
|
-
id_ = item.id_
|
43
|
-
obj = item.obj
|
44
|
-
|
45
|
-
# Unlink any old item currently bound to this id
|
46
|
-
old = self._id_to_item.get(id_)
|
47
|
-
if old is not None and old is not item:
|
48
|
-
old_obj = old.obj
|
49
|
-
if old_obj is not None:
|
50
|
-
self._objid_to_item.pop(id(old_obj), None)
|
51
|
-
|
52
|
-
# Unlink any old item currently bound to this obj
|
53
|
-
if obj is not None:
|
54
|
-
prev = self._objid_to_item.get(id(obj))
|
55
|
-
if prev is not None and prev is not item:
|
56
|
-
self._id_to_item.pop(prev.id_, None)
|
57
|
-
|
58
|
-
# Link new
|
59
|
-
self._id_to_item[id_] = item
|
60
|
-
if obj is not None:
|
61
|
-
self._objid_to_item[id(obj)] = item
|
62
|
-
|
63
|
-
def by_id(self, id_: int) -> TItem | None:
|
64
|
-
return self._id_to_item.get(id_)
|
65
|
-
|
66
|
-
def by_obj(self, obj: Any) -> TItem | None:
|
67
|
-
return self._objid_to_item.get(id(obj))
|
68
|
-
|
69
|
-
def pop_id(self, id_: int) -> TItem | None:
|
70
|
-
it = self._id_to_item.pop(id_, None)
|
71
|
-
if it is not None:
|
72
|
-
obj = it.obj
|
73
|
-
if obj is not None:
|
74
|
-
self._objid_to_item.pop(id(obj), None)
|
75
|
-
return it
|
76
|
-
|
77
|
-
def pop_obj(self, obj: Any) -> TItem | None:
|
78
|
-
it = self._objid_to_item.pop(id(obj), None)
|
79
|
-
if it is not None:
|
80
|
-
self._id_to_item.pop(it.id_, None)
|
81
|
-
return it
|
82
|
-
|
83
|
-
def pop_item(self, item: TItem) -> TItem | None:
|
84
|
-
"""
|
85
|
-
Remove this exact Item if present on either side.
|
86
|
-
"""
|
87
|
-
removed = self._id_to_item.pop(item.id_, None)
|
88
|
-
obj = item.obj
|
89
|
-
if obj is not None:
|
90
|
-
self._objid_to_item.pop(id(obj), None)
|
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, TItem]]:
|
109
|
-
return iter(self._id_to_item.items())
|
110
|
-
|
111
|
-
def items(self) -> Iterator[TItem]:
|
112
|
-
return iter(self._id_to_item.values())
|
@@ -1,13 +0,0 @@
|
|
1
|
-
fastquadtree-0.9.1.dist-info/METADATA,sha256=ZUdCmtvzf7fHofYIKv0A6txQ78-ODxVkghW_3Qdpi8w,9088
|
2
|
-
fastquadtree-0.9.1.dist-info/WHEEL,sha256=cqfH6P_NujaeOc1olR46J5a7YgoxWJnrr5iZ1_DMqps,129
|
3
|
-
fastquadtree-0.9.1.dist-info/licenses/LICENSE,sha256=pRuvcuqIMtEUBMgvP1Bc4fOHydzeuA61c6DQoQ1pb1w,1071
|
4
|
-
fastquadtree/__init__.py,sha256=rtkveNz7rScRasTRGu1yEqzeoJfLfreJNxg21orPL-U,195
|
5
|
-
fastquadtree/_base_quadtree.py,sha256=MCXkrEnMqZmYLRAChVsjBu_R9n-bvclPmgmwTscZpzQ,8413
|
6
|
-
fastquadtree/_bimap.py,sha256=7QBJ3QzEQq69PhKNA2W04N37i3-oNleEyb_qSXGSecw,3327
|
7
|
-
fastquadtree/_item.py,sha256=0dxvqAMa94Zg-ZjYVMeZJt-irpafalrXph_Qeqp40AU,1348
|
8
|
-
fastquadtree/_native.abi3.so,sha256=NVl0i8M0DDVWoYT5yqy1wUNaNhsd-i56g874FzsZ2cA,466880
|
9
|
-
fastquadtree/point_quadtree.py,sha256=3bW4Apol-0TKSYF4IJ5KLbn9mhLEFSQT7P-rD9H6vuc,5583
|
10
|
-
fastquadtree/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
11
|
-
fastquadtree/pyqtree.py,sha256=Q2b1Ixc9u4d6_YM6-40i3TSkIo4PxYQdoG96I6TC0A4,4725
|
12
|
-
fastquadtree/rect_quadtree.py,sha256=fVjb5o8kl3s2FwuzpaaMeUV2Ni_h7uxjJNeWdJTNWws,3519
|
13
|
-
fastquadtree-0.9.1.dist-info/RECORD,,
|
File without changes
|
File without changes
|