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.
@@ -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
@@ -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)