fastquadtree 0.9.1__cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl → 1.0.1__cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 the few native hooks and item builders.
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
- # typed item map if tracking enabled
62
+
64
63
  self._track_objects = bool(track_objects)
65
- self._items: BiMap[ItemType] | None = BiMap() if track_objects else None
66
- self._next_id = int(start_id)
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
- # ---- shared helpers ----
70
+ # ---- internal helper ----
70
71
 
71
- def _alloc_id(self, id_: int | None) -> int:
72
- if id_ is None:
73
- nid = self._next_id
74
- self._next_id += 1
75
- return nid
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
- def insert(self, geom: G, *, id_: int | None = None, obj: Any = None) -> int:
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
- id_: Optional integer id. If None, an auto id is assigned.
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 the point is outside tree bounds.
92
+ ValueError: If geometry is outside the tree bounds.
95
93
  """
96
- use_id = self._alloc_id(id_)
97
- if not self._native.insert(use_id, geom):
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._items is not None:
104
- self._items.add(self._make_item(use_id, geom, obj))
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 use_id
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 items with auto-assigned ids. Faster than inserting one at a time.
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. Either Points (x, y) or Rects (x0, y0, x1, y1) depending on quadtree type.
121
+ geoms: List of geometries.
122
+ objs: Optional list of Python objects aligned with geoms.
115
123
 
116
124
  Returns:
117
- The number of items inserted
125
+ Number of items inserted.
126
+
127
+ Raises:
128
+ ValueError: If any geometry is outside bounds.
118
129
  """
119
- start_id = self._next_id
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
- self._next_id = last_id + 1
126
- if self._items is not None:
127
- for i, id_ in enumerate(range(start_id, last_id + 1)):
128
- self._items.add(self._make_item(id_, geoms[i], None))
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, else False.
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._items is not None:
147
- self._items.pop_id(id_)
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._items is None:
188
+ if self._store is None:
160
189
  raise ValueError("Cannot attach objects when track_objects=False")
161
- it = self._items.by_id(id_)
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._items.add(self._make_item(id_, it.geom, obj)) # type: ignore[attr-defined]
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._items is None:
200
+ if self._store is None:
184
201
  raise ValueError("Cannot delete by object when track_objects=False")
185
- it = self._items.by_obj(obj)
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, *, reset_ids: bool = False) -> None:
207
+ def clear(self) -> None:
192
208
  """
193
- Empty the tree in place, preserving bounds/capacity/max_depth.
209
+ Empty the tree in place, preserving bounds, capacity, and max_depth.
194
210
 
195
- Args:
196
- reset_ids: If True, restart auto-assigned ids from 1.
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._items is not None:
201
- self._items.clear()
202
- if reset_ids:
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._items is None:
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._items.items() if t.obj is not None]
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._items is None:
232
+ if self._store is None:
228
233
  raise ValueError("Cannot get items when track_objects=False")
229
- return list(self._items.items())
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. Great for visualizing the tree structure.
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._items is None:
246
+ if self._store is None:
245
247
  raise ValueError("Cannot get objects when track_objects=False")
246
- item = self._items.by_id(id_)
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
 
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
@@ -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 raw
78
- if self._items is None:
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
- out: list[PointItem] = []
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._items is None:
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._items.by_id(id_)
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._items is None:
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._items.by_id(id_)
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
- >>> spindex = Index(bbox=(0, 0, 100, 100))
35
- >>> spindex.insert('duck', (50, 30, 53, 60))
36
- >>> spindex.insert('cookie', (10, 20, 15, 25))
37
- >>> spindex.insert('python', (40, 50, 95, 90))
38
- >>> results = spindex.intersect((51, 51, 86, 86))
39
- >>> sorted(results)
40
- ['duck', 'python']
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._id_to_obj: dict[int, Any] = {}
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._id_to_obj[id(item)] = item
101
- self._qt.insert(id(item), bbox)
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._qt.delete(id(item), bbox)
114
-
115
- # Pops
116
- self._id_to_obj.pop(id(item), None)
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
- results = self._qt.query(bbox)
130
- # result = (id, x0, y0, x1, y1)
131
- return [self._id_to_obj[result[0]] for result in results]
160
+ result = self._qt.query_ids(bbox)
161
+ # result = [id1, id2, ...]
162
+ return gather_objs(self._objects, result)
@@ -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 raw
79
- if self._items is None:
80
- # Build RectItem without objects
81
- return [
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.9.1
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=xBqaWU3Z-cfW-EFy0ENuEqxgXZXkIHUBkA1cT6FlqGM,127
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=M62NxHShvDaMHVyXSc7zaE-UoFfAyS_biELFMHZYeJI,463388
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=xBqaWU3Z-cfW-EFy0ENuEqxgXZXkIHUBkA1cT6FlqGM,127
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=Dpf7Yo9ThxYSj9y3yyKFTbrde9GMFtaNegUblIuJbEA,460780
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,,