fastquadtree 1.5.0__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/__init__.py +5 -0
- fastquadtree/_base_quadtree.py +543 -0
- fastquadtree/_item.py +91 -0
- fastquadtree/_native.abi3.so +0 -0
- fastquadtree/_obj_store.py +194 -0
- fastquadtree/point_quadtree.py +206 -0
- fastquadtree/py.typed +0 -0
- fastquadtree/pyqtree.py +176 -0
- fastquadtree/rect_quadtree.py +230 -0
- fastquadtree-1.5.0.dist-info/METADATA +206 -0
- fastquadtree-1.5.0.dist-info/RECORD +13 -0
- fastquadtree-1.5.0.dist-info/WHEEL +5 -0
- fastquadtree-1.5.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# _bimap.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from operator import itemgetter
|
|
5
|
+
from typing import Any, Generic, Iterable, Iterator, Sequence, 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, handle_out_of_order=True)
|
|
39
|
+
|
|
40
|
+
# ---- Serialization ----
|
|
41
|
+
def to_dict(self) -> dict[str, Any]:
|
|
42
|
+
"""
|
|
43
|
+
Serialize to a dict suitable for JSON or other serialization.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
A dict with 'items' key containing list of serialized items.
|
|
47
|
+
"""
|
|
48
|
+
items = [it.to_dict() for it in self._arr if it is not None]
|
|
49
|
+
return {"items": items}
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_dict(cls, data: dict[str, Any], item_factory: Any) -> ObjStore[TItem]:
|
|
53
|
+
"""
|
|
54
|
+
Deserialize from a dict.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
data: A dict with 'items' key containing list of serialized items.
|
|
58
|
+
item_factory: A callable that takes (id, obj) and returns an Item.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
An ObjStore instance populated with the deserialized items.
|
|
62
|
+
"""
|
|
63
|
+
items = []
|
|
64
|
+
for item_data in data.get("items", []):
|
|
65
|
+
item = Item.from_dict(item_data)
|
|
66
|
+
items.append(item_factory(item.id_, item.geom, item.obj))
|
|
67
|
+
return cls(items)
|
|
68
|
+
|
|
69
|
+
# -------- core --------
|
|
70
|
+
|
|
71
|
+
def add(self, item: TItem, handle_out_of_order: bool = False) -> None:
|
|
72
|
+
"""
|
|
73
|
+
Insert or replace the mapping at item.id_. Reverse map updated so obj points to id.
|
|
74
|
+
"""
|
|
75
|
+
id_ = item.id_
|
|
76
|
+
obj = item.obj
|
|
77
|
+
|
|
78
|
+
# ids must be dense and assigned by the caller
|
|
79
|
+
if id_ > len(self._arr):
|
|
80
|
+
if not handle_out_of_order:
|
|
81
|
+
raise AssertionError(
|
|
82
|
+
"ObjStore.add received an out-of-order id, use alloc_id() to get the next available id"
|
|
83
|
+
)
|
|
84
|
+
# fill holes with None
|
|
85
|
+
while len(self._arr) < id_:
|
|
86
|
+
self._arr.append(None)
|
|
87
|
+
self._objs.append(None)
|
|
88
|
+
|
|
89
|
+
if id_ == len(self._arr):
|
|
90
|
+
# append
|
|
91
|
+
self._arr.append(item)
|
|
92
|
+
self._objs.append(obj)
|
|
93
|
+
self._len += 1
|
|
94
|
+
else:
|
|
95
|
+
# replace or fill a hole
|
|
96
|
+
old = self._arr[id_]
|
|
97
|
+
if old is None:
|
|
98
|
+
self._len += 1
|
|
99
|
+
elif old.obj is not None:
|
|
100
|
+
self._obj_to_id.pop(id(old.obj), None)
|
|
101
|
+
self._arr[id_] = item
|
|
102
|
+
self._objs[id_] = obj
|
|
103
|
+
|
|
104
|
+
if obj is not None:
|
|
105
|
+
self._obj_to_id[id(obj)] = id_
|
|
106
|
+
|
|
107
|
+
def by_id(self, id_: int) -> TItem | None:
|
|
108
|
+
return self._arr[id_] if 0 <= id_ < len(self._arr) else None
|
|
109
|
+
|
|
110
|
+
def by_obj(self, obj: Any) -> TItem | None:
|
|
111
|
+
id_ = self._obj_to_id.get(id(obj))
|
|
112
|
+
return self.by_id(id_) if id_ is not None else None
|
|
113
|
+
|
|
114
|
+
def pop_id(self, id_: int) -> TItem | None:
|
|
115
|
+
"""Remove by id. Dense ids go to the free-list for reuse."""
|
|
116
|
+
if not (0 <= id_ < len(self._arr)):
|
|
117
|
+
return None
|
|
118
|
+
it = self._arr[id_]
|
|
119
|
+
if it is None:
|
|
120
|
+
return None
|
|
121
|
+
self._arr[id_] = None
|
|
122
|
+
self._objs[id_] = None
|
|
123
|
+
if it.obj is not None:
|
|
124
|
+
self._obj_to_id.pop(id(it.obj), None)
|
|
125
|
+
self._free.append(id_)
|
|
126
|
+
self._len -= 1
|
|
127
|
+
return it
|
|
128
|
+
|
|
129
|
+
# -------- allocation --------
|
|
130
|
+
|
|
131
|
+
def alloc_id(self) -> int:
|
|
132
|
+
"""
|
|
133
|
+
Get a reusable dense id. Uses free-list else appends at the tail.
|
|
134
|
+
Build your Item with this id then call add(item).
|
|
135
|
+
"""
|
|
136
|
+
return self._free.pop() if self._free else len(self._arr)
|
|
137
|
+
|
|
138
|
+
# -------- fast batch gathers --------
|
|
139
|
+
|
|
140
|
+
def get_many_by_ids(self, ids: Sequence[int], *, chunk: int = 2048) -> list[TItem]:
|
|
141
|
+
"""
|
|
142
|
+
Batch: return Items for ids, preserving order.
|
|
143
|
+
Uses C-level itemgetter on the dense array in chunks.
|
|
144
|
+
"""
|
|
145
|
+
out: list[TItem] = []
|
|
146
|
+
extend = out.extend
|
|
147
|
+
arr = self._arr
|
|
148
|
+
for i in range(0, len(ids), chunk):
|
|
149
|
+
block = ids[i : i + chunk]
|
|
150
|
+
vals = itemgetter(*block)(arr) # tuple or single item
|
|
151
|
+
extend(vals if isinstance(vals, tuple) else (vals,))
|
|
152
|
+
return out
|
|
153
|
+
|
|
154
|
+
def get_many_objects(self, ids: Sequence[int], *, chunk: int = 2048) -> list[Any]:
|
|
155
|
+
"""
|
|
156
|
+
Batch: return Python objects for ids, preserving order.
|
|
157
|
+
Mirrors get_many_by_ids but reads from _objs.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
out: list[Any] = []
|
|
161
|
+
extend = out.extend
|
|
162
|
+
objs = self._objs
|
|
163
|
+
for i in range(0, len(ids), chunk):
|
|
164
|
+
block = ids[i : i + chunk]
|
|
165
|
+
vals = itemgetter(*block)(objs) # tuple or single object
|
|
166
|
+
extend(vals if isinstance(vals, tuple) else (vals,))
|
|
167
|
+
return out
|
|
168
|
+
|
|
169
|
+
# -------- convenience and iteration --------
|
|
170
|
+
|
|
171
|
+
def __len__(self) -> int:
|
|
172
|
+
return self._len
|
|
173
|
+
|
|
174
|
+
def clear(self) -> None:
|
|
175
|
+
self._arr.clear()
|
|
176
|
+
self._objs.clear()
|
|
177
|
+
self._obj_to_id.clear()
|
|
178
|
+
self._free.clear()
|
|
179
|
+
self._len = 0
|
|
180
|
+
|
|
181
|
+
def contains_id(self, id_: int) -> bool:
|
|
182
|
+
return 0 <= id_ < len(self._arr) and self._arr[id_] is not None
|
|
183
|
+
|
|
184
|
+
def contains_obj(self, obj: Any) -> bool:
|
|
185
|
+
return id(obj) in self._obj_to_id
|
|
186
|
+
|
|
187
|
+
def items_by_id(self) -> Iterator[tuple[int, TItem]]:
|
|
188
|
+
for i, it in enumerate(self._arr):
|
|
189
|
+
if it is not None:
|
|
190
|
+
yield i, it
|
|
191
|
+
|
|
192
|
+
def items(self) -> Iterator[TItem]:
|
|
193
|
+
for _, it in self.items_by_id():
|
|
194
|
+
yield it
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# point_quadtree.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Literal, SupportsFloat, Tuple, overload
|
|
5
|
+
|
|
6
|
+
from ._base_quadtree import Bounds, _BaseQuadTree
|
|
7
|
+
from ._item import Point, PointItem
|
|
8
|
+
from ._native import QuadTree as QuadTreeF32, QuadTreeF64, QuadTreeI32, QuadTreeI64
|
|
9
|
+
|
|
10
|
+
_IdCoord = Tuple[int, SupportsFloat, SupportsFloat]
|
|
11
|
+
|
|
12
|
+
DTYPE_MAP = {
|
|
13
|
+
"f32": QuadTreeF32,
|
|
14
|
+
"f64": QuadTreeF64,
|
|
15
|
+
"i32": QuadTreeI32,
|
|
16
|
+
"i64": QuadTreeI64,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class QuadTree(_BaseQuadTree[Point, _IdCoord, PointItem]):
|
|
21
|
+
"""
|
|
22
|
+
Point version of the quadtree. All geometries are 2D points (x, y).
|
|
23
|
+
High-level Python wrapper over the Rust quadtree engine.
|
|
24
|
+
|
|
25
|
+
Performance characteristics:
|
|
26
|
+
Inserts: average O(log n) <br>
|
|
27
|
+
Rect queries: average O(log n + k) where k is matches returned <br>
|
|
28
|
+
Nearest neighbor: average O(log n) <br>
|
|
29
|
+
|
|
30
|
+
Thread-safety:
|
|
31
|
+
Instances are not thread-safe. Use external synchronization if you
|
|
32
|
+
mutate the same tree from multiple threads.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
bounds: World bounds as (min_x, min_y, max_x, max_y).
|
|
36
|
+
capacity: Max number of points per node before splitting.
|
|
37
|
+
max_depth: Optional max tree depth. If omitted, engine decides.
|
|
38
|
+
track_objects: Enable id <-> object mapping inside Python.
|
|
39
|
+
dtype: Data type for coordinates and ids in the native engine. Default is 'f32'. Options are 'f32', 'f64', 'i32', 'i64'.
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
ValueError: If parameters are invalid or inserts are out of bounds.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
bounds: Bounds,
|
|
48
|
+
capacity: int,
|
|
49
|
+
*,
|
|
50
|
+
max_depth: int | None = None,
|
|
51
|
+
track_objects: bool = False,
|
|
52
|
+
dtype: str = "f32",
|
|
53
|
+
):
|
|
54
|
+
super().__init__(
|
|
55
|
+
bounds,
|
|
56
|
+
capacity,
|
|
57
|
+
max_depth=max_depth,
|
|
58
|
+
track_objects=track_objects,
|
|
59
|
+
dtype=dtype,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
@overload
|
|
63
|
+
def query(
|
|
64
|
+
self, rect: Bounds, *, as_items: Literal[False] = ...
|
|
65
|
+
) -> list[_IdCoord]: ...
|
|
66
|
+
@overload
|
|
67
|
+
def query(self, rect: Bounds, *, as_items: Literal[True]) -> list[PointItem]: ...
|
|
68
|
+
def query(
|
|
69
|
+
self, rect: Bounds, *, as_items: bool = False
|
|
70
|
+
) -> list[PointItem] | list[_IdCoord]:
|
|
71
|
+
"""
|
|
72
|
+
Return all points inside an axis-aligned rectangle.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
rect: Query rectangle as (min_x, min_y, max_x, max_y).
|
|
76
|
+
as_items: If True, return Item wrappers. If False, return raw tuples.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
If as_items is False: list of (id, x, y) tuples.
|
|
80
|
+
If as_items is True: list of Item objects.
|
|
81
|
+
|
|
82
|
+
Example:
|
|
83
|
+
```python
|
|
84
|
+
results = qt.query((10.0, 10.0, 20.0, 20.0), as_items=True)
|
|
85
|
+
for item in results:
|
|
86
|
+
print(f"Found point id={item.id_} at {item.geom} with obj={item.obj}")
|
|
87
|
+
```
|
|
88
|
+
"""
|
|
89
|
+
if not as_items:
|
|
90
|
+
return self._native.query(rect)
|
|
91
|
+
if self._store is None:
|
|
92
|
+
raise ValueError("Cannot return results as items with track_objects=False")
|
|
93
|
+
return self._store.get_many_by_ids(self._native.query_ids(rect))
|
|
94
|
+
|
|
95
|
+
def query_np(self, rect: Bounds) -> tuple[Any, Any]:
|
|
96
|
+
"""
|
|
97
|
+
Return all points inside an axis-aligned rectangle as NumPy arrays.
|
|
98
|
+
The first array is an array of IDs, and the second is a corresponding array of point coordinates.
|
|
99
|
+
|
|
100
|
+
Requirements:
|
|
101
|
+
NumPy must be installed to use this method.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
rect: Query rectangle as (min_x, min_y, max_x, max_y).
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Tuple of (ids, locations) where:
|
|
108
|
+
ids: NDArray[np.uint64] with shape (N,)
|
|
109
|
+
locations: NDArray[np.floating] with shape (N, 2)
|
|
110
|
+
|
|
111
|
+
Example:
|
|
112
|
+
```python
|
|
113
|
+
ids, locations = qt.query_np((10.0, 10.0, 20.0, 20.0))
|
|
114
|
+
for id_, (x, y) in zip(ids, locations):
|
|
115
|
+
print(f"Found point id={id_} at ({x}, {y})")
|
|
116
|
+
```
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
return self._native.query_np(rect)
|
|
120
|
+
|
|
121
|
+
@overload
|
|
122
|
+
def nearest_neighbor(
|
|
123
|
+
self, xy: Point, *, as_item: Literal[False] = ...
|
|
124
|
+
) -> _IdCoord | None: ...
|
|
125
|
+
@overload
|
|
126
|
+
def nearest_neighbor(
|
|
127
|
+
self, xy: Point, *, as_item: Literal[True]
|
|
128
|
+
) -> PointItem | None: ...
|
|
129
|
+
def nearest_neighbor(
|
|
130
|
+
self, xy: Point, *, as_item: bool = False
|
|
131
|
+
) -> PointItem | _IdCoord | None:
|
|
132
|
+
"""
|
|
133
|
+
Return the single nearest neighbor to the query point.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
xy: Query point (x, y).
|
|
137
|
+
as_item: If True, return Item. If False, return (id, x, y).
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
The nearest neighbor or None if the tree is empty.
|
|
141
|
+
"""
|
|
142
|
+
t = self._native.nearest_neighbor(xy)
|
|
143
|
+
if t is None or not as_item:
|
|
144
|
+
return t
|
|
145
|
+
if self._store is None:
|
|
146
|
+
raise ValueError("Cannot return result as item with track_objects=False")
|
|
147
|
+
id_, _x, _y = t
|
|
148
|
+
it = self._store.by_id(id_)
|
|
149
|
+
if it is None:
|
|
150
|
+
raise RuntimeError("Internal error: missing tracked item")
|
|
151
|
+
return it
|
|
152
|
+
|
|
153
|
+
@overload
|
|
154
|
+
def nearest_neighbors(
|
|
155
|
+
self, xy: Point, k: int, *, as_items: Literal[False] = ...
|
|
156
|
+
) -> list[_IdCoord]: ...
|
|
157
|
+
@overload
|
|
158
|
+
def nearest_neighbors(
|
|
159
|
+
self, xy: Point, k: int, *, as_items: Literal[True]
|
|
160
|
+
) -> list[PointItem]: ...
|
|
161
|
+
def nearest_neighbors(
|
|
162
|
+
self, xy: Point, k: int, *, as_items: bool = False
|
|
163
|
+
) -> list[PointItem] | list[_IdCoord]:
|
|
164
|
+
"""
|
|
165
|
+
Return the k nearest neighbors to the query point in order of increasing distance.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
xy: Query point (x, y).
|
|
169
|
+
k: Number of neighbors to return.
|
|
170
|
+
as_items: If True, return Item wrappers. If False, return raw tuples.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
If as_items is False: list of (id, x, y) tuples. <br>
|
|
174
|
+
If as_items is True: list of Item objects. <br>
|
|
175
|
+
"""
|
|
176
|
+
raw = self._native.nearest_neighbors(xy, k)
|
|
177
|
+
if not as_items:
|
|
178
|
+
return raw
|
|
179
|
+
if self._store is None:
|
|
180
|
+
raise ValueError("Cannot return results as items with track_objects=False")
|
|
181
|
+
out: list[PointItem] = []
|
|
182
|
+
for id_, _x, _y in raw:
|
|
183
|
+
it = self._store.by_id(id_)
|
|
184
|
+
if it is None:
|
|
185
|
+
raise RuntimeError("Internal error: missing tracked item")
|
|
186
|
+
out.append(it)
|
|
187
|
+
return out
|
|
188
|
+
|
|
189
|
+
def _new_native(self, bounds: Bounds, capacity: int, max_depth: int | None) -> Any:
|
|
190
|
+
"""Create the native engine instance."""
|
|
191
|
+
rust_cls = DTYPE_MAP.get(self._dtype)
|
|
192
|
+
if rust_cls is None:
|
|
193
|
+
raise TypeError(f"Unsupported dtype: {self._dtype}")
|
|
194
|
+
return rust_cls(bounds, capacity, max_depth)
|
|
195
|
+
|
|
196
|
+
@classmethod
|
|
197
|
+
def _new_native_from_bytes(cls, data: bytes, dtype: str = "f32") -> Any:
|
|
198
|
+
"""Create a new native engine instance from serialized bytes."""
|
|
199
|
+
rust_cls = DTYPE_MAP.get(dtype)
|
|
200
|
+
if rust_cls is None:
|
|
201
|
+
raise TypeError(f"Unsupported dtype: {dtype}")
|
|
202
|
+
return rust_cls.from_bytes(data)
|
|
203
|
+
|
|
204
|
+
@staticmethod
|
|
205
|
+
def _make_item(id_: int, geom: Point, obj: Any | None) -> PointItem:
|
|
206
|
+
return PointItem(id_, geom, obj)
|
fastquadtree/py.typed
ADDED
|
File without changes
|
fastquadtree/pyqtree.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Python Shim to mimic the interface of pyqtree and allow for a
|
|
3
|
+
drop-in replacement to fastquadtree.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
from operator import itemgetter
|
|
10
|
+
from typing import Any, SupportsFloat, Tuple
|
|
11
|
+
|
|
12
|
+
from ._native import RectQuadTree
|
|
13
|
+
|
|
14
|
+
Point = Tuple[SupportsFloat, SupportsFloat] # only for type hints in docstrings
|
|
15
|
+
|
|
16
|
+
# Default parameters from pyqtree
|
|
17
|
+
MAX_ITEMS = 10
|
|
18
|
+
MAX_DEPTH = 20
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Helper to gather objects by ids in chunks
|
|
22
|
+
# Performance improvement over list comprehension for large result sets
|
|
23
|
+
# 2.945 median query time --> 2.030 median query time (500k items, 500 queries)
|
|
24
|
+
def gather_objs(objs, ids, chunk=2048):
|
|
25
|
+
out = []
|
|
26
|
+
for i in range(0, len(ids), chunk):
|
|
27
|
+
getter = itemgetter(*ids[i : i + chunk])
|
|
28
|
+
vals = getter(objs) # tuple or single object
|
|
29
|
+
if isinstance(vals, tuple):
|
|
30
|
+
out.extend(vals)
|
|
31
|
+
else:
|
|
32
|
+
out.append(vals)
|
|
33
|
+
return out
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Index:
|
|
37
|
+
"""
|
|
38
|
+
The interface of the class below is taken from the pyqtree package, but the implementation
|
|
39
|
+
has been modified to use the fastquadtree package as a backend instead of
|
|
40
|
+
the original pure-python implementation.
|
|
41
|
+
Based on the benchmarks, this gives a overall performance boost of 6.514x.
|
|
42
|
+
See the benchmark section of the docs for more details and the latest numbers.
|
|
43
|
+
|
|
44
|
+
Index is the top-level class for creating and using a quadtree spatial index
|
|
45
|
+
with the original pyqtree interface. If you are not migrating from pyqtree,
|
|
46
|
+
consider using the RectQuadTree class for detailed control and better performance.
|
|
47
|
+
|
|
48
|
+
This class wraps a RectQuadTree instance and provides methods to insert items with bounding boxes,
|
|
49
|
+
remove items, and query for items intersecting a given bounding box.
|
|
50
|
+
|
|
51
|
+
Example usage:
|
|
52
|
+
```python
|
|
53
|
+
from fastquadtree.pyqtree import Index
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
spindex = Index(bbox=(0, 0, 100, 100))
|
|
57
|
+
spindex.insert('duck', (50, 30, 53, 60))
|
|
58
|
+
spindex.insert('cookie', (10, 20, 15, 25))
|
|
59
|
+
spindex.insert('python', (40, 50, 95, 90))
|
|
60
|
+
results = spindex.intersect((51, 51, 86, 86))
|
|
61
|
+
sorted(results) # ['duck', 'python']
|
|
62
|
+
```
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
__slots__ = ("_free", "_item_to_id", "_objects", "_qt")
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
bbox: Iterable[SupportsFloat] | None = None,
|
|
70
|
+
x: float | int | None = None,
|
|
71
|
+
y: float | int | None = None,
|
|
72
|
+
width: float | int | None = None,
|
|
73
|
+
height: float | int | None = None,
|
|
74
|
+
max_items: int = MAX_ITEMS,
|
|
75
|
+
max_depth: int = MAX_DEPTH,
|
|
76
|
+
):
|
|
77
|
+
"""
|
|
78
|
+
Initiate by specifying either 1) a bbox to keep track of, or 2) with an xy centerpoint and a width and height.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
bbox: The coordinate system bounding box of the area that the quadtree should
|
|
82
|
+
keep track of, as a 4-length sequence (xmin,ymin,xmax,ymax)
|
|
83
|
+
x:
|
|
84
|
+
The x center coordinate of the area that the quadtree should keep track of.
|
|
85
|
+
y:
|
|
86
|
+
The y center coordinate of the area that the quadtree should keep track of.
|
|
87
|
+
width:
|
|
88
|
+
How far from the xcenter that the quadtree should look when keeping track.
|
|
89
|
+
height:
|
|
90
|
+
How far from the ycenter that the quadtree should look when keeping track
|
|
91
|
+
max_items (optional): The maximum number of items allowed per quad before splitting
|
|
92
|
+
up into four new subquads. Default is 10.
|
|
93
|
+
max_depth (optional): The maximum levels of nested subquads, after which no more splitting
|
|
94
|
+
occurs and the bottommost quad nodes may grow indefinately. Default is 20.
|
|
95
|
+
|
|
96
|
+
Note:
|
|
97
|
+
Either the bbox argument must be set, or the x, y, width, and height
|
|
98
|
+
arguments must be set.
|
|
99
|
+
"""
|
|
100
|
+
if bbox is not None:
|
|
101
|
+
x1, y1, x2, y2 = bbox
|
|
102
|
+
self._qt = RectQuadTree((x1, y1, x2, y2), max_items, max_depth=max_depth)
|
|
103
|
+
|
|
104
|
+
elif (
|
|
105
|
+
x is not None and y is not None and width is not None and height is not None
|
|
106
|
+
):
|
|
107
|
+
self._qt = RectQuadTree(
|
|
108
|
+
(x - width / 2, y - height / 2, x + width / 2, y + height / 2),
|
|
109
|
+
max_items,
|
|
110
|
+
max_depth=max_depth,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
else:
|
|
114
|
+
raise ValueError(
|
|
115
|
+
"Either the bbox argument must be set, or the x, y, width, and height arguments must be set"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
self._objects = []
|
|
119
|
+
self._free = []
|
|
120
|
+
self._item_to_id = {}
|
|
121
|
+
|
|
122
|
+
def insert(self, item: Any, bbox: Iterable[SupportsFloat]):
|
|
123
|
+
"""
|
|
124
|
+
Inserts an item into the quadtree along with its bounding box.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
item: The item to insert into the index, which will be returned by the intersection method
|
|
128
|
+
bbox: The spatial bounding box tuple of the item, with four members (xmin,ymin,xmax,ymax)
|
|
129
|
+
"""
|
|
130
|
+
if type(bbox) is not tuple: # Handle non-tuple input
|
|
131
|
+
bbox = tuple(bbox)
|
|
132
|
+
|
|
133
|
+
if self._free:
|
|
134
|
+
rid = self._free.pop()
|
|
135
|
+
self._objects[rid] = item
|
|
136
|
+
else:
|
|
137
|
+
rid = len(self._objects)
|
|
138
|
+
self._objects.append(item)
|
|
139
|
+
self._qt.insert(rid, bbox)
|
|
140
|
+
self._item_to_id[id(item)] = rid
|
|
141
|
+
|
|
142
|
+
def remove(self, item: Any, bbox: Iterable[SupportsFloat]):
|
|
143
|
+
"""
|
|
144
|
+
Removes an item from the quadtree.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
item: The item to remove from the index
|
|
148
|
+
bbox: The spatial bounding box tuple of the item, with four members (xmin,ymin,xmax,ymax)
|
|
149
|
+
|
|
150
|
+
Note:
|
|
151
|
+
Both parameters need to exactly match the parameters provided to the insert method.
|
|
152
|
+
"""
|
|
153
|
+
if type(bbox) is not tuple: # Handle non-tuple input
|
|
154
|
+
bbox = tuple(bbox)
|
|
155
|
+
|
|
156
|
+
rid = self._item_to_id.pop(id(item))
|
|
157
|
+
self._qt.delete(rid, bbox)
|
|
158
|
+
self._objects[rid] = None
|
|
159
|
+
self._free.append(rid)
|
|
160
|
+
|
|
161
|
+
def intersect(self, bbox: Iterable[SupportsFloat]) -> list:
|
|
162
|
+
"""
|
|
163
|
+
Intersects an input bounding box rectangle with all of the items
|
|
164
|
+
contained in the quadtree.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
bbox: A spatial bounding box tuple with four members (xmin,ymin,xmax,ymax)
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
A list of inserted items whose bounding boxes intersect with the input bbox.
|
|
171
|
+
"""
|
|
172
|
+
if type(bbox) is not tuple: # Handle non-tuple input
|
|
173
|
+
bbox = tuple(bbox)
|
|
174
|
+
result = self._qt.query_ids(bbox)
|
|
175
|
+
# result = [id1, id2, ...]
|
|
176
|
+
return gather_objs(self._objects, result)
|