fastquadtree 1.1.2__tar.gz → 1.2.1__tar.gz
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.
Potentially problematic release.
This version of fastquadtree might be problematic. Click here for more details.
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/.gitignore +2 -1
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/Cargo.lock +65 -1
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/Cargo.toml +3 -1
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/PKG-INFO +2 -1
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/README.md +1 -0
- fastquadtree-1.2.1/benchmarks/benchmark_serialization_vs_rebuild.py +149 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/docs/benchmark.md +26 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/docs/future_features.md +1 -1
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/docs/index.md +1 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/interactive/interactive_v2.py +18 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/pysrc/fastquadtree/_base_quadtree.py +180 -1
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/pysrc/fastquadtree/_item.py +29 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/pysrc/fastquadtree/_obj_store.py +39 -5
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/pysrc/fastquadtree/point_quadtree.py +26 -1
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/pysrc/fastquadtree/rect_quadtree.py +26 -1
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/src/geom.rs +4 -2
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/src/lib.rs +35 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/src/quadtree.rs +13 -2
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/src/rect_quadtree.rs +13 -1
- fastquadtree-1.2.1/tests/serialization.rs +61 -0
- fastquadtree-1.2.1/tests/test_serialization.py +114 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/.github/workflows/docs.yml +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/.github/workflows/release.yml +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/.pre-commit-config.yaml +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/LICENSE +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/assets/ballpit.png +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/assets/interactive_v2_rect_screenshot.png +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/assets/interactive_v2_screenshot.png +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/assets/quadtree_bench_throughput.png +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/assets/quadtree_bench_time.png +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/benchmarks/benchmark_native_vs_shim.py +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/benchmarks/benchmark_np_vs_list.py +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/benchmarks/cross_library_bench.py +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/benchmarks/quadtree_bench/__init__.py +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/benchmarks/quadtree_bench/engines.py +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/benchmarks/quadtree_bench/main.py +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/benchmarks/quadtree_bench/plotting.py +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/benchmarks/quadtree_bench/runner.py +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/benchmarks/requirements.txt +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/benchmarks/runner.py +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/benchmarks/system_info_collector.py +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/docs/api/point_item.md +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/docs/api/pyqtree.md +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/docs/api/quadtree.md +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/docs/api/rect_item.md +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/docs/api/rect_quadtree.md +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/docs/quickstart.md +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/docs/runnables.md +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/docs/styles/overrides.css +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/interactive/ballpit.py +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/interactive/interactive.py +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/interactive/interactive_v2_rect.py +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/interactive/requirements.txt +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/mkdocs.yml +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/pyproject.toml +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/pysrc/fastquadtree/__init__.py +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/pysrc/fastquadtree/py.typed +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/pysrc/fastquadtree/pyqtree.py +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/tests/insertions.rs +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/tests/nearest_neighbor.rs +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/tests/query.rs +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/tests/rect_quadtree.rs +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/tests/rectangle_traversal.rs +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/tests/test_base_quadtree.py +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/tests/test_clear.py +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/tests/test_delete.rs +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/tests/test_delete_by_object.py +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/tests/test_delete_python.py +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/tests/test_insert_many_numpy.py +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/tests/test_obj_store.py +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/tests/test_point_quadtree_nn_runtime.py +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/tests/test_pyqtree_shim_compat.py +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/tests/test_python.py +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/tests/test_rect_quadtree.py +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/tests/test_unconventional_bounds.py +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/tests/test_wrapper_edges.py +0 -0
- {fastquadtree-1.1.2 → fastquadtree-1.2.1}/tests/unconventional_bounds.rs +0 -0
|
@@ -8,12 +8,34 @@ version = "1.5.0"
|
|
|
8
8
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
9
9
|
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
|
10
10
|
|
|
11
|
+
[[package]]
|
|
12
|
+
name = "bincode"
|
|
13
|
+
version = "2.0.1"
|
|
14
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
15
|
+
checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740"
|
|
16
|
+
dependencies = [
|
|
17
|
+
"bincode_derive",
|
|
18
|
+
"serde",
|
|
19
|
+
"unty",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[[package]]
|
|
23
|
+
name = "bincode_derive"
|
|
24
|
+
version = "2.0.1"
|
|
25
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
26
|
+
checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09"
|
|
27
|
+
dependencies = [
|
|
28
|
+
"virtue",
|
|
29
|
+
]
|
|
30
|
+
|
|
11
31
|
[[package]]
|
|
12
32
|
name = "fastquadtree"
|
|
13
|
-
version = "1.1
|
|
33
|
+
version = "1.2.1"
|
|
14
34
|
dependencies = [
|
|
35
|
+
"bincode",
|
|
15
36
|
"numpy",
|
|
16
37
|
"pyo3",
|
|
38
|
+
"serde",
|
|
17
39
|
"smallvec",
|
|
18
40
|
]
|
|
19
41
|
|
|
@@ -224,6 +246,36 @@ version = "2.1.1"
|
|
|
224
246
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
225
247
|
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
|
226
248
|
|
|
249
|
+
[[package]]
|
|
250
|
+
name = "serde"
|
|
251
|
+
version = "1.0.228"
|
|
252
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
253
|
+
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
|
254
|
+
dependencies = [
|
|
255
|
+
"serde_core",
|
|
256
|
+
"serde_derive",
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
[[package]]
|
|
260
|
+
name = "serde_core"
|
|
261
|
+
version = "1.0.228"
|
|
262
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
263
|
+
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
|
264
|
+
dependencies = [
|
|
265
|
+
"serde_derive",
|
|
266
|
+
]
|
|
267
|
+
|
|
268
|
+
[[package]]
|
|
269
|
+
name = "serde_derive"
|
|
270
|
+
version = "1.0.228"
|
|
271
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
272
|
+
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
|
273
|
+
dependencies = [
|
|
274
|
+
"proc-macro2",
|
|
275
|
+
"quote",
|
|
276
|
+
"syn",
|
|
277
|
+
]
|
|
278
|
+
|
|
227
279
|
[[package]]
|
|
228
280
|
name = "smallvec"
|
|
229
281
|
version = "1.15.1"
|
|
@@ -258,3 +310,15 @@ name = "unindent"
|
|
|
258
310
|
version = "0.2.4"
|
|
259
311
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
260
312
|
checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
|
|
313
|
+
|
|
314
|
+
[[package]]
|
|
315
|
+
name = "unty"
|
|
316
|
+
version = "0.0.4"
|
|
317
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
318
|
+
checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae"
|
|
319
|
+
|
|
320
|
+
[[package]]
|
|
321
|
+
name = "virtue"
|
|
322
|
+
version = "0.0.18"
|
|
323
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
324
|
+
checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "fastquadtree"
|
|
3
|
-
version = "1.1
|
|
3
|
+
version = "1.2.1"
|
|
4
4
|
edition = "2021"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
|
|
@@ -11,6 +11,8 @@ crate-type = ["rlib", "cdylib"]
|
|
|
11
11
|
pyo3 = { version = "0.26", features = ["extension-module", "abi3-py38"] }
|
|
12
12
|
smallvec = "1.15.1"
|
|
13
13
|
numpy = "0.26"
|
|
14
|
+
serde = { version = "1.0", features = ["derive"] }
|
|
15
|
+
bincode = {version = "2.0.1", features = ["serde"]}
|
|
14
16
|
|
|
15
17
|
[profile.release]
|
|
16
18
|
opt-level = 3
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastquadtree
|
|
3
|
-
Version: 1.1
|
|
3
|
+
Version: 1.2.1
|
|
4
4
|
Classifier: Programming Language :: Python :: 3
|
|
5
5
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
6
6
|
Classifier: Programming Language :: Rust
|
|
@@ -70,6 +70,7 @@ Rust-optimized quadtree with a clean Python API
|
|
|
70
70
|
- Support for [inserting bounding boxes](https://elan456.github.io/fastquadtree/api/rect_quadtree/) or points
|
|
71
71
|
- Fast KNN and range queries
|
|
72
72
|
- Optional object tracking for id ↔ object mapping
|
|
73
|
+
- Fast [serialization](https://elan456.github.io/fastquadtree/benchmark/#serialization-vs-rebuild) to/from bytes
|
|
73
74
|
- [100% test coverage](https://codecov.io/gh/Elan456/fastquadtree) and CI on GitHub Actions
|
|
74
75
|
- 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
|
|
75
76
|
|
|
@@ -30,6 +30,7 @@ Rust-optimized quadtree with a clean Python API
|
|
|
30
30
|
- Support for [inserting bounding boxes](https://elan456.github.io/fastquadtree/api/rect_quadtree/) or points
|
|
31
31
|
- Fast KNN and range queries
|
|
32
32
|
- Optional object tracking for id ↔ object mapping
|
|
33
|
+
- Fast [serialization](https://elan456.github.io/fastquadtree/benchmark/#serialization-vs-rebuild) to/from bytes
|
|
33
34
|
- [100% test coverage](https://codecov.io/gh/Elan456/fastquadtree) and CI on GitHub Actions
|
|
34
35
|
- 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
|
|
35
36
|
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Benchmark: serialization/deserialization vs rebuild from NumPy array
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import gc
|
|
8
|
+
import statistics as stats
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from time import perf_counter as pc
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from fastquadtree import QuadTree
|
|
15
|
+
|
|
16
|
+
CAPACITY = 64
|
|
17
|
+
MAX_DEPTH = 10
|
|
18
|
+
N = 1_000_000
|
|
19
|
+
REPEATS = 7
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def timeit(fn, repeat=REPEATS):
|
|
23
|
+
# warmup
|
|
24
|
+
fn()
|
|
25
|
+
times = []
|
|
26
|
+
gc.disable()
|
|
27
|
+
try:
|
|
28
|
+
for _ in range(repeat):
|
|
29
|
+
t0 = pc()
|
|
30
|
+
fn()
|
|
31
|
+
times.append(pc() - t0)
|
|
32
|
+
finally:
|
|
33
|
+
gc.enable()
|
|
34
|
+
return times
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def make_points():
|
|
38
|
+
rng = np.random.default_rng(42) # seed
|
|
39
|
+
# shape (N, 2), float32 to match native expectations
|
|
40
|
+
return rng.uniform(0.0, 1000.0, size=(N, 2)).astype(np.float32)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def build_original(pts: np.ndarray) -> QuadTree:
|
|
44
|
+
qt = QuadTree((0, 0, 1000, 1000), capacity=CAPACITY, max_depth=MAX_DEPTH)
|
|
45
|
+
qt.insert_many(pts)
|
|
46
|
+
return qt
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main():
|
|
50
|
+
pts = make_points()
|
|
51
|
+
original_qt = build_original(pts)
|
|
52
|
+
|
|
53
|
+
# correctness baseline
|
|
54
|
+
base_count = original_qt.count_items()
|
|
55
|
+
assert base_count == N
|
|
56
|
+
|
|
57
|
+
# serialize timing
|
|
58
|
+
ser_times = timeit(lambda: original_qt.to_bytes())
|
|
59
|
+
qt_bytes = original_qt.to_bytes()
|
|
60
|
+
print(f"Serialized size: {len(qt_bytes):,} bytes")
|
|
61
|
+
|
|
62
|
+
# write once for the file path
|
|
63
|
+
fname = "quadtree_serialization.bin"
|
|
64
|
+
with Path(fname).open("wb") as f:
|
|
65
|
+
f.write(qt_bytes)
|
|
66
|
+
|
|
67
|
+
def rebuild_points():
|
|
68
|
+
qt = QuadTree((0, 0, 1000, 1000), capacity=CAPACITY, max_depth=MAX_DEPTH)
|
|
69
|
+
qt.insert_many(pts)
|
|
70
|
+
assert qt.count_items() == base_count
|
|
71
|
+
_ = qt.query((100, 100, 200, 200))
|
|
72
|
+
return qt
|
|
73
|
+
|
|
74
|
+
def rebuild_from_mem():
|
|
75
|
+
qt = QuadTree.from_bytes(qt_bytes)
|
|
76
|
+
assert qt.count_items() == base_count
|
|
77
|
+
_ = qt.query((100, 100, 200, 200))
|
|
78
|
+
return qt
|
|
79
|
+
|
|
80
|
+
def rebuild_from_file():
|
|
81
|
+
with Path(fname).open("rb") as f:
|
|
82
|
+
data = f.read()
|
|
83
|
+
qt = QuadTree.from_bytes(data)
|
|
84
|
+
assert qt.count_items() == base_count
|
|
85
|
+
_ = qt.query((100, 100, 200, 200))
|
|
86
|
+
return qt
|
|
87
|
+
|
|
88
|
+
t_points = timeit(rebuild_points)
|
|
89
|
+
t_mem = timeit(rebuild_from_mem)
|
|
90
|
+
t_file = timeit(rebuild_from_file)
|
|
91
|
+
|
|
92
|
+
Path(fname).unlink(missing_ok=True)
|
|
93
|
+
|
|
94
|
+
def show(label, arr):
|
|
95
|
+
print(
|
|
96
|
+
f"{label:<28} mean={stats.mean(arr):.6f}s stdev={stats.pstdev(arr):.6f}s "
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
show("serialize to bytes", ser_times)
|
|
100
|
+
show("rebuild from points", t_points)
|
|
101
|
+
show("rebuild from bytes", t_mem)
|
|
102
|
+
show("rebuild from file", t_file)
|
|
103
|
+
|
|
104
|
+
# ----- Markdown summary -----
|
|
105
|
+
def fmt(x: float) -> str:
|
|
106
|
+
return f"{x:.6f}"
|
|
107
|
+
|
|
108
|
+
m_ser = stats.mean(ser_times)
|
|
109
|
+
m_pts = stats.mean(t_points)
|
|
110
|
+
m_mem = stats.mean(t_mem)
|
|
111
|
+
m_file = stats.mean(t_file)
|
|
112
|
+
|
|
113
|
+
s_ser = stats.pstdev(ser_times)
|
|
114
|
+
s_pts = stats.pstdev(t_points)
|
|
115
|
+
s_mem = stats.pstdev(t_mem)
|
|
116
|
+
s_file = stats.pstdev(t_file)
|
|
117
|
+
|
|
118
|
+
speedup_mem = m_pts / m_mem if m_mem > 0 else float("inf")
|
|
119
|
+
speedup_file = m_pts / m_file if m_file > 0 else float("inf")
|
|
120
|
+
|
|
121
|
+
md = f"""
|
|
122
|
+
## Serialization vs Rebuild
|
|
123
|
+
|
|
124
|
+
### Configuration
|
|
125
|
+
- Points: {N:,}
|
|
126
|
+
- Capacity: {CAPACITY}
|
|
127
|
+
- Max depth: {MAX_DEPTH}
|
|
128
|
+
- Repeats: {REPEATS}
|
|
129
|
+
|
|
130
|
+
### Results
|
|
131
|
+
|
|
132
|
+
| Variant | Mean (s) | Stdev (s) |
|
|
133
|
+
|---|---:|---:|
|
|
134
|
+
| Serialize to bytes | {fmt(m_ser)} | {fmt(s_ser)} |
|
|
135
|
+
| Rebuild from points | {fmt(m_pts)} | {fmt(s_pts)} |
|
|
136
|
+
| Rebuild from bytes | {fmt(m_mem)} | {fmt(s_mem)} |
|
|
137
|
+
| Rebuild from file | {fmt(m_file)} | {fmt(s_file)} |
|
|
138
|
+
|
|
139
|
+
### Summary
|
|
140
|
+
|
|
141
|
+
- Rebuild from bytes is **{fmt(speedup_mem)}x** faster than reinserting points.
|
|
142
|
+
- Rebuild from file is **{fmt(speedup_file)}x** faster than reinserting points.
|
|
143
|
+
- Serialized blob size is **{len(qt_bytes):,} bytes**.
|
|
144
|
+
"""
|
|
145
|
+
print(md.strip())
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
main()
|
|
@@ -107,6 +107,31 @@ If your data is already in a NumPy array, using the `insert_many` method directl
|
|
|
107
107
|
|
|
108
108
|
---------
|
|
109
109
|
|
|
110
|
+
## Serialization vs Rebuild
|
|
111
|
+
|
|
112
|
+
### Configuration
|
|
113
|
+
- Points: 1,000,000
|
|
114
|
+
- Capacity: 64
|
|
115
|
+
- Max depth: 10
|
|
116
|
+
- Repeats: 7
|
|
117
|
+
|
|
118
|
+
### Results
|
|
119
|
+
|
|
120
|
+
| Variant | Mean (s) | Stdev (s) |
|
|
121
|
+
|---|---:|---:|
|
|
122
|
+
| Serialize to bytes | 0.021356 | 0.000937 |
|
|
123
|
+
| Rebuild from points | 0.106783 | 0.011430 |
|
|
124
|
+
| Rebuild from bytes | 0.021754 | 0.001687 |
|
|
125
|
+
| Rebuild from file | 0.024887 | 0.001846 |
|
|
126
|
+
|
|
127
|
+
### Summary
|
|
128
|
+
|
|
129
|
+
- Rebuild from bytes is **4.908747x** faster than reinserting points.
|
|
130
|
+
- Rebuild from file is **4.290712x** faster than reinserting points.
|
|
131
|
+
- Serialized blob size is **13,770,328 bytes**.
|
|
132
|
+
|
|
133
|
+
----------------
|
|
134
|
+
|
|
110
135
|
## System Info
|
|
111
136
|
- **OS**: Windows 11 AMD64
|
|
112
137
|
- **Python**: CPython 3.12.2
|
|
@@ -127,6 +152,7 @@ Then run:
|
|
|
127
152
|
python benchmarks/cross_library_bench.py
|
|
128
153
|
python benchmarks/benchmark_native_vs_shim.py
|
|
129
154
|
python benchmarks/benchmark_np_vs_list.py
|
|
155
|
+
python benchmarks/benchmark_serialization_vs_rebuild.py
|
|
130
156
|
```
|
|
131
157
|
|
|
132
158
|
Check the CLI arguments for the cross-library benchmark in `benchmarks/quadtree_bench/main.py`.
|
|
@@ -7,7 +7,7 @@ If you have any suggestions or would like to contribute, please feel free to ope
|
|
|
7
7
|
|
|
8
8
|
## Planned Features
|
|
9
9
|
|
|
10
|
-
### 1. Quadtree serialization
|
|
10
|
+
### 1. [COMPLETE] Quadtree serialization
|
|
11
11
|
|
|
12
12
|
By serializing the quadtree, we can save its state to a file and load it later. This will allow us to persist the quadtree structure and data across sessions. For example, you could pre build a quadtree with all the walls in your video game level, serialize it to a file, and then load it when the game starts. This will heavily reduce the game load time since you won't have to rebuild the quadtree from scratch every time.
|
|
13
13
|
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
- Support for [inserting bounding boxes](api/rect_quadtree.md) or points
|
|
42
42
|
- Fast KNN and range queries
|
|
43
43
|
- Optional object tracking for id ↔ object mapping
|
|
44
|
+
- Fast [serialization](benchmark.md#serialization-vs-rebuild) to/from bytes
|
|
44
45
|
- [100% test coverage](https://codecov.io/gh/Elan456/fastquadtree) and CI on GitHub Actions
|
|
45
46
|
|
|
46
47
|
## Examples
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import math
|
|
2
2
|
import random
|
|
3
3
|
from collections import deque
|
|
4
|
+
from pathlib import Path
|
|
4
5
|
|
|
5
6
|
import pygame
|
|
6
7
|
|
|
@@ -240,6 +241,7 @@ def hud(text_lines):
|
|
|
240
241
|
# ------------------------------
|
|
241
242
|
def handle_events(running, paused, show_nodes, show_nn, show_trails, zoom_target):
|
|
242
243
|
"""Handle discrete events like key presses and mouse clicks."""
|
|
244
|
+
global qtree
|
|
243
245
|
for ev in pygame.event.get():
|
|
244
246
|
if ev.type == pygame.QUIT:
|
|
245
247
|
running = False
|
|
@@ -258,6 +260,21 @@ def handle_events(running, paused, show_nodes, show_nn, show_trails, zoom_target
|
|
|
258
260
|
zoom_target = clamp(zoom_target * ZOOM_FACTOR, ZOOM_MIN, ZOOM_MAX)
|
|
259
261
|
elif ev.key in (pygame.K_MINUS, pygame.K_KP_MINUS):
|
|
260
262
|
zoom_target = clamp(zoom_target / ZOOM_FACTOR, ZOOM_MIN, ZOOM_MAX)
|
|
263
|
+
elif ev.key == pygame.K_c:
|
|
264
|
+
# Save quadtree state
|
|
265
|
+
data = qtree.to_bytes()
|
|
266
|
+
with Path("quadtree_state.bin").open("wb") as f:
|
|
267
|
+
f.write(data)
|
|
268
|
+
print("Quadtree state saved to quadtree_state.bin")
|
|
269
|
+
elif ev.key == pygame.K_v:
|
|
270
|
+
# Load quadtree state
|
|
271
|
+
try:
|
|
272
|
+
with Path("quadtree_state.bin").open("rb") as f:
|
|
273
|
+
data = f.read()
|
|
274
|
+
qtree = QuadTree.from_bytes(data)
|
|
275
|
+
print("Quadtree state loaded from quadtree_state.bin")
|
|
276
|
+
except Exception as e: # noqa: BLE001
|
|
277
|
+
print(f"Failed to load quadtree state: {e}")
|
|
261
278
|
|
|
262
279
|
elif ev.type == pygame.MOUSEWHEEL:
|
|
263
280
|
if ev.y != 0:
|
|
@@ -436,6 +453,7 @@ def main():
|
|
|
436
453
|
f"zoom: {zoom:.2f} target: {zoom_target:.2f}",
|
|
437
454
|
"WASD to pan. Mouse wheel or +/- to zoom.",
|
|
438
455
|
"arrow keys to resize rectangle.",
|
|
456
|
+
"c to save current state, v to load.",
|
|
439
457
|
"1 nodes. 2 NN rays. 3 trails. SPACE pause. L-click add. R-click remove (shift to repeat)",
|
|
440
458
|
]
|
|
441
459
|
)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# _abc_quadtree.py
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import pickle
|
|
4
5
|
from abc import ABC, abstractmethod
|
|
5
6
|
from typing import (
|
|
6
7
|
TYPE_CHECKING,
|
|
@@ -58,8 +59,9 @@ class _BaseQuadTree(Generic[G, HitT, ItemType], ABC):
|
|
|
58
59
|
def _new_native(self, bounds: Bounds, capacity: int, max_depth: int | None) -> Any:
|
|
59
60
|
"""Create the native engine instance."""
|
|
60
61
|
|
|
62
|
+
@staticmethod
|
|
61
63
|
@abstractmethod
|
|
62
|
-
def _make_item(
|
|
64
|
+
def _make_item(id_: int, geom: G, obj: Any | None) -> ItemType:
|
|
63
65
|
"""Build an ItemType from id, geometry, and optional object."""
|
|
64
66
|
|
|
65
67
|
# ---- ctor ----
|
|
@@ -84,6 +86,91 @@ class _BaseQuadTree(Generic[G, HitT, ItemType], ABC):
|
|
|
84
86
|
self._next_id = 0
|
|
85
87
|
self._count = 0
|
|
86
88
|
|
|
89
|
+
# ---- serialization ----
|
|
90
|
+
|
|
91
|
+
def to_dict(self) -> dict[str, Any]:
|
|
92
|
+
"""
|
|
93
|
+
Serialize the quadtree to a dict suitable for JSON or other serialization.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
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.
|
|
97
|
+
|
|
98
|
+
Example:
|
|
99
|
+
```python
|
|
100
|
+
state = qt.to_dict()
|
|
101
|
+
assert "core" in state and "bounds" in state
|
|
102
|
+
```
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
core_bytes = self._native.to_bytes()
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
"core": core_bytes,
|
|
109
|
+
"store": self._store.to_dict() if self._store is not None else None,
|
|
110
|
+
"bounds": self._bounds,
|
|
111
|
+
"capacity": self._capacity,
|
|
112
|
+
"max_depth": self._max_depth,
|
|
113
|
+
"track_objects": self._track_objects,
|
|
114
|
+
"next_id": self._next_id,
|
|
115
|
+
"count": self._count,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
def to_bytes(self) -> bytes:
|
|
119
|
+
"""
|
|
120
|
+
Serialize the quadtree to bytes.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Bytes representing the serialized quadtree. Can be saved as a file or loaded with `from_bytes()`.
|
|
124
|
+
|
|
125
|
+
Example:
|
|
126
|
+
```python
|
|
127
|
+
blob = qt.to_bytes()
|
|
128
|
+
with open("tree.fqt", "wb") as f:
|
|
129
|
+
f.write(blob)
|
|
130
|
+
```
|
|
131
|
+
"""
|
|
132
|
+
return pickle.dumps(self.to_dict())
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def from_bytes(cls, data: bytes) -> _BaseQuadTree[G, HitT, ItemType]:
|
|
136
|
+
"""
|
|
137
|
+
Deserialize a quadtree from bytes.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
data: Bytes representing the serialized quadtree from `to_bytes()`.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
A new quadtree instance with the same state as when serialized.
|
|
144
|
+
|
|
145
|
+
Example:
|
|
146
|
+
```python
|
|
147
|
+
blob = qt.to_bytes()
|
|
148
|
+
qt2 = type(qt).from_bytes(blob)
|
|
149
|
+
assert qt2.count_items() == qt.count_items()
|
|
150
|
+
```
|
|
151
|
+
"""
|
|
152
|
+
in_dict = pickle.loads(data)
|
|
153
|
+
core_bytes = in_dict["core"]
|
|
154
|
+
store_dict = in_dict["store"]
|
|
155
|
+
|
|
156
|
+
qt = cls.__new__(cls) # type: ignore[call-arg]
|
|
157
|
+
qt._native = cls._new_native_from_bytes(core_bytes)
|
|
158
|
+
|
|
159
|
+
if store_dict is not None:
|
|
160
|
+
qt._store = ObjStore.from_dict(store_dict, qt._make_item)
|
|
161
|
+
else:
|
|
162
|
+
qt._store = None
|
|
163
|
+
|
|
164
|
+
# Extract bounds, capacity, max_depth from native
|
|
165
|
+
qt._bounds = in_dict["bounds"]
|
|
166
|
+
qt._capacity = in_dict["capacity"]
|
|
167
|
+
qt._max_depth = in_dict["max_depth"]
|
|
168
|
+
qt._next_id = in_dict["next_id"]
|
|
169
|
+
qt._count = in_dict["count"]
|
|
170
|
+
qt._track_objects = in_dict["track_objects"]
|
|
171
|
+
|
|
172
|
+
return qt
|
|
173
|
+
|
|
87
174
|
# ---- internal helper ----
|
|
88
175
|
|
|
89
176
|
def _ids_to_objects(self, ids: Iterable[int]) -> list[Any]:
|
|
@@ -107,6 +194,13 @@ class _BaseQuadTree(Generic[G, HitT, ItemType], ABC):
|
|
|
107
194
|
|
|
108
195
|
Raises:
|
|
109
196
|
ValueError: If geometry is outside the tree bounds.
|
|
197
|
+
|
|
198
|
+
Example:
|
|
199
|
+
```python
|
|
200
|
+
id0 = point_qt.insert((10.0, 20.0)) # for point trees
|
|
201
|
+
id1 = rect_qt.insert((0.0, 0.0, 5.0, 5.0), obj="box") # for rect trees
|
|
202
|
+
assert isinstance(id0, int) and isinstance(id1, int)
|
|
203
|
+
```
|
|
110
204
|
"""
|
|
111
205
|
if self._store is not None:
|
|
112
206
|
# Reuse a dense free slot if available, else append
|
|
@@ -152,6 +246,17 @@ class _BaseQuadTree(Generic[G, HitT, ItemType], ABC):
|
|
|
152
246
|
|
|
153
247
|
Raises:
|
|
154
248
|
ValueError: If any geometry is outside bounds.
|
|
249
|
+
|
|
250
|
+
Example:
|
|
251
|
+
```python
|
|
252
|
+
n = qt.insert_many([(1.0, 1.0), (2.0, 2.0)])
|
|
253
|
+
assert n == 2
|
|
254
|
+
|
|
255
|
+
import numpy as np
|
|
256
|
+
arr = np.array([[3.0, 3.0], [4.0, 4.0]], dtype=np.float32)
|
|
257
|
+
n2 = qt.insert_many(arr)
|
|
258
|
+
assert n2 == 2
|
|
259
|
+
```
|
|
155
260
|
"""
|
|
156
261
|
if type(geoms) is list and len(geoms) == 0:
|
|
157
262
|
return 0
|
|
@@ -216,8 +321,19 @@ class _BaseQuadTree(Generic[G, HitT, ItemType], ABC):
|
|
|
216
321
|
"""
|
|
217
322
|
Delete an item by id and exact geometry.
|
|
218
323
|
|
|
324
|
+
Args:
|
|
325
|
+
id_: The id of the item to delete.
|
|
326
|
+
geom: The geometry of the item to delete.
|
|
327
|
+
|
|
219
328
|
Returns:
|
|
220
329
|
True if the item was found and deleted.
|
|
330
|
+
|
|
331
|
+
Example:
|
|
332
|
+
```python
|
|
333
|
+
i = qt.insert((1.0, 2.0))
|
|
334
|
+
ok = qt.delete(i, (1.0, 2.0))
|
|
335
|
+
assert ok is True
|
|
336
|
+
```
|
|
221
337
|
"""
|
|
222
338
|
deleted = self._native.delete(id_, geom)
|
|
223
339
|
if deleted:
|
|
@@ -230,6 +346,17 @@ class _BaseQuadTree(Generic[G, HitT, ItemType], ABC):
|
|
|
230
346
|
"""
|
|
231
347
|
Attach or replace the Python object for an existing id.
|
|
232
348
|
Tracking must be enabled.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
id_: The id of the item to attach the object to.
|
|
352
|
+
obj: The Python object to attach.
|
|
353
|
+
|
|
354
|
+
Example:
|
|
355
|
+
```python
|
|
356
|
+
i = qt.insert((2.0, 3.0), obj=None)
|
|
357
|
+
qt.attach(i, {"meta": 123})
|
|
358
|
+
assert qt.get(i) == {"meta": 123}
|
|
359
|
+
```
|
|
233
360
|
"""
|
|
234
361
|
if self._store is None:
|
|
235
362
|
raise ValueError("Cannot attach objects when track_objects=False")
|
|
@@ -242,6 +369,16 @@ class _BaseQuadTree(Generic[G, HitT, ItemType], ABC):
|
|
|
242
369
|
def delete_by_object(self, obj: Any) -> bool:
|
|
243
370
|
"""
|
|
244
371
|
Delete an item by Python object identity. Tracking must be enabled.
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
obj: The Python object to delete.
|
|
375
|
+
|
|
376
|
+
Example:
|
|
377
|
+
```python
|
|
378
|
+
i = qt.insert((3.0, 4.0), obj="tag")
|
|
379
|
+
ok = qt.delete_by_object("tag")
|
|
380
|
+
assert ok is True
|
|
381
|
+
```
|
|
245
382
|
"""
|
|
246
383
|
if self._store is None:
|
|
247
384
|
raise ValueError("Cannot delete by object when track_objects=False")
|
|
@@ -256,6 +393,13 @@ class _BaseQuadTree(Generic[G, HitT, ItemType], ABC):
|
|
|
256
393
|
|
|
257
394
|
If tracking is enabled, the id -> object mapping is also cleared.
|
|
258
395
|
The ids are reset to start at zero again.
|
|
396
|
+
|
|
397
|
+
Example:
|
|
398
|
+
```python
|
|
399
|
+
_ = qt.insert((5.0, 6.0))
|
|
400
|
+
qt.clear()
|
|
401
|
+
assert qt.count_items() == 0 and len(qt) == 0
|
|
402
|
+
```
|
|
259
403
|
"""
|
|
260
404
|
self._native = self._new_native(self._bounds, self._capacity, self._max_depth)
|
|
261
405
|
self._count = 0
|
|
@@ -266,6 +410,14 @@ class _BaseQuadTree(Generic[G, HitT, ItemType], ABC):
|
|
|
266
410
|
def get_all_objects(self) -> list[Any]:
|
|
267
411
|
"""
|
|
268
412
|
Return all tracked Python objects in the tree.
|
|
413
|
+
|
|
414
|
+
Example:
|
|
415
|
+
```python
|
|
416
|
+
_ = qt.insert((7.0, 8.0), obj="a")
|
|
417
|
+
_ = qt.insert((9.0, 1.0), obj="b")
|
|
418
|
+
objs = qt.get_all_objects()
|
|
419
|
+
assert set(objs) == {"a", "b"}
|
|
420
|
+
```
|
|
269
421
|
"""
|
|
270
422
|
if self._store is None:
|
|
271
423
|
raise ValueError("Cannot get objects when track_objects=False")
|
|
@@ -274,6 +426,13 @@ class _BaseQuadTree(Generic[G, HitT, ItemType], ABC):
|
|
|
274
426
|
def get_all_items(self) -> list[ItemType]:
|
|
275
427
|
"""
|
|
276
428
|
Return all Item wrappers in the tree.
|
|
429
|
+
|
|
430
|
+
Example:
|
|
431
|
+
```python
|
|
432
|
+
_ = qt.insert((1.0, 1.0), obj=None)
|
|
433
|
+
items = qt.get_all_items()
|
|
434
|
+
assert hasattr(items[0], "id_") and hasattr(items[0], "geom")
|
|
435
|
+
```
|
|
277
436
|
"""
|
|
278
437
|
if self._store is None:
|
|
279
438
|
raise ValueError("Cannot get items when track_objects=False")
|
|
@@ -282,12 +441,25 @@ class _BaseQuadTree(Generic[G, HitT, ItemType], ABC):
|
|
|
282
441
|
def get_all_node_boundaries(self) -> list[Bounds]:
|
|
283
442
|
"""
|
|
284
443
|
Return all node boundaries in the tree. Useful for visualization.
|
|
444
|
+
|
|
445
|
+
Example:
|
|
446
|
+
```python
|
|
447
|
+
bounds = qt.get_all_node_boundaries()
|
|
448
|
+
assert isinstance(bounds, list)
|
|
449
|
+
```
|
|
285
450
|
"""
|
|
286
451
|
return self._native.get_all_node_boundaries()
|
|
287
452
|
|
|
288
453
|
def get(self, id_: int) -> Any | None:
|
|
289
454
|
"""
|
|
290
455
|
Return the object associated with id, if tracking is enabled.
|
|
456
|
+
|
|
457
|
+
Example:
|
|
458
|
+
```python
|
|
459
|
+
i = qt.insert((1.0, 2.0), obj={"k": "v"})
|
|
460
|
+
obj = qt.get(i)
|
|
461
|
+
assert obj == {"k": "v"}
|
|
462
|
+
```
|
|
291
463
|
"""
|
|
292
464
|
if self._store is None:
|
|
293
465
|
raise ValueError("Cannot get objects when track_objects=False")
|
|
@@ -297,6 +469,13 @@ class _BaseQuadTree(Generic[G, HitT, ItemType], ABC):
|
|
|
297
469
|
def count_items(self) -> int:
|
|
298
470
|
"""
|
|
299
471
|
Return the number of items currently in the tree (native count).
|
|
472
|
+
|
|
473
|
+
Example:
|
|
474
|
+
```python
|
|
475
|
+
before = qt.count_items()
|
|
476
|
+
_ = qt.insert((2.0, 2.0))
|
|
477
|
+
assert qt.count_items() == before + 1
|
|
478
|
+
```
|
|
300
479
|
"""
|
|
301
480
|
return self._native.count_items()
|
|
302
481
|
|