fastquadtree 1.1.1__tar.gz → 1.2.0__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.1 → fastquadtree-1.2.0}/.gitignore +2 -1
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/Cargo.lock +65 -1
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/Cargo.toml +3 -1
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/PKG-INFO +2 -1
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/README.md +1 -0
- fastquadtree-1.2.0/benchmarks/benchmark_serialization_vs_rebuild.py +149 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/docs/benchmark.md +26 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/docs/future_features.md +1 -1
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/docs/index.md +1 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/interactive/interactive_v2.py +18 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/pysrc/fastquadtree/_base_quadtree.py +68 -1
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/pysrc/fastquadtree/_item.py +29 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/pysrc/fastquadtree/_obj_store.py +39 -5
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/pysrc/fastquadtree/point_quadtree.py +19 -1
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/pysrc/fastquadtree/pyqtree.py +6 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/pysrc/fastquadtree/rect_quadtree.py +19 -1
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/src/geom.rs +4 -2
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/src/lib.rs +35 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/src/quadtree.rs +13 -2
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/src/rect_quadtree.rs +13 -1
- fastquadtree-1.2.0/tests/serialization.rs +61 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/tests/test_pyqtree_shim_compat.py +39 -0
- fastquadtree-1.2.0/tests/test_serialization.py +114 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/.github/workflows/docs.yml +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/.github/workflows/release.yml +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/.pre-commit-config.yaml +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/LICENSE +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/assets/ballpit.png +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/assets/interactive_v2_rect_screenshot.png +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/assets/interactive_v2_screenshot.png +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/assets/quadtree_bench_throughput.png +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/assets/quadtree_bench_time.png +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/benchmarks/benchmark_native_vs_shim.py +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/benchmarks/benchmark_np_vs_list.py +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/benchmarks/cross_library_bench.py +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/benchmarks/quadtree_bench/__init__.py +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/benchmarks/quadtree_bench/engines.py +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/benchmarks/quadtree_bench/main.py +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/benchmarks/quadtree_bench/plotting.py +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/benchmarks/quadtree_bench/runner.py +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/benchmarks/requirements.txt +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/benchmarks/runner.py +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/benchmarks/system_info_collector.py +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/docs/api/point_item.md +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/docs/api/pyqtree.md +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/docs/api/quadtree.md +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/docs/api/rect_item.md +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/docs/api/rect_quadtree.md +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/docs/quickstart.md +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/docs/runnables.md +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/docs/styles/overrides.css +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/interactive/ballpit.py +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/interactive/interactive.py +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/interactive/interactive_v2_rect.py +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/interactive/requirements.txt +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/mkdocs.yml +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/pyproject.toml +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/pysrc/fastquadtree/__init__.py +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/pysrc/fastquadtree/py.typed +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/tests/insertions.rs +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/tests/nearest_neighbor.rs +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/tests/query.rs +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/tests/rect_quadtree.rs +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/tests/rectangle_traversal.rs +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/tests/test_base_quadtree.py +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/tests/test_clear.py +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/tests/test_delete.rs +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/tests/test_delete_by_object.py +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/tests/test_delete_python.py +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/tests/test_insert_many_numpy.py +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/tests/test_obj_store.py +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/tests/test_point_quadtree_nn_runtime.py +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/tests/test_python.py +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/tests/test_rect_quadtree.py +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/tests/test_unconventional_bounds.py +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/tests/test_wrapper_edges.py +0 -0
- {fastquadtree-1.1.1 → fastquadtree-1.2.0}/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.
|
|
33
|
+
version = "1.2.0"
|
|
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.
|
|
3
|
+
version = "1.2.0"
|
|
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.
|
|
3
|
+
Version: 1.2.0
|
|
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,71 @@ 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
|
+
|
|
99
|
+
core_bytes = self._native.to_bytes()
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
"core": core_bytes,
|
|
103
|
+
"store": self._store.to_dict() if self._store is not None else None,
|
|
104
|
+
"bounds": self._bounds,
|
|
105
|
+
"capacity": self._capacity,
|
|
106
|
+
"max_depth": self._max_depth,
|
|
107
|
+
"track_objects": self._track_objects,
|
|
108
|
+
"next_id": self._next_id,
|
|
109
|
+
"count": self._count,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
def to_bytes(self) -> bytes:
|
|
113
|
+
"""
|
|
114
|
+
Serialize the quadtree to bytes.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Bytes representing the serialized quadtree. Can be saved as a file or loaded with `from_bytes()`.
|
|
118
|
+
"""
|
|
119
|
+
return pickle.dumps(self.to_dict())
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
def from_bytes(cls, data: bytes) -> _BaseQuadTree[G, HitT, ItemType]:
|
|
123
|
+
"""
|
|
124
|
+
Deserialize a quadtree from bytes.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
data: Bytes representing the serialized quadtree from `to_bytes()`.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
A new quadtree instance with the same state as when serialized.
|
|
131
|
+
"""
|
|
132
|
+
in_dict = pickle.loads(data)
|
|
133
|
+
core_bytes = in_dict["core"]
|
|
134
|
+
store_dict = in_dict["store"]
|
|
135
|
+
|
|
136
|
+
qt = cls.__new__(cls) # type: ignore[call-arg]
|
|
137
|
+
qt._native = cls._new_native_from_bytes(core_bytes)
|
|
138
|
+
|
|
139
|
+
if store_dict is not None:
|
|
140
|
+
qt._store = ObjStore.from_dict(store_dict, qt._make_item)
|
|
141
|
+
else:
|
|
142
|
+
qt._store = None
|
|
143
|
+
|
|
144
|
+
# Extract bounds, capacity, max_depth from native
|
|
145
|
+
qt._bounds = in_dict["bounds"]
|
|
146
|
+
qt._capacity = in_dict["capacity"]
|
|
147
|
+
qt._max_depth = in_dict["max_depth"]
|
|
148
|
+
qt._next_id = in_dict["next_id"]
|
|
149
|
+
qt._count = in_dict["count"]
|
|
150
|
+
qt._track_objects = in_dict["track_objects"]
|
|
151
|
+
|
|
152
|
+
return qt
|
|
153
|
+
|
|
87
154
|
# ---- internal helper ----
|
|
88
155
|
|
|
89
156
|
def _ids_to_objects(self, ids: Iterable[int]) -> list[Any]:
|
|
@@ -27,6 +27,35 @@ class Item:
|
|
|
27
27
|
self.geom: Point | Bounds = geom
|
|
28
28
|
self.obj: Any | None = obj
|
|
29
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
|
+
|
|
30
59
|
|
|
31
60
|
class PointItem(Item):
|
|
32
61
|
"""
|
|
@@ -35,11 +35,40 @@ class ObjStore(Generic[TItem]):
|
|
|
35
35
|
|
|
36
36
|
if items:
|
|
37
37
|
for it in items:
|
|
38
|
-
self.add(it)
|
|
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)
|
|
39
68
|
|
|
40
69
|
# -------- core --------
|
|
41
70
|
|
|
42
|
-
def add(self, item: TItem) -> None:
|
|
71
|
+
def add(self, item: TItem, handle_out_of_order: bool = False) -> None:
|
|
43
72
|
"""
|
|
44
73
|
Insert or replace the mapping at item.id_. Reverse map updated so obj points to id.
|
|
45
74
|
"""
|
|
@@ -48,9 +77,14 @@ class ObjStore(Generic[TItem]):
|
|
|
48
77
|
|
|
49
78
|
# ids must be dense and assigned by the caller
|
|
50
79
|
if id_ > len(self._arr):
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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)
|
|
54
88
|
|
|
55
89
|
if id_ == len(self._arr):
|
|
56
90
|
# append
|
|
@@ -49,6 +49,18 @@ class QuadTree(_BaseQuadTree[Point, _IdCoord, PointItem]):
|
|
|
49
49
|
track_objects=track_objects,
|
|
50
50
|
)
|
|
51
51
|
|
|
52
|
+
@classmethod
|
|
53
|
+
def from_bytes(cls, data: bytes) -> QuadTree:
|
|
54
|
+
"""
|
|
55
|
+
Create a QuadTree instance from serialized bytes.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
data: Serialized byte data from `to_bytes()`.
|
|
59
|
+
Returns:
|
|
60
|
+
A QuadTree instance.
|
|
61
|
+
"""
|
|
62
|
+
return super().from_bytes(data)
|
|
63
|
+
|
|
52
64
|
@overload
|
|
53
65
|
def query(
|
|
54
66
|
self, rect: Bounds, *, as_items: Literal[False] = ...
|
|
@@ -145,5 +157,11 @@ class QuadTree(_BaseQuadTree[Point, _IdCoord, PointItem]):
|
|
|
145
157
|
return _RustQuadTree(bounds, capacity)
|
|
146
158
|
return _RustQuadTree(bounds, capacity, max_depth=max_depth)
|
|
147
159
|
|
|
148
|
-
|
|
160
|
+
@classmethod
|
|
161
|
+
def _new_native_from_bytes(cls, data: bytes) -> Any:
|
|
162
|
+
"""Create a new native engine instance from serialized bytes."""
|
|
163
|
+
return _RustQuadTree.from_bytes(data)
|
|
164
|
+
|
|
165
|
+
@staticmethod
|
|
166
|
+
def _make_item(id_: int, geom: Point, obj: Any | None) -> PointItem:
|
|
149
167
|
return PointItem(id_, geom, obj)
|
|
@@ -122,6 +122,9 @@ class Index:
|
|
|
122
122
|
- **item**: The item to insert into the index, which will be returned by the intersection method
|
|
123
123
|
- **bbox**: The spatial bounding box tuple of the item, with four members (xmin,ymin,xmax,ymax)
|
|
124
124
|
"""
|
|
125
|
+
if type(bbox) is list: # Handle list input
|
|
126
|
+
bbox = tuple(bbox)
|
|
127
|
+
|
|
125
128
|
if self._free:
|
|
126
129
|
rid = self._free.pop()
|
|
127
130
|
self._objects[rid] = item
|
|
@@ -141,6 +144,9 @@ class Index:
|
|
|
141
144
|
|
|
142
145
|
Both parameters need to exactly match the parameters provided to the insert method.
|
|
143
146
|
"""
|
|
147
|
+
if type(bbox) is list: # Handle list input
|
|
148
|
+
bbox = tuple(bbox)
|
|
149
|
+
|
|
144
150
|
rid = self._item_to_id.pop(id(item))
|
|
145
151
|
self._qt.delete(rid, bbox)
|
|
146
152
|
self._objects[rid] = None
|
|
@@ -50,6 +50,18 @@ class RectQuadTree(_BaseQuadTree[Bounds, _IdRect, RectItem]):
|
|
|
50
50
|
track_objects=track_objects,
|
|
51
51
|
)
|
|
52
52
|
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_bytes(cls, data: bytes) -> RectQuadTree:
|
|
55
|
+
"""
|
|
56
|
+
Create a RectQuadTree instance from serialized bytes.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
data: Serialized byte data from `to_bytes()`.
|
|
60
|
+
Returns:
|
|
61
|
+
A RectQuadTree instance.
|
|
62
|
+
"""
|
|
63
|
+
return super().from_bytes(data)
|
|
64
|
+
|
|
53
65
|
@overload
|
|
54
66
|
def query(
|
|
55
67
|
self, rect: Bounds, *, as_items: Literal[False] = ...
|
|
@@ -81,5 +93,11 @@ class RectQuadTree(_BaseQuadTree[Bounds, _IdRect, RectItem]):
|
|
|
81
93
|
return _RustRectQuadTree(bounds, capacity)
|
|
82
94
|
return _RustRectQuadTree(bounds, capacity, max_depth=max_depth)
|
|
83
95
|
|
|
84
|
-
|
|
96
|
+
@classmethod
|
|
97
|
+
def _new_native_from_bytes(cls, data: bytes) -> Any:
|
|
98
|
+
"""Create a new native engine instance from serialized bytes."""
|
|
99
|
+
return _RustRectQuadTree.from_bytes(data)
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def _make_item(id_: int, geom: Bounds, obj: Any | None) -> RectItem:
|
|
85
103
|
return RectItem(id_, geom, obj)
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
|
|
1
|
+
use serde::{Serialize, Deserialize};
|
|
2
|
+
|
|
3
|
+
#[derive(Copy, Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
|
2
4
|
pub struct Point {
|
|
3
5
|
pub x: f32,
|
|
4
6
|
pub y: f32,
|
|
5
7
|
}
|
|
6
8
|
|
|
7
|
-
#[derive(Copy, Clone, Debug, PartialEq, Default)]
|
|
9
|
+
#[derive(Copy, Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
|
8
10
|
pub struct Rect {
|
|
9
11
|
pub min_x: f32,
|
|
10
12
|
pub min_y: f32,
|
|
@@ -8,6 +8,7 @@ pub use crate::rect_quadtree::{RectItem, RectQuadTree};
|
|
|
8
8
|
|
|
9
9
|
use pyo3::prelude::*;
|
|
10
10
|
use pyo3::types::PyList;
|
|
11
|
+
use pyo3::types::PyBytes;
|
|
11
12
|
use pyo3::exceptions::PyValueError;
|
|
12
13
|
use numpy::PyReadonlyArray2;
|
|
13
14
|
|
|
@@ -38,6 +39,23 @@ impl PyQuadTree {
|
|
|
38
39
|
Self { inner }
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
// Return Python bytes
|
|
43
|
+
pub fn to_bytes<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyBytes>> {
|
|
44
|
+
let buf = self
|
|
45
|
+
.inner
|
|
46
|
+
.to_bytes()
|
|
47
|
+
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("serialize failed: {e}")))?;
|
|
48
|
+
Ok(PyBytes::new(py, &buf))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Construct from Python bytes
|
|
52
|
+
#[staticmethod]
|
|
53
|
+
pub fn from_bytes(bytes: &Bound<PyBytes>) -> PyResult<Self> {
|
|
54
|
+
let inner = QuadTree::from_bytes(bytes.as_bytes())
|
|
55
|
+
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("deserialize failed: {e}")))?;
|
|
56
|
+
Ok(Self { inner })
|
|
57
|
+
}
|
|
58
|
+
|
|
41
59
|
pub fn insert(&mut self, id: u64, xy: (f32, f32)) -> bool {
|
|
42
60
|
let (x, y) = xy;
|
|
43
61
|
self.inner.insert(Item { id, point: Point { x, y } })
|
|
@@ -152,6 +170,23 @@ impl PyRectQuadTree {
|
|
|
152
170
|
Self { inner }
|
|
153
171
|
}
|
|
154
172
|
|
|
173
|
+
// Return Python bytes
|
|
174
|
+
pub fn to_bytes<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyBytes>> {
|
|
175
|
+
let buf = self
|
|
176
|
+
.inner
|
|
177
|
+
.to_bytes()
|
|
178
|
+
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("serialize failed: {e}")))?;
|
|
179
|
+
Ok(PyBytes::new(py, &buf))
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Construct from Python bytes
|
|
183
|
+
#[staticmethod]
|
|
184
|
+
pub fn from_bytes(bytes: &Bound<PyBytes>) -> PyResult<Self> {
|
|
185
|
+
let inner = RectQuadTree::from_bytes(bytes.as_bytes())
|
|
186
|
+
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("deserialize failed: {e}")))?;
|
|
187
|
+
Ok(Self { inner })
|
|
188
|
+
}
|
|
189
|
+
|
|
155
190
|
/// Insert one AABB by id.
|
|
156
191
|
pub fn insert(&mut self, id: u64, rect: (f32, f32, f32, f32)) -> bool {
|
|
157
192
|
let (min_x, min_y, max_x, max_y) = rect;
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
use std::collections::HashSet;
|
|
2
2
|
use crate::geom::{Point, Rect, dist_sq_point_to_rect, dist_sq_points};
|
|
3
3
|
use smallvec::SmallVec;
|
|
4
|
+
use serde::{Serialize, Deserialize};
|
|
5
|
+
use bincode::config::standard;
|
|
6
|
+
use bincode::serde::{encode_to_vec, decode_from_slice};
|
|
4
7
|
|
|
5
|
-
#[derive(Copy, Clone, Debug, PartialEq, Default)]
|
|
8
|
+
#[derive(Copy, Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
|
6
9
|
pub struct Item {
|
|
7
10
|
pub id: u64,
|
|
8
11
|
pub point: Point,
|
|
9
12
|
}
|
|
10
13
|
|
|
11
|
-
|
|
14
|
+
#[derive(Serialize, Deserialize)]
|
|
12
15
|
pub struct QuadTree {
|
|
13
16
|
pub boundary: Rect,
|
|
14
17
|
pub items: Vec<Item>,
|
|
@@ -55,6 +58,14 @@ impl QuadTree {
|
|
|
55
58
|
}
|
|
56
59
|
}
|
|
57
60
|
|
|
61
|
+
pub fn to_bytes(&self) -> Result<Vec<u8>, bincode::error::EncodeError> {
|
|
62
|
+
encode_to_vec(self, standard())
|
|
63
|
+
}
|
|
64
|
+
pub fn from_bytes(bytes: &[u8]) -> Result<Self, bincode::error::DecodeError> {
|
|
65
|
+
let (qt, _len): (Self, usize) = decode_from_slice(bytes, standard())?;
|
|
66
|
+
Ok(qt)
|
|
67
|
+
}
|
|
68
|
+
|
|
58
69
|
pub fn new_child(boundary: Rect, capacity: usize, depth: usize, max_depth: usize) -> Self {
|
|
59
70
|
QuadTree {
|
|
60
71
|
boundary,
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
use smallvec::SmallVec;
|
|
2
2
|
use crate::geom::Rect;
|
|
3
|
+
use serde::{Serialize, Deserialize};
|
|
4
|
+
use bincode::config::standard;
|
|
5
|
+
use bincode::serde::{encode_to_vec, decode_from_slice};
|
|
3
6
|
|
|
4
|
-
#[derive(Copy, Clone, Debug, PartialEq, Default)]
|
|
7
|
+
#[derive(Copy, Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
|
5
8
|
pub struct RectItem {
|
|
6
9
|
pub id: u64,
|
|
7
10
|
pub rect: Rect,
|
|
8
11
|
}
|
|
9
12
|
|
|
13
|
+
#[derive(Serialize, Deserialize)]
|
|
10
14
|
pub struct RectQuadTree {
|
|
11
15
|
pub boundary: Rect,
|
|
12
16
|
pub items: Vec<RectItem>,
|
|
@@ -68,6 +72,14 @@ impl RectQuadTree {
|
|
|
68
72
|
}
|
|
69
73
|
}
|
|
70
74
|
|
|
75
|
+
pub fn to_bytes(&self) -> Result<Vec<u8>, bincode::error::EncodeError> {
|
|
76
|
+
encode_to_vec(self, standard())
|
|
77
|
+
}
|
|
78
|
+
pub fn from_bytes(bytes: &[u8]) -> Result<Self, bincode::error::DecodeError> {
|
|
79
|
+
let (qt, _len): (Self, usize) = decode_from_slice(bytes, standard())?;
|
|
80
|
+
Ok(qt)
|
|
81
|
+
}
|
|
82
|
+
|
|
71
83
|
fn new_child(boundary: Rect, capacity: usize, depth: usize, max_depth: usize) -> Self {
|
|
72
84
|
RectQuadTree {
|
|
73
85
|
boundary,
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
use fastquadtree::{RectQuadTree, QuadTree, Item, RectItem, Point, Rect};
|
|
2
|
+
|
|
3
|
+
#[test]
|
|
4
|
+
fn quadtree_roundtrip_bytes() {
|
|
5
|
+
// Build a small tree
|
|
6
|
+
let mut qt = QuadTree::new(
|
|
7
|
+
Rect { min_x: 0.0, min_y: 0.0, max_x: 10.0, max_y: 10.0 },
|
|
8
|
+
4,
|
|
9
|
+
);
|
|
10
|
+
for (i, (x, y)) in [(1.0, 1.0), (2.0, 3.0), (7.5, 8.5), (9.0, 0.5)].into_iter().enumerate() {
|
|
11
|
+
qt.insert(Item { id: i as u64 + 1, point: Point { x, y } });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Serialize
|
|
15
|
+
let bytes = qt.to_bytes().expect("serialize quadtree");
|
|
16
|
+
|
|
17
|
+
// Deserialize
|
|
18
|
+
let qt2 = QuadTree::from_bytes(&bytes).expect("deserialize quadtree");
|
|
19
|
+
|
|
20
|
+
// Basic invariants
|
|
21
|
+
assert_eq!(qt.count_items(), qt2.count_items());
|
|
22
|
+
|
|
23
|
+
// Query equality for a region
|
|
24
|
+
let rect = Rect { min_x: 0.0, min_y: 0.0, max_x: 5.0, max_y: 5.0 };
|
|
25
|
+
let a: Vec<_> = qt.query(rect).into_iter().map(|(id, _, _)| id).collect();
|
|
26
|
+
let b: Vec<_> = qt2.query(rect).into_iter().map(|(id, _, _)| id).collect();
|
|
27
|
+
assert_eq!(a, b);
|
|
28
|
+
|
|
29
|
+
// Nearest neighbor equality
|
|
30
|
+
let nn1 = qt.nearest_neighbor(Point { x: 1.2, y: 1.1 }).map(|it| it.id);
|
|
31
|
+
let nn2 = qt2.nearest_neighbor(Point { x: 1.2, y: 1.1 }).map(|it| it.id);
|
|
32
|
+
assert_eq!(nn1, nn2);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
#[test]
|
|
37
|
+
fn rectquadtree_roundtrip_bytes() {
|
|
38
|
+
// Build a small tree
|
|
39
|
+
let mut qt = RectQuadTree::new(
|
|
40
|
+
Rect { min_x: 0.0, min_y: 0.0, max_x: 10.0, max_y: 10.0 },
|
|
41
|
+
4,
|
|
42
|
+
);
|
|
43
|
+
for (i, (x, y)) in [(1.0, 1.0), (2.0, 3.0), (7.5, 8.5), (9.0, 0.5)].into_iter().enumerate() {
|
|
44
|
+
qt.insert(RectItem { id: i as u64 + 1, rect: Rect { min_x: x, min_y: y, max_x: x + 1.0, max_y: y + 1.0 } });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Serialize
|
|
48
|
+
let bytes = qt.to_bytes().expect("serialize quadtree");
|
|
49
|
+
|
|
50
|
+
// Deserialize
|
|
51
|
+
let qt2 = RectQuadTree::from_bytes(&bytes).expect("deserialize quadtree");
|
|
52
|
+
|
|
53
|
+
// Basic invariants
|
|
54
|
+
assert_eq!(qt.count_items(), qt2.count_items());
|
|
55
|
+
|
|
56
|
+
// Query equality for a region
|
|
57
|
+
let rect = Rect { min_x: 0.0, min_y: 0.0, max_x: 5.0, max_y: 5.0 };
|
|
58
|
+
let a: Vec<_> = qt.query(rect).into_iter().map(|it| it.0).collect();
|
|
59
|
+
let b: Vec<_> = qt2.query(rect).into_iter().map(|it| it.0).collect();
|
|
60
|
+
assert_eq!(a, b);
|
|
61
|
+
}
|
|
@@ -362,3 +362,42 @@ def test_query_list_and_tuple_equivalence():
|
|
|
362
362
|
results_from_list = idx.intersect(query_list)
|
|
363
363
|
|
|
364
364
|
assert results_from_tuple == results_from_list == [obj1]
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def test_remove_list_and_tuple_equivalence():
|
|
368
|
+
"""Test that both list and tuple inputs for bbox work the same in remove."""
|
|
369
|
+
idx = FQTIndex(bbox=WORLD)
|
|
370
|
+
|
|
371
|
+
obj1, box1 = "obj1", (10.0, 10.0, 20.0, 20.0)
|
|
372
|
+
obj2, box2 = "obj2", (30.0, 30.0, 40.0, 40.0)
|
|
373
|
+
|
|
374
|
+
idx.insert(obj1, box1)
|
|
375
|
+
idx.insert(obj2, box2)
|
|
376
|
+
|
|
377
|
+
# Remove using tuple
|
|
378
|
+
idx.remove(obj1, box1)
|
|
379
|
+
|
|
380
|
+
# Remove using list
|
|
381
|
+
box2_list = [30.0, 30.0, 40.0, 40.0]
|
|
382
|
+
idx.remove(obj2, box2_list)
|
|
383
|
+
|
|
384
|
+
# Both objects should be removed
|
|
385
|
+
assert idx.intersect((0.0, 0.0, 100.0, 100.0)) == []
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def test_insert_list_and_tuple_equivalence():
|
|
389
|
+
"""Test that both list and tuple inputs for bbox work the same in insert."""
|
|
390
|
+
idx = FQTIndex(bbox=WORLD)
|
|
391
|
+
|
|
392
|
+
obj1, box1 = "obj1", (10.0, 10.0, 20.0, 20.0)
|
|
393
|
+
obj2, box2 = "obj2", [30.0, 30.0, 40.0, 40.0] # box2 as list
|
|
394
|
+
|
|
395
|
+
# Insert using tuple
|
|
396
|
+
idx.insert(obj1, box1)
|
|
397
|
+
|
|
398
|
+
# Insert using list
|
|
399
|
+
idx.insert(obj2, box2)
|
|
400
|
+
|
|
401
|
+
# Both objects should be present
|
|
402
|
+
results = idx.intersect((0.0, 0.0, 100.0, 100.0))
|
|
403
|
+
assert set(results) == {obj1, obj2}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Test unconventional quadtree bounding boxes including negative regions."""
|
|
3
|
+
|
|
4
|
+
from fastquadtree import QuadTree, RectQuadTree
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_serialization_point_quadtree():
|
|
8
|
+
"""Test serialization and deserialization of PointQuadTree."""
|
|
9
|
+
qt = QuadTree((0, 0, 100, 100), capacity=4)
|
|
10
|
+
points = [(10, 10), (20, 20), (30, 30), (40, 40)]
|
|
11
|
+
for pt in points:
|
|
12
|
+
qt.insert(pt)
|
|
13
|
+
|
|
14
|
+
data = qt.to_bytes()
|
|
15
|
+
qt2 = QuadTree.from_bytes(data)
|
|
16
|
+
|
|
17
|
+
assert qt.count_items() == qt2.count_items()
|
|
18
|
+
for rect in [(0, 0, 50, 50), (15, 15, 35, 35)]:
|
|
19
|
+
res1 = qt.query(rect)
|
|
20
|
+
res2 = qt2.query(rect)
|
|
21
|
+
assert sorted(res1) == sorted(res2)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_serialization_rect_quadtree():
|
|
25
|
+
"""Test serialization and deserialization of RectQuadTree."""
|
|
26
|
+
rqt = RectQuadTree((0, 0, 100, 100), capacity=4)
|
|
27
|
+
rects = [(5, 5, 15, 15), (20, 20, 30, 30), (35, 35, 45, 45), (50, 50, 60, 60)]
|
|
28
|
+
for rect in rects:
|
|
29
|
+
rqt.insert(rect)
|
|
30
|
+
|
|
31
|
+
data = rqt.to_bytes()
|
|
32
|
+
rqt2 = RectQuadTree.from_bytes(data)
|
|
33
|
+
|
|
34
|
+
assert rqt.count_items() == rqt2.count_items()
|
|
35
|
+
for query_rect in [(0, 0, 25, 25), (30, 30, 55, 55)]:
|
|
36
|
+
res1 = rqt.query(query_rect)
|
|
37
|
+
res2 = rqt2.query(query_rect)
|
|
38
|
+
assert sorted(res1) == sorted(res2)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_serialization_with_objects_point():
|
|
42
|
+
"""Test serialization of quadtree with associated objects."""
|
|
43
|
+
qt = QuadTree((0, 0, 100, 100), capacity=4, track_objects=True)
|
|
44
|
+
items = [((10, 10), "A"), ((20, 20), "B"), ((30, 30), "C")]
|
|
45
|
+
for pt, obj in items:
|
|
46
|
+
qt.insert(pt, obj=obj)
|
|
47
|
+
|
|
48
|
+
data = qt.to_bytes()
|
|
49
|
+
qt2 = QuadTree.from_bytes(data)
|
|
50
|
+
|
|
51
|
+
assert qt.count_items() == qt2.count_items()
|
|
52
|
+
|
|
53
|
+
# Check that the objects in the object store are preserved
|
|
54
|
+
all_qt2_items = [item.to_dict() for item in qt2.get_all_items()]
|
|
55
|
+
all_qt_items = [item.to_dict() for item in qt.get_all_items()]
|
|
56
|
+
|
|
57
|
+
for item in all_qt_items:
|
|
58
|
+
assert item in all_qt2_items
|
|
59
|
+
|
|
60
|
+
assert type(qt2.get_all_items()[0]) is type(qt.get_all_items()[0])
|
|
61
|
+
assert type(qt2.get_all_items()[0].obj) is type(qt.get_all_items()[0].obj)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_serialization_with_objects_rect():
|
|
65
|
+
"""Test serialization of rect quadtree with associated objects."""
|
|
66
|
+
rqt = RectQuadTree((0, 0, 100, 100), capacity=4, track_objects=True)
|
|
67
|
+
items = [
|
|
68
|
+
((5, 5, 15, 15), "RectA"),
|
|
69
|
+
((20, 20, 30, 30), "RectB"),
|
|
70
|
+
((35, 35, 45, 45), "RectC"),
|
|
71
|
+
]
|
|
72
|
+
for rect, obj in items:
|
|
73
|
+
rqt.insert(rect, obj=obj)
|
|
74
|
+
|
|
75
|
+
data = rqt.to_bytes()
|
|
76
|
+
rqt2 = RectQuadTree.from_bytes(data)
|
|
77
|
+
|
|
78
|
+
assert rqt.count_items() == rqt2.count_items()
|
|
79
|
+
|
|
80
|
+
# Check that the objects in the object store are preserved
|
|
81
|
+
all_rqt2_items = [item.to_dict() for item in rqt2.get_all_items()]
|
|
82
|
+
all_rqt_items = [item.to_dict() for item in rqt.get_all_items()]
|
|
83
|
+
|
|
84
|
+
for item in all_rqt_items:
|
|
85
|
+
assert item in all_rqt2_items
|
|
86
|
+
|
|
87
|
+
assert type(rqt2.get_all_items()[0]) is type(rqt.get_all_items()[0])
|
|
88
|
+
assert type(rqt2.get_all_items()[0].obj) is type(rqt.get_all_items()[0].obj)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_serialization_perserves_ids():
|
|
92
|
+
"""Test that serialization and deserialization preserves item ids."""
|
|
93
|
+
qt = QuadTree((0, 0, 100, 100), capacity=4, track_objects=True)
|
|
94
|
+
items = [((10, 10), "A"), ((20, 20), "B"), ((30, 30), "C")]
|
|
95
|
+
for pt, obj in items:
|
|
96
|
+
qt.insert(pt, obj=obj)
|
|
97
|
+
|
|
98
|
+
original_ids = [item.id_ for item in qt.get_all_items()]
|
|
99
|
+
|
|
100
|
+
data = qt.to_bytes()
|
|
101
|
+
qt2 = QuadTree.from_bytes(data)
|
|
102
|
+
|
|
103
|
+
deserialized_ids = [item.id_ for item in qt2.get_all_items()]
|
|
104
|
+
|
|
105
|
+
assert sorted(original_ids) == sorted(deserialized_ids)
|
|
106
|
+
|
|
107
|
+
# Delete id 1
|
|
108
|
+
qt.delete(1, (20, 20))
|
|
109
|
+
ids_after_delete = [item.id_ for item in qt.get_all_items()]
|
|
110
|
+
|
|
111
|
+
qt3 = QuadTree.from_bytes(qt.to_bytes())
|
|
112
|
+
ids_after_delete_deserialized = [item.id_ for item in qt3.get_all_items()]
|
|
113
|
+
assert sorted(ids_after_delete) == [0, 2]
|
|
114
|
+
assert sorted(ids_after_delete_deserialized) == [0, 2]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|