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,5 @@
1
+ from ._item import Item, PointItem, RectItem
2
+ from .point_quadtree import QuadTree
3
+ from .rect_quadtree import RectQuadTree
4
+
5
+ __all__ = ["Item", "PointItem", "QuadTree", "RectItem", "RectQuadTree"]
@@ -0,0 +1,543 @@
1
+ # _abc_quadtree.py
2
+ from __future__ import annotations
3
+
4
+ import pickle
5
+ from abc import ABC, abstractmethod
6
+ from typing import (
7
+ TYPE_CHECKING,
8
+ Any,
9
+ Generic,
10
+ Iterable,
11
+ Sequence,
12
+ SupportsFloat,
13
+ Tuple,
14
+ TypeVar,
15
+ overload,
16
+ )
17
+
18
+ from ._item import Item # base class for PointItem and RectItem
19
+ from ._obj_store import ObjStore
20
+
21
+ if TYPE_CHECKING:
22
+ from typing import Self # Only in Python 3.11+
23
+
24
+ from numpy.typing import NDArray
25
+
26
+ Bounds = Tuple[SupportsFloat, SupportsFloat, SupportsFloat, SupportsFloat]
27
+
28
+ # Generic parameters
29
+ G = TypeVar("G") # geometry type, e.g. Point or Bounds
30
+ HitT = TypeVar("HitT") # raw native tuple, e.g. (id,x,y) or (id,x0,y0,x1,y1)
31
+ ItemType = TypeVar("ItemType", bound=Item) # e.g. PointItem or RectItem
32
+
33
+ # Quadtree dtype to numpy dtype mapping
34
+ QUADTREE_DTYPE_TO_NP_DTYPE = {
35
+ "f32": "float32",
36
+ "f64": "float64",
37
+ "i32": "int32",
38
+ "i64": "int64",
39
+ }
40
+
41
+
42
+ def _is_np_array(x: Any) -> bool:
43
+ mod = getattr(x.__class__, "__module__", "")
44
+ return mod.startswith("numpy") and hasattr(x, "ndim") and hasattr(x, "shape")
45
+
46
+
47
+ class _BaseQuadTree(Generic[G, HitT, ItemType], ABC):
48
+ """
49
+ Shared logic for Python QuadTree wrappers over native Rust engines.
50
+
51
+ Concrete subclasses must implement:
52
+ - _new_native(bounds, capacity, max_depth)
53
+ - _make_item(id_, geom, obj)
54
+ """
55
+
56
+ __slots__ = (
57
+ "_bounds",
58
+ "_capacity",
59
+ "_count",
60
+ "_dtype",
61
+ "_max_depth",
62
+ "_native",
63
+ "_next_id",
64
+ "_store",
65
+ "_track_objects",
66
+ )
67
+
68
+ # ---- required native hooks ----
69
+
70
+ @abstractmethod
71
+ def _new_native(self, bounds: Bounds, capacity: int, max_depth: int | None) -> Any:
72
+ """Create the native engine instance."""
73
+
74
+ @classmethod
75
+ def _new_native_from_bytes(cls, data: bytes, dtype: str) -> Any:
76
+ """Create the native engine instance from serialized bytes."""
77
+
78
+ @staticmethod
79
+ @abstractmethod
80
+ def _make_item(id_: int, geom: G, obj: Any | None) -> ItemType:
81
+ """Build an ItemType from id, geometry, and optional object."""
82
+
83
+ # ---- ctor ----
84
+
85
+ def __init__(
86
+ self,
87
+ bounds: Bounds,
88
+ capacity: int,
89
+ *,
90
+ max_depth: int | None = None,
91
+ track_objects: bool = False,
92
+ dtype: str = "f32",
93
+ ):
94
+ # Handle some bounds validation and list --> tuple conversion
95
+ if type(bounds) is not tuple:
96
+ bounds = tuple(bounds) # pyright: ignore[reportAssignmentType]
97
+ if len(bounds) != 4:
98
+ raise ValueError(
99
+ "bounds must be a tuple of four numeric values (x min, y min, x max, y max)"
100
+ )
101
+
102
+ self._bounds = bounds
103
+
104
+ self._max_depth = max_depth
105
+ self._capacity = capacity
106
+ self._dtype = dtype
107
+ self._native = self._new_native(self._bounds, self._capacity, self._max_depth)
108
+
109
+ self._track_objects = bool(track_objects)
110
+ self._store: ObjStore[ItemType] | None = ObjStore() if track_objects else None
111
+
112
+ # Auto ids when not using ObjStore.free slots
113
+ self._next_id = 0
114
+ self._count = 0
115
+
116
+ # ---- serialization ----
117
+
118
+ def to_dict(self) -> dict[str, Any]:
119
+ """
120
+ Serialize the quadtree to a dict suitable for JSON or other serialization.
121
+
122
+ Returns:
123
+ Includes a binary 'core' key for the native engine state, plus other metadata such as bounds and capacity and the obj store if tracking is enabled.
124
+
125
+ Example:
126
+ ```python
127
+ state = qt.to_dict()
128
+ assert "core" in state and "bounds" in state
129
+ ```
130
+ """
131
+
132
+ core_bytes = self._native.to_bytes()
133
+
134
+ return {
135
+ "core": core_bytes,
136
+ "store": self._store.to_dict() if self._store is not None else None,
137
+ "bounds": self._bounds,
138
+ "capacity": self._capacity,
139
+ "max_depth": self._max_depth,
140
+ "track_objects": self._track_objects,
141
+ "next_id": self._next_id,
142
+ "count": self._count,
143
+ }
144
+
145
+ def to_bytes(self) -> bytes:
146
+ """
147
+ Serialize the quadtree to bytes.
148
+
149
+ Returns:
150
+ Bytes representing the serialized quadtree. Can be saved as a file or loaded with `from_bytes()`.
151
+
152
+ Example:
153
+ ```python
154
+ blob = qt.to_bytes()
155
+ with open("tree.fqt", "wb") as f:
156
+ f.write(blob)
157
+ ```
158
+ """
159
+ return pickle.dumps(self.to_dict())
160
+
161
+ @classmethod
162
+ def from_bytes(cls, data: bytes, dtype: str = "f32") -> Self:
163
+ """
164
+ Deserialize a quadtree from bytes. Specifiy the dtype if the original tree that was serialized used a non-default dtype.
165
+
166
+ Args:
167
+ data: Bytes representing the serialized quadtree from `to_bytes()`.
168
+ dtype: The data type used in the native engine ('f32', 'f64', 'i32', 'i64') when saved to bytes.
169
+
170
+ Returns:
171
+ A new quadtree instance with the same state as when serialized.
172
+
173
+ Example:
174
+ ```python
175
+ blob = qt.to_bytes()
176
+ qt2 = type(qt).from_bytes(blob)
177
+ assert qt2.count_items() == qt.count_items()
178
+ ```
179
+ """
180
+ in_dict = pickle.loads(data)
181
+ core_bytes = in_dict["core"]
182
+ store_dict = in_dict["store"]
183
+
184
+ qt = cls.__new__(cls) # type: ignore[call-arg]
185
+ try:
186
+ qt._native = cls._new_native_from_bytes(core_bytes, dtype=dtype)
187
+ except ValueError as ve:
188
+ raise ValueError(
189
+ "Failed to deserialize quadtree native core. "
190
+ "This may be due to a dtype mismatch. "
191
+ "Ensure the dtype used in from_bytes() matches the original tree. "
192
+ "Error details: " + str(ve)
193
+ ) from ve
194
+
195
+ if store_dict is not None:
196
+ qt._store = ObjStore.from_dict(store_dict, qt._make_item)
197
+ else:
198
+ qt._store = None
199
+
200
+ # Extract bounds, capacity, max_depth from native
201
+ qt._bounds = in_dict["bounds"]
202
+ qt._capacity = in_dict["capacity"]
203
+ qt._max_depth = in_dict["max_depth"]
204
+ qt._next_id = in_dict["next_id"]
205
+ qt._count = in_dict["count"]
206
+ qt._track_objects = in_dict["track_objects"]
207
+
208
+ return qt
209
+
210
+ # ---- internal helper ----
211
+
212
+ def _ids_to_objects(self, ids: Iterable[int]) -> list[Any]:
213
+ """Map ids -> Python objects via ObjStore in a batched way."""
214
+ if self._store is None:
215
+ raise ValueError("Cannot map ids to objects when track_objects=False")
216
+ return self._store.get_many_objects(list(ids))
217
+
218
+ # ---- shared API ----
219
+
220
+ def insert(self, geom: G, *, obj: Any | None = None) -> int:
221
+ """
222
+ Insert a single item.
223
+
224
+ Args:
225
+ geom: Point (x, y) or Rect (x0, y0, x1, y1) depending on quadtree type.
226
+ obj: Optional Python object to associate with id if tracking is enabled.
227
+
228
+ Returns:
229
+ The id used for this insert.
230
+
231
+ Raises:
232
+ ValueError: If geometry is outside the tree bounds.
233
+
234
+ Example:
235
+ ```python
236
+ id0 = point_qt.insert((10.0, 20.0)) # for point trees
237
+ id1 = rect_qt.insert((0.0, 0.0, 5.0, 5.0), obj="box") # for rect trees
238
+ assert isinstance(id0, int) and isinstance(id1, int)
239
+ ```
240
+ """
241
+ if self._store is not None:
242
+ # Reuse a dense free slot if available, else append
243
+ rid = self._store.alloc_id()
244
+ else:
245
+ rid = self._next_id
246
+ self._next_id += 1
247
+
248
+ if not self._native.insert(rid, geom):
249
+ bx0, by0, bx1, by1 = self._bounds
250
+ raise ValueError(
251
+ f"Geometry {geom!r} is outside bounds ({bx0}, {by0}, {bx1}, {by1})"
252
+ )
253
+
254
+ if self._store is not None:
255
+ self._store.add(self._make_item(rid, geom, obj))
256
+
257
+ self._count += 1
258
+ return rid
259
+
260
+ @overload
261
+ def insert_many(self, geoms: Sequence[G], objs: list[Any] | None = None) -> int: ...
262
+ @overload
263
+ def insert_many(
264
+ self, geoms: NDArray[Any], objs: list[Any] | None = None
265
+ ) -> int: ...
266
+ def insert_many(
267
+ self, geoms: NDArray[Any] | Sequence[G], objs: list[Any] | None = None
268
+ ) -> int:
269
+ """
270
+ Bulk insert with auto-assigned contiguous ids. Faster than inserting one-by-one.<br>
271
+ Can accept either a Python sequence of geometries or a NumPy array of shape (N,2) or (N,4) with a dtype that matches the quadtree's dtype.
272
+
273
+ If tracking is enabled, the objects will be bulk stored internally.
274
+ If no objects are provided, the items will have obj=None (if tracking).
275
+
276
+ Args:
277
+ geoms: List of geometries.
278
+ objs: Optional list of Python objects aligned with geoms.
279
+
280
+ Returns:
281
+ Number of items inserted.
282
+
283
+ Raises:
284
+ ValueError: If any geometry is outside bounds.
285
+
286
+ Example:
287
+ ```python
288
+ n = qt.insert_many([(1.0, 1.0), (2.0, 2.0)])
289
+ assert n == 2
290
+
291
+ import numpy as np
292
+ arr = np.array([[3.0, 3.0], [4.0, 4.0]], dtype=np.float32)
293
+ n2 = qt.insert_many(arr)
294
+ assert n2 == 2
295
+ ```
296
+ """
297
+ if type(geoms) is list and len(geoms) == 0:
298
+ return 0
299
+
300
+ if _is_np_array(geoms):
301
+ import numpy as _np
302
+ else:
303
+ _np = None
304
+
305
+ # Early return if the numpy array is empty
306
+ if _np is not None and isinstance(geoms, _np.ndarray):
307
+ if geoms.size == 0:
308
+ return 0
309
+
310
+ # Check if dtype matches quadtree dtype
311
+ expected_np_dtype = QUADTREE_DTYPE_TO_NP_DTYPE.get(self._dtype)
312
+ if geoms.dtype != expected_np_dtype:
313
+ raise TypeError(
314
+ f"Numpy array dtype {geoms.dtype} does not match quadtree dtype {self._dtype}"
315
+ )
316
+
317
+ if self._store is None:
318
+ # Simple contiguous path with native bulk insert
319
+ start_id = self._next_id
320
+
321
+ if _np is not None:
322
+ last_id = self._native.insert_many_np(start_id, geoms)
323
+ else:
324
+ last_id = self._native.insert_many(start_id, geoms)
325
+ num = last_id - start_id + 1
326
+ if num < len(geoms):
327
+ raise ValueError("One or more items are outside tree bounds")
328
+ self._next_id = last_id + 1
329
+ self._count += num
330
+ return num
331
+
332
+ # With tracking enabled:
333
+ start_id = len(self._store._arr) # contiguous tail position
334
+ if _np is not None:
335
+ last_id = self._native.insert_many_np(start_id, geoms)
336
+ else:
337
+ last_id = self._native.insert_many(start_id, geoms)
338
+ num = last_id - start_id + 1
339
+ if num < len(geoms):
340
+ raise ValueError("One or more items are outside tree bounds")
341
+
342
+ # For object tracking, we need the geoms to be a Python list
343
+ if _np is not None:
344
+ geoms = geoms.tolist() # pyright: ignore[reportAttributeAccessIssue]
345
+
346
+ # Function bindings to avoid repeated attribute lookups
347
+ add = self._store.add
348
+ mk = self._make_item
349
+
350
+ # Add items to the store in one pass
351
+ if objs is None:
352
+ for off, geom in enumerate(geoms):
353
+ add(mk(start_id + off, geom, None))
354
+ else:
355
+ if len(objs) != len(geoms):
356
+ raise ValueError("objs length must match geoms length")
357
+ for off, (geom, o) in enumerate(zip(geoms, objs)):
358
+ add(mk(start_id + off, geom, o))
359
+
360
+ # Keep _next_id monotonic for the non-tracking path
361
+ self._next_id = max(self._next_id, last_id + 1)
362
+
363
+ self._count += num
364
+ return num
365
+
366
+ def delete(self, id_: int, geom: G) -> bool:
367
+ """
368
+ Delete an item by id and exact geometry.
369
+
370
+ Args:
371
+ id_: The id of the item to delete.
372
+ geom: The geometry of the item to delete.
373
+
374
+ Returns:
375
+ True if the item was found and deleted.
376
+
377
+ Example:
378
+ ```python
379
+ i = qt.insert((1.0, 2.0))
380
+ ok = qt.delete(i, (1.0, 2.0))
381
+ assert ok is True
382
+ ```
383
+ """
384
+ deleted = self._native.delete(id_, geom)
385
+ if deleted:
386
+ self._count -= 1
387
+ if self._store is not None:
388
+ self._store.pop_id(id_)
389
+ return deleted
390
+
391
+ def attach(self, id_: int, obj: Any) -> None:
392
+ """
393
+ Attach or replace the Python object for an existing id.
394
+ Tracking must be enabled.
395
+
396
+ Args:
397
+ id_: The id of the item to attach the object to.
398
+ obj: The Python object to attach.
399
+
400
+ Example:
401
+ ```python
402
+ i = qt.insert((2.0, 3.0), obj=None)
403
+ qt.attach(i, {"meta": 123})
404
+ assert qt.get(i) == {"meta": 123}
405
+ ```
406
+ """
407
+ if self._store is None:
408
+ raise ValueError("Cannot attach objects when track_objects=False")
409
+ it = self._store.by_id(id_)
410
+ if it is None:
411
+ raise KeyError(f"Id {id_} not found in quadtree")
412
+ # Preserve geometry from existing item
413
+ self._store.add(self._make_item(id_, it.geom, obj)) # type: ignore[attr-defined]
414
+
415
+ def delete_by_object(self, obj: Any) -> bool:
416
+ """
417
+ Delete an item by Python object identity. Tracking must be enabled.
418
+
419
+ Args:
420
+ obj: The Python object to delete.
421
+
422
+ Example:
423
+ ```python
424
+ i = qt.insert((3.0, 4.0), obj="tag")
425
+ ok = qt.delete_by_object("tag")
426
+ assert ok is True
427
+ ```
428
+ """
429
+ if self._store is None:
430
+ raise ValueError("Cannot delete by object when track_objects=False")
431
+ it = self._store.by_obj(obj)
432
+ if it is None:
433
+ return False
434
+ return self.delete(it.id_, it.geom) # type: ignore[arg-type]
435
+
436
+ def clear(self) -> None:
437
+ """
438
+ Empty the tree in place, preserving bounds, capacity, and max_depth.
439
+
440
+ If tracking is enabled, the id -> object mapping is also cleared.
441
+ The ids are reset to start at zero again.
442
+
443
+ Example:
444
+ ```python
445
+ _ = qt.insert((5.0, 6.0))
446
+ qt.clear()
447
+ assert qt.count_items() == 0 and len(qt) == 0
448
+ ```
449
+ """
450
+ self._native = self._new_native(self._bounds, self._capacity, self._max_depth)
451
+ self._count = 0
452
+ if self._store is not None:
453
+ self._store.clear()
454
+ self._next_id = 0
455
+
456
+ def get_all_objects(self) -> list[Any]:
457
+ """
458
+ Return all tracked Python objects in the tree.
459
+
460
+ Example:
461
+ ```python
462
+ _ = qt.insert((7.0, 8.0), obj="a")
463
+ _ = qt.insert((9.0, 1.0), obj="b")
464
+ objs = qt.get_all_objects()
465
+ assert set(objs) == {"a", "b"}
466
+ ```
467
+ """
468
+ if self._store is None:
469
+ raise ValueError("Cannot get objects when track_objects=False")
470
+ return [t.obj for t in self._store.items() if t.obj is not None]
471
+
472
+ def get_all_items(self) -> list[ItemType]:
473
+ """
474
+ Return all Item wrappers in the tree.
475
+
476
+ Example:
477
+ ```python
478
+ _ = qt.insert((1.0, 1.0), obj=None)
479
+ items = qt.get_all_items()
480
+ assert hasattr(items[0], "id_") and hasattr(items[0], "geom")
481
+ ```
482
+ """
483
+ if self._store is None:
484
+ raise ValueError("Cannot get items when track_objects=False")
485
+ return list(self._store.items())
486
+
487
+ def get_all_node_boundaries(self) -> list[Bounds]:
488
+ """
489
+ Return all node boundaries in the tree. Useful for visualization.
490
+
491
+ Example:
492
+ ```python
493
+ bounds = qt.get_all_node_boundaries()
494
+ assert isinstance(bounds, list)
495
+ ```
496
+ """
497
+ return self._native.get_all_node_boundaries()
498
+
499
+ def get(self, id_: int) -> Any | None:
500
+ """
501
+ Return the object associated with id, if tracking is enabled.
502
+
503
+ Example:
504
+ ```python
505
+ i = qt.insert((1.0, 2.0), obj={"k": "v"})
506
+ obj = qt.get(i)
507
+ assert obj == {"k": "v"}
508
+ ```
509
+ """
510
+ if self._store is None:
511
+ raise ValueError("Cannot get objects when track_objects=False")
512
+ item = self._store.by_id(id_)
513
+ return None if item is None else item.obj
514
+
515
+ def count_items(self) -> int:
516
+ """
517
+ Return the number of items currently in the tree (native count).
518
+
519
+ Example:
520
+ ```python
521
+ before = qt.count_items()
522
+ _ = qt.insert((2.0, 2.0))
523
+ assert qt.count_items() == before + 1
524
+ ```
525
+ """
526
+ return self._native.count_items()
527
+
528
+ def get_inner_max_depth(self) -> int:
529
+ """
530
+ Return the maximum depth of the quadtree core.
531
+ Useful if you let the core chose the default max depth based on dtype
532
+ by constructing with max_depth=None.
533
+
534
+ Example:
535
+ ```python
536
+ depth = qt.get_inner_max_depth()
537
+ assert isinstance(depth, int)
538
+ ```
539
+ """
540
+ return self._native.get_max_depth()
541
+
542
+ def __len__(self) -> int:
543
+ return self._count
fastquadtree/_item.py ADDED
@@ -0,0 +1,91 @@
1
+ # item.py
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, SupportsFloat, Tuple
5
+
6
+ Bounds = Tuple[SupportsFloat, SupportsFloat, SupportsFloat, SupportsFloat]
7
+ """Axis-aligned rectangle as (min_x, min_y, max_x, max_y)."""
8
+
9
+ Point = Tuple[SupportsFloat, SupportsFloat]
10
+ """2D point as (x, y)."""
11
+
12
+
13
+ class Item:
14
+ """
15
+ Lightweight view of an index entry.
16
+
17
+ Attributes:
18
+ id_: Integer identifier.
19
+ geom: The geometry, either a Point or Rectangle Bounds.
20
+ obj: The attached Python object if available, else None.
21
+ """
22
+
23
+ __slots__ = ("geom", "id_", "obj")
24
+
25
+ def __init__(self, id_: int, geom: Point | Bounds, obj: Any | None = None):
26
+ self.id_: int = id_
27
+ self.geom: Point | Bounds = geom
28
+ self.obj: Any | None = obj
29
+
30
+ def to_dict(self) -> dict[str, Any]:
31
+ """
32
+ Serialize the item to a dictionary.
33
+
34
+ Returns:
35
+ A dictionary with 'id', 'geom', and 'obj' keys.
36
+ """
37
+ return {
38
+ "id": self.id_,
39
+ "geom": self.geom,
40
+ "obj": self.obj,
41
+ }
42
+
43
+ @classmethod
44
+ def from_dict(cls, data: dict[str, Any]) -> Item:
45
+ """
46
+ Deserialize an item from a dictionary.
47
+
48
+ Args:
49
+ data: A dictionary with 'id', 'geom', and 'obj' keys.
50
+
51
+ Returns:
52
+ An Item instance populated with the deserialized data.
53
+ """
54
+ id_ = data["id"]
55
+ geom = data["geom"]
56
+ obj = data["obj"]
57
+ return cls(id_, geom, obj)
58
+
59
+
60
+ class PointItem(Item):
61
+ """
62
+ Lightweight point item wrapper for tracking and as_items results.
63
+
64
+ Attributes:
65
+ id_: Integer identifier.
66
+ geom: The point geometry as (x, y).
67
+ obj: The attached Python object if available, else None.
68
+ """
69
+
70
+ __slots__ = ("x", "y")
71
+
72
+ def __init__(self, id_: int, geom: Point, obj: Any | None = None):
73
+ super().__init__(id_, geom, obj)
74
+ self.x, self.y = geom
75
+
76
+
77
+ class RectItem(Item):
78
+ """
79
+ Lightweight rectangle item wrapper for tracking and as_items results.
80
+
81
+ Attributes:
82
+ id_: Integer identifier.
83
+ geom: The rectangle geometry as (min_x, min_y, max_x, max_y
84
+ obj: The attached Python object if available, else None.
85
+ """
86
+
87
+ __slots__ = ("max_x", "max_y", "min_x", "min_y")
88
+
89
+ def __init__(self, id_: int, geom: Bounds, obj: Any | None = None):
90
+ super().__init__(id_, geom, obj)
91
+ self.min_x, self.min_y, self.max_x, self.max_y = geom
Binary file