fastquadtree 1.0.2__tar.gz → 1.1.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.
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/.pre-commit-config.yaml +2 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/Cargo.lock +91 -1
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/Cargo.toml +2 -1
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/PKG-INFO +4 -2
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/README.md +2 -1
- fastquadtree-1.1.0/benchmarks/benchmark_np_vs_list.py +178 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/benchmarks/requirements.txt +4 -1
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/docs/benchmark.md +30 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/docs/index.md +1 -1
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/mkdocs.yml +3 -3
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/pyproject.toml +2 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/pysrc/fastquadtree/_base_quadtree.py +51 -5
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/src/lib.rs +60 -0
- fastquadtree-1.1.0/tests/test_insert_many_numpy.py +183 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/tests/test_pyqtree_shim_compat.py +97 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/.github/workflows/docs.yml +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/.github/workflows/release.yml +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/.gitignore +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/LICENSE +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/assets/ballpit.png +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/assets/interactive_v2_rect_screenshot.png +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/assets/interactive_v2_screenshot.png +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/assets/quadtree_bench_throughput.png +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/assets/quadtree_bench_time.png +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/benchmarks/benchmark_native_vs_shim.py +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/benchmarks/cross_library_bench.py +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/benchmarks/quadtree_bench/__init__.py +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/benchmarks/quadtree_bench/engines.py +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/benchmarks/quadtree_bench/main.py +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/benchmarks/quadtree_bench/plotting.py +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/benchmarks/quadtree_bench/runner.py +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/benchmarks/runner.py +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/benchmarks/system_info_collector.py +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/docs/api/point_item.md +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/docs/api/pyqtree.md +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/docs/api/quadtree.md +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/docs/api/rect_item.md +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/docs/api/rect_quadtree.md +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/docs/future_features.md +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/docs/quickstart.md +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/docs/runnables.md +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/docs/styles/overrides.css +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/interactive/ballpit.py +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/interactive/interactive.py +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/interactive/interactive_v2.py +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/interactive/interactive_v2_rect.py +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/interactive/requirements.txt +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/pysrc/fastquadtree/__init__.py +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/pysrc/fastquadtree/_item.py +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/pysrc/fastquadtree/_obj_store.py +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/pysrc/fastquadtree/point_quadtree.py +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/pysrc/fastquadtree/py.typed +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/pysrc/fastquadtree/pyqtree.py +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/pysrc/fastquadtree/rect_quadtree.py +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/src/geom.rs +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/src/quadtree.rs +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/src/rect_quadtree.rs +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/tests/insertions.rs +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/tests/nearest_neighbor.rs +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/tests/query.rs +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/tests/rect_quadtree.rs +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/tests/rectangle_traversal.rs +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/tests/test_base_quadtree.py +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/tests/test_clear.py +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/tests/test_delete.rs +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/tests/test_delete_by_object.py +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/tests/test_delete_python.py +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/tests/test_obj_store.py +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/tests/test_point_quadtree_nn_runtime.py +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/tests/test_python.py +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/tests/test_rect_quadtree.py +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/tests/test_unconventional_bounds.py +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/tests/test_wrapper_edges.py +0 -0
- {fastquadtree-1.0.2 → fastquadtree-1.1.0}/tests/unconventional_bounds.rs +0 -0
@@ -7,9 +7,11 @@ repos:
|
|
7
7
|
- id: ruff
|
8
8
|
name: ruff (lint + fix)
|
9
9
|
args: ["--fix", "--exit-non-zero-on-fix"]
|
10
|
+
fail_fast: true
|
10
11
|
# Code formatter (Black replacement)
|
11
12
|
- id: ruff-format
|
12
13
|
name: ruff (format)
|
14
|
+
fail_fast: true
|
13
15
|
|
14
16
|
# Local hooks that run in sequence and do not receive file args
|
15
17
|
- repo: local
|
@@ -10,8 +10,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
|
10
10
|
|
11
11
|
[[package]]
|
12
12
|
name = "fastquadtree"
|
13
|
-
version = "1.0
|
13
|
+
version = "1.1.0"
|
14
14
|
dependencies = [
|
15
|
+
"numpy",
|
15
16
|
"pyo3",
|
16
17
|
"smallvec",
|
17
18
|
]
|
@@ -34,6 +35,16 @@ version = "0.2.175"
|
|
34
35
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
35
36
|
checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543"
|
36
37
|
|
38
|
+
[[package]]
|
39
|
+
name = "matrixmultiply"
|
40
|
+
version = "0.3.10"
|
41
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
42
|
+
checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08"
|
43
|
+
dependencies = [
|
44
|
+
"autocfg",
|
45
|
+
"rawpointer",
|
46
|
+
]
|
47
|
+
|
37
48
|
[[package]]
|
38
49
|
name = "memoffset"
|
39
50
|
version = "0.9.1"
|
@@ -43,6 +54,64 @@ dependencies = [
|
|
43
54
|
"autocfg",
|
44
55
|
]
|
45
56
|
|
57
|
+
[[package]]
|
58
|
+
name = "ndarray"
|
59
|
+
version = "0.16.1"
|
60
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
61
|
+
checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841"
|
62
|
+
dependencies = [
|
63
|
+
"matrixmultiply",
|
64
|
+
"num-complex",
|
65
|
+
"num-integer",
|
66
|
+
"num-traits",
|
67
|
+
"portable-atomic",
|
68
|
+
"portable-atomic-util",
|
69
|
+
"rawpointer",
|
70
|
+
]
|
71
|
+
|
72
|
+
[[package]]
|
73
|
+
name = "num-complex"
|
74
|
+
version = "0.4.6"
|
75
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
76
|
+
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
77
|
+
dependencies = [
|
78
|
+
"num-traits",
|
79
|
+
]
|
80
|
+
|
81
|
+
[[package]]
|
82
|
+
name = "num-integer"
|
83
|
+
version = "0.1.46"
|
84
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
85
|
+
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
|
86
|
+
dependencies = [
|
87
|
+
"num-traits",
|
88
|
+
]
|
89
|
+
|
90
|
+
[[package]]
|
91
|
+
name = "num-traits"
|
92
|
+
version = "0.2.19"
|
93
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
94
|
+
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
95
|
+
dependencies = [
|
96
|
+
"autocfg",
|
97
|
+
]
|
98
|
+
|
99
|
+
[[package]]
|
100
|
+
name = "numpy"
|
101
|
+
version = "0.26.0"
|
102
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
103
|
+
checksum = "9b2dba356160b54f5371b550575b78130a54718b4c6e46b3f33a6da74a27e78b"
|
104
|
+
dependencies = [
|
105
|
+
"libc",
|
106
|
+
"ndarray",
|
107
|
+
"num-complex",
|
108
|
+
"num-integer",
|
109
|
+
"num-traits",
|
110
|
+
"pyo3",
|
111
|
+
"pyo3-build-config",
|
112
|
+
"rustc-hash",
|
113
|
+
]
|
114
|
+
|
46
115
|
[[package]]
|
47
116
|
name = "once_cell"
|
48
117
|
version = "1.21.3"
|
@@ -55,6 +124,15 @@ version = "1.11.1"
|
|
55
124
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
56
125
|
checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
|
57
126
|
|
127
|
+
[[package]]
|
128
|
+
name = "portable-atomic-util"
|
129
|
+
version = "0.2.4"
|
130
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
131
|
+
checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507"
|
132
|
+
dependencies = [
|
133
|
+
"portable-atomic",
|
134
|
+
]
|
135
|
+
|
58
136
|
[[package]]
|
59
137
|
name = "proc-macro2"
|
60
138
|
version = "1.0.101"
|
@@ -134,6 +212,18 @@ dependencies = [
|
|
134
212
|
"proc-macro2",
|
135
213
|
]
|
136
214
|
|
215
|
+
[[package]]
|
216
|
+
name = "rawpointer"
|
217
|
+
version = "0.2.1"
|
218
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
219
|
+
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
|
220
|
+
|
221
|
+
[[package]]
|
222
|
+
name = "rustc-hash"
|
223
|
+
version = "2.1.1"
|
224
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
225
|
+
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
226
|
+
|
137
227
|
[[package]]
|
138
228
|
name = "smallvec"
|
139
229
|
version = "1.15.1"
|
@@ -1,6 +1,6 @@
|
|
1
1
|
[package]
|
2
2
|
name = "fastquadtree"
|
3
|
-
version = "1.0
|
3
|
+
version = "1.1.0"
|
4
4
|
edition = "2021"
|
5
5
|
readme = "README.md"
|
6
6
|
|
@@ -10,6 +10,7 @@ crate-type = ["rlib", "cdylib"]
|
|
10
10
|
[dependencies]
|
11
11
|
pyo3 = { version = "0.26", features = ["extension-module", "abi3-py38"] }
|
12
12
|
smallvec = "1.15.1"
|
13
|
+
numpy = "0.26"
|
13
14
|
|
14
15
|
[profile.release]
|
15
16
|
opt-level = 3
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: fastquadtree
|
3
|
-
Version: 1.0
|
3
|
+
Version: 1.1.0
|
4
4
|
Classifier: Programming Language :: Python :: 3
|
5
5
|
Classifier: Programming Language :: Python :: 3 :: Only
|
6
6
|
Classifier: Programming Language :: Rust
|
@@ -25,6 +25,7 @@ Requires-Dist: mkdocs-git-revision-date-localized-plugin ; extra == 'dev'
|
|
25
25
|
Requires-Dist: mkdocs-minify-plugin ; extra == 'dev'
|
26
26
|
Requires-Dist: maturin>=1.5 ; extra == 'dev'
|
27
27
|
Requires-Dist: pyqtree==1.0.0 ; extra == 'dev'
|
28
|
+
Requires-Dist: numpy ; extra == 'dev'
|
28
29
|
Provides-Extra: dev
|
29
30
|
License-File: LICENSE
|
30
31
|
Summary: Rust-accelerated quadtree for Python with fast inserts, range queries, and k-NN search.
|
@@ -48,6 +49,7 @@ Rust-optimized quadtree with a clean Python API
|
|
48
49
|
[](https://pypi.org/project/fastquadtree/)
|
49
50
|
[](https://pepy.tech/projects/fastquadtree)
|
50
51
|
[](https://github.com/Elan456/fastquadtree/actions/workflows/release.yml)
|
52
|
+

|
51
53
|
|
52
54
|
[](https://pyo3.rs/)
|
53
55
|
[](https://www.maturin.rs/)
|
@@ -62,7 +64,7 @@ Rust-optimized quadtree with a clean Python API
|
|
62
64
|
|
63
65
|
## Why use fastquadtree
|
64
66
|
|
65
|
-
- Clean [Python API](https://elan456.github.io/fastquadtree/api/quadtree/) with modern typing hints
|
67
|
+
- Clean [Python API](https://elan456.github.io/fastquadtree/api/quadtree/) with no external dependencies and modern typing hints
|
66
68
|
- The fastest quadtree Python package ([>10x faster](https://elan456.github.io/fastquadtree/benchmark/) than pyqtree)
|
67
69
|
- Prebuilt wheels for Windows, macOS, and Linux
|
68
70
|
- Support for [inserting bounding boxes](https://elan456.github.io/fastquadtree/api/rect_quadtree/) or points
|
@@ -9,6 +9,7 @@ Rust-optimized quadtree with a clean Python API
|
|
9
9
|
[](https://pypi.org/project/fastquadtree/)
|
10
10
|
[](https://pepy.tech/projects/fastquadtree)
|
11
11
|
[](https://github.com/Elan456/fastquadtree/actions/workflows/release.yml)
|
12
|
+

|
12
13
|
|
13
14
|
[](https://pyo3.rs/)
|
14
15
|
[](https://www.maturin.rs/)
|
@@ -23,7 +24,7 @@ Rust-optimized quadtree with a clean Python API
|
|
23
24
|
|
24
25
|
## Why use fastquadtree
|
25
26
|
|
26
|
-
- Clean [Python API](https://elan456.github.io/fastquadtree/api/quadtree/) with modern typing hints
|
27
|
+
- Clean [Python API](https://elan456.github.io/fastquadtree/api/quadtree/) with no external dependencies and modern typing hints
|
27
28
|
- The fastest quadtree Python package ([>10x faster](https://elan456.github.io/fastquadtree/benchmark/) than pyqtree)
|
28
29
|
- Prebuilt wheels for Windows, macOS, and Linux
|
29
30
|
- Support for [inserting bounding boxes](https://elan456.github.io/fastquadtree/api/rect_quadtree/) or points
|
@@ -0,0 +1,178 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
from __future__ import annotations
|
3
|
+
|
4
|
+
import argparse
|
5
|
+
import gc
|
6
|
+
import statistics as stats
|
7
|
+
from time import perf_counter as now
|
8
|
+
|
9
|
+
import numpy as np
|
10
|
+
from system_info_collector import (
|
11
|
+
collect_system_info,
|
12
|
+
format_system_info_markdown_lite,
|
13
|
+
)
|
14
|
+
|
15
|
+
from fastquadtree import QuadTree as ShimQuadTree # high level wrapper with insert_many
|
16
|
+
|
17
|
+
BOUNDS: tuple[float, float, float, float] = (0.0, 0.0, 1000.0, 1000.0)
|
18
|
+
CAPACITY = 64
|
19
|
+
MAX_DEPTH = 10
|
20
|
+
SEED = 12345
|
21
|
+
|
22
|
+
|
23
|
+
def gen_np_points(n: int, dtype: np.dtype) -> np.ndarray:
|
24
|
+
rng = np.random.default_rng(SEED)
|
25
|
+
# Generate within bounds. Use dtype the user requested.
|
26
|
+
if np.issubdtype(dtype, np.integer):
|
27
|
+
arr = rng.integers(low=0, high=1000, size=(n, 2), dtype=dtype)
|
28
|
+
else:
|
29
|
+
arr = rng.random(size=(n, 2), dtype=dtype) * 1000.0
|
30
|
+
return arr
|
31
|
+
|
32
|
+
|
33
|
+
def _build_tree_np(points_np: np.ndarray, track_objects: bool) -> float:
|
34
|
+
t0 = now()
|
35
|
+
qt = ShimQuadTree(
|
36
|
+
BOUNDS, CAPACITY, max_depth=MAX_DEPTH, track_objects=track_objects
|
37
|
+
)
|
38
|
+
# Direct NumPy path
|
39
|
+
inserted = qt.insert_many(points_np)
|
40
|
+
dt = now() - t0
|
41
|
+
assert (
|
42
|
+
inserted == points_np.shape[0]
|
43
|
+
), f"Inserted {inserted} != {points_np.shape[0]}"
|
44
|
+
return dt
|
45
|
+
|
46
|
+
|
47
|
+
def _build_tree_list(
|
48
|
+
points_list: list[tuple[float, float]], track_objects: bool
|
49
|
+
) -> float:
|
50
|
+
t0 = now()
|
51
|
+
qt = ShimQuadTree(
|
52
|
+
BOUNDS, CAPACITY, max_depth=MAX_DEPTH, track_objects=track_objects
|
53
|
+
)
|
54
|
+
inserted = qt.insert_many(points_list)
|
55
|
+
dt = now() - t0
|
56
|
+
assert inserted == len(points_list), f"Inserted {inserted} != {len(points_list)}"
|
57
|
+
return dt
|
58
|
+
|
59
|
+
|
60
|
+
def bench_np_direct(points_np: np.ndarray, repeats: int, track_objects: bool) -> float:
|
61
|
+
times = []
|
62
|
+
for _ in range(repeats):
|
63
|
+
gc.disable()
|
64
|
+
times.append(_build_tree_np(points_np, track_objects))
|
65
|
+
gc.enable()
|
66
|
+
return stats.median(times)
|
67
|
+
|
68
|
+
|
69
|
+
def bench_list_from_np(
|
70
|
+
points_np: np.ndarray, repeats: int, track_objects: bool, include_conversion: bool
|
71
|
+
) -> float:
|
72
|
+
times = []
|
73
|
+
if not include_conversion:
|
74
|
+
# Convert once up front so measured time is insert only
|
75
|
+
points_list = [tuple(map(float, row)) for row in points_np]
|
76
|
+
for _ in range(repeats):
|
77
|
+
gc.disable()
|
78
|
+
if include_conversion:
|
79
|
+
# Count conversion cost
|
80
|
+
t0 = now()
|
81
|
+
points_list = [tuple(map(float, row)) for row in points_np]
|
82
|
+
convert_time = now() - t0
|
83
|
+
build_time = _build_tree_list(points_list, track_objects) # type: ignore
|
84
|
+
times.append(convert_time + build_time)
|
85
|
+
else:
|
86
|
+
# Insert only
|
87
|
+
times.append(_build_tree_list(points_list, track_objects)) # pyright: ignore[reportPossiblyUnboundVariable, reportArgumentType]
|
88
|
+
gc.enable()
|
89
|
+
return stats.median(times)
|
90
|
+
|
91
|
+
|
92
|
+
def main():
|
93
|
+
ap = argparse.ArgumentParser(
|
94
|
+
description="Benchmark: NumPy insert vs Python list insert"
|
95
|
+
)
|
96
|
+
ap.add_argument("--points", type=int, default=500_000)
|
97
|
+
ap.add_argument("--repeats", type=int, default=5)
|
98
|
+
ap.add_argument(
|
99
|
+
"--dtype",
|
100
|
+
type=str,
|
101
|
+
default="float32",
|
102
|
+
choices=["float32", "float64", "int32", "int64"],
|
103
|
+
help="Dtype for generated NumPy points",
|
104
|
+
)
|
105
|
+
ap.add_argument(
|
106
|
+
"--track-objects",
|
107
|
+
action="store_true",
|
108
|
+
help="Enable object tracking in the shim to include store overhead",
|
109
|
+
)
|
110
|
+
args = ap.parse_args()
|
111
|
+
|
112
|
+
dtype_map = {
|
113
|
+
"float32": np.float32,
|
114
|
+
"float64": np.float64,
|
115
|
+
"int32": np.int32,
|
116
|
+
"int64": np.int64,
|
117
|
+
}
|
118
|
+
dtype = dtype_map[args.dtype]
|
119
|
+
|
120
|
+
print("NumPy vs list insert benchmark")
|
121
|
+
print("=" * 50)
|
122
|
+
print("Configuration:")
|
123
|
+
print(f" Points: {args.points:,}")
|
124
|
+
print(f" Repeats: {args.repeats}")
|
125
|
+
print(f" Dtype: {args.dtype}")
|
126
|
+
print(f" Track objects: {args.track_objects}")
|
127
|
+
print()
|
128
|
+
|
129
|
+
# Data
|
130
|
+
pts_np = gen_np_points(args.points, dtype=dtype)
|
131
|
+
|
132
|
+
# Warmup
|
133
|
+
_ = bench_np_direct(pts_np[:10_000], repeats=1, track_objects=args.track_objects)
|
134
|
+
_ = bench_list_from_np(
|
135
|
+
pts_np[:10_000],
|
136
|
+
repeats=1,
|
137
|
+
track_objects=args.track_objects,
|
138
|
+
include_conversion=False,
|
139
|
+
)
|
140
|
+
_ = bench_list_from_np(
|
141
|
+
pts_np[:10_000],
|
142
|
+
repeats=1,
|
143
|
+
track_objects=args.track_objects,
|
144
|
+
include_conversion=True,
|
145
|
+
)
|
146
|
+
|
147
|
+
# Actual runs
|
148
|
+
t_np = bench_np_direct(pts_np, args.repeats, args.track_objects)
|
149
|
+
t_list_insert_only = bench_list_from_np(
|
150
|
+
pts_np, args.repeats, args.track_objects, include_conversion=False
|
151
|
+
)
|
152
|
+
t_list_with_convert = bench_list_from_np(
|
153
|
+
pts_np, args.repeats, args.track_objects, include_conversion=True
|
154
|
+
)
|
155
|
+
|
156
|
+
def fmt(x: float) -> str:
|
157
|
+
if x < 1e-3:
|
158
|
+
return f"{x * 1e6:.1f} µs"
|
159
|
+
if x < 1:
|
160
|
+
return f"{x * 1e3:.1f} ms"
|
161
|
+
return f"{x:.3f} s"
|
162
|
+
|
163
|
+
print("Results (median of repeats)")
|
164
|
+
print()
|
165
|
+
print("| Variant | Build time |")
|
166
|
+
print("|---|---:|")
|
167
|
+
print(f"| NumPy array direct | {fmt(t_np)} |")
|
168
|
+
print(f"| Python list insert only | {fmt(t_list_insert_only)} |")
|
169
|
+
print(f"| Python list including conversion | {fmt(t_list_with_convert)} |")
|
170
|
+
|
171
|
+
if collect_system_info and format_system_info_markdown_lite:
|
172
|
+
info = collect_system_info()
|
173
|
+
print()
|
174
|
+
print(format_system_info_markdown_lite(info))
|
175
|
+
|
176
|
+
|
177
|
+
if __name__ == "__main__":
|
178
|
+
main()
|
@@ -57,6 +57,8 @@ Using the shim with object tracking increases build time by 5.345x and query tim
|
|
57
57
|
|
58
58
|
Adding the object map tends to impact the build time more than query time.
|
59
59
|
|
60
|
+
-----------
|
61
|
+
|
60
62
|
## pyqtree drop-in shim performance gains
|
61
63
|
|
62
64
|
### Configuration
|
@@ -78,6 +80,33 @@ This is a **total speedup of 6.567x** compared to the original pyqtree and requi
|
|
78
80
|
|
79
81
|
---------
|
80
82
|
|
83
|
+
## NumPy Bulk Insert vs Python List Insert
|
84
|
+
### Configuration
|
85
|
+
|
86
|
+
- Points: 500,000
|
87
|
+
- Repeats: 5
|
88
|
+
- Dtype: float32
|
89
|
+
- Track objects: False
|
90
|
+
|
91
|
+
### Results (median of repeats)
|
92
|
+
|
93
|
+
| Variant | Build time |
|
94
|
+
|---|---:|
|
95
|
+
| NumPy array direct | 42.8 ms |
|
96
|
+
| Python list insert only | 51.1 ms |
|
97
|
+
| Python list including conversion | 540.2 ms |
|
98
|
+
|
99
|
+
Key:
|
100
|
+
|
101
|
+
- *NumPy array direct*: Using the `insert_many` method with a NumPy array of shape (N, 2).
|
102
|
+
- *Python list insert only*: Using the `insert_many` method with a Python list of tuples.
|
103
|
+
- *Python list including conversion*: Time taken to convert a NumPy array to a Python list of tuples, then inserting.
|
104
|
+
|
105
|
+
### Summary
|
106
|
+
If your data is already in a NumPy array, using the `insert_many` method directly with the array is significantly faster than converting to a Python list first.
|
107
|
+
|
108
|
+
---------
|
109
|
+
|
81
110
|
## System Info
|
82
111
|
- **OS**: Windows 11 AMD64
|
83
112
|
- **Python**: CPython 3.12.2
|
@@ -97,6 +126,7 @@ Then run:
|
|
97
126
|
```bash
|
98
127
|
python benchmarks/cross_library_bench.py
|
99
128
|
python benchmarks/benchmark_native_vs_shim.py
|
129
|
+
python benchmarks/benchmark_np_vs_list.py
|
100
130
|
```
|
101
131
|
|
102
132
|
Check the CLI arguments for the cross-library benchmark in `benchmarks/quadtree_bench/main.py`.
|
@@ -35,7 +35,7 @@
|
|
35
35
|
|
36
36
|
## Why use fastquadtree
|
37
37
|
|
38
|
-
- Clean [Python API](api/quadtree.md) with modern typing hints
|
38
|
+
- Clean [Python API](api/quadtree.md) with no external dependencies and modern typing hints
|
39
39
|
- The fastest quadtree Python package ([>10x faster](benchmark.md) than pyqtree)
|
40
40
|
- Prebuilt wheels for Windows, macOS, and Linux
|
41
41
|
- Support for [inserting bounding boxes](api/rect_quadtree.md) or points
|
@@ -44,11 +44,11 @@ plugins:
|
|
44
44
|
separate_signature: true
|
45
45
|
line_length: 88
|
46
46
|
heading_level: 2
|
47
|
-
filters: # Exclude __slots__ and __len__ and anything with a single underscore prefix
|
47
|
+
filters: # Exclude __slots__ and __len__ and anything with a single underscore prefix (don't filter insert_many even though it has a single underscore)
|
48
48
|
- "!__slots__"
|
49
49
|
- "!__len__"
|
50
|
-
- "
|
51
|
-
|
50
|
+
- "!^_[^_]"
|
51
|
+
|
52
52
|
- autorefs
|
53
53
|
- git-revision-date-localized:
|
54
54
|
fallback_to_build_date: true
|
@@ -9,6 +9,7 @@ dynamic = ["version"]
|
|
9
9
|
description = "Rust-accelerated quadtree for Python with fast inserts, range queries, and k-NN search."
|
10
10
|
readme = { file = "README.md", content-type = "text/markdown" }
|
11
11
|
requires-python = ">=3.8"
|
12
|
+
dependencies = [] # No runtime dependencies
|
12
13
|
license = { file = "LICENSE" }
|
13
14
|
authors = [{ name = "Ethan Anderson" }]
|
14
15
|
keywords = ["quadtree", "spatial-index", "geometry", "rust", "pyo3", "nearest-neighbor", "k-nn"]
|
@@ -78,6 +79,7 @@ dev = [
|
|
78
79
|
"mkdocs-minify-plugin",
|
79
80
|
"maturin>=1.5", # build Rust wheels
|
80
81
|
"pyqtree==1.0.0", # for comparison in tests
|
82
|
+
"numpy",
|
81
83
|
]
|
82
84
|
|
83
85
|
[tool.ruff]
|
@@ -2,11 +2,23 @@
|
|
2
2
|
from __future__ import annotations
|
3
3
|
|
4
4
|
from abc import ABC, abstractmethod
|
5
|
-
from typing import
|
5
|
+
from typing import (
|
6
|
+
TYPE_CHECKING,
|
7
|
+
Any,
|
8
|
+
Generic,
|
9
|
+
Iterable,
|
10
|
+
Sequence,
|
11
|
+
Tuple,
|
12
|
+
TypeVar,
|
13
|
+
overload,
|
14
|
+
)
|
6
15
|
|
7
16
|
from ._item import Item # base class for PointItem and RectItem
|
8
17
|
from ._obj_store import ObjStore
|
9
18
|
|
19
|
+
if TYPE_CHECKING:
|
20
|
+
from numpy.typing import NDArray
|
21
|
+
|
10
22
|
Bounds = Tuple[float, float, float, float]
|
11
23
|
|
12
24
|
# Generic parameters
|
@@ -15,6 +27,11 @@ HitT = TypeVar("HitT") # raw native tuple, e.g. (id,x,y) or (id,x0,y0,x1,y1)
|
|
15
27
|
ItemType = TypeVar("ItemType", bound=Item) # e.g. PointItem or RectItem
|
16
28
|
|
17
29
|
|
30
|
+
def _is_np_array(x: Any) -> bool:
|
31
|
+
mod = getattr(x.__class__, "__module__", "")
|
32
|
+
return mod.startswith("numpy") and hasattr(x, "ndim") and hasattr(x, "shape")
|
33
|
+
|
34
|
+
|
18
35
|
class _BaseQuadTree(Generic[G, HitT, ItemType], ABC):
|
19
36
|
"""
|
20
37
|
Shared logic for Python QuadTree wrappers over native Rust engines.
|
@@ -110,9 +127,18 @@ class _BaseQuadTree(Generic[G, HitT, ItemType], ABC):
|
|
110
127
|
self._count += 1
|
111
128
|
return rid
|
112
129
|
|
113
|
-
|
130
|
+
@overload
|
131
|
+
def insert_many(self, geoms: Sequence[G], objs: list[Any] | None = None) -> int: ...
|
132
|
+
@overload
|
133
|
+
def insert_many(
|
134
|
+
self, geoms: NDArray[Any], objs: list[Any] | None = None
|
135
|
+
) -> int: ...
|
136
|
+
def insert_many(
|
137
|
+
self, geoms: NDArray[Any] | Sequence[G], objs: list[Any] | None = None
|
138
|
+
) -> int:
|
114
139
|
"""
|
115
140
|
Bulk insert with auto-assigned contiguous ids. Faster than inserting one-by-one.<br>
|
141
|
+
Can accept either a Python sequence of geometries or a NumPy array of shape (N,2) or (N,4) with dtype float32.
|
116
142
|
|
117
143
|
If tracking is enabled, the objects will be bulk stored internally.
|
118
144
|
If no objects are provided, the items will have obj=None (if tracking).
|
@@ -127,13 +153,30 @@ class _BaseQuadTree(Generic[G, HitT, ItemType], ABC):
|
|
127
153
|
Raises:
|
128
154
|
ValueError: If any geometry is outside bounds.
|
129
155
|
"""
|
130
|
-
if
|
156
|
+
if type(geoms) is list and len(geoms) == 0:
|
131
157
|
return 0
|
132
158
|
|
159
|
+
if _is_np_array(geoms):
|
160
|
+
import numpy as _np
|
161
|
+
else:
|
162
|
+
_np = None
|
163
|
+
|
164
|
+
# Early return if the numpy array is empty
|
165
|
+
if _np is not None and isinstance(geoms, _np.ndarray):
|
166
|
+
if geoms.size == 0:
|
167
|
+
return 0
|
168
|
+
|
169
|
+
if geoms.dtype != _np.float32:
|
170
|
+
raise TypeError("Numpy array must use dtype float32")
|
171
|
+
|
133
172
|
if self._store is None:
|
134
173
|
# Simple contiguous path with native bulk insert
|
135
174
|
start_id = self._next_id
|
136
|
-
|
175
|
+
|
176
|
+
if _np is not None:
|
177
|
+
last_id = self._native.insert_many_np(start_id, geoms)
|
178
|
+
else:
|
179
|
+
last_id = self._native.insert_many(start_id, geoms)
|
137
180
|
num = last_id - start_id + 1
|
138
181
|
if num < len(geoms):
|
139
182
|
raise ValueError("One or more items are outside tree bounds")
|
@@ -143,7 +186,10 @@ class _BaseQuadTree(Generic[G, HitT, ItemType], ABC):
|
|
143
186
|
|
144
187
|
# With tracking enabled:
|
145
188
|
start_id = len(self._store._arr) # contiguous tail position
|
146
|
-
|
189
|
+
if _np is not None:
|
190
|
+
last_id = self._native.insert_many_np(start_id, geoms)
|
191
|
+
else:
|
192
|
+
last_id = self._native.insert_many(start_id, geoms)
|
147
193
|
num = last_id - start_id + 1
|
148
194
|
if num < len(geoms):
|
149
195
|
raise ValueError("One or more items are outside tree bounds")
|
@@ -8,6 +8,8 @@ pub use crate::rect_quadtree::{RectItem, RectQuadTree};
|
|
8
8
|
|
9
9
|
use pyo3::prelude::*;
|
10
10
|
use pyo3::types::PyList;
|
11
|
+
use pyo3::exceptions::PyValueError;
|
12
|
+
use numpy::PyReadonlyArray2;
|
11
13
|
|
12
14
|
fn item_to_tuple(it: Item) -> (u64, f32, f32) {
|
13
15
|
(it.id, it.point.x, it.point.y)
|
@@ -53,6 +55,35 @@ impl PyQuadTree {
|
|
53
55
|
id.saturating_sub(1)
|
54
56
|
}
|
55
57
|
|
58
|
+
/// Assume (n x 2) numpy array of float32 points [[x, y], ...]
|
59
|
+
pub fn insert_many_np<'py>(
|
60
|
+
&mut self,
|
61
|
+
py: Python<'py>, // Allow releasing the GIL during insertion
|
62
|
+
start_id: u64,
|
63
|
+
points: PyReadonlyArray2<'py, f32>,
|
64
|
+
) -> PyResult<u64> {
|
65
|
+
let view = points.as_array();
|
66
|
+
if view.ncols() != 2 {
|
67
|
+
return Err(PyValueError::new_err("points must have shape (N, 2)"));
|
68
|
+
}
|
69
|
+
|
70
|
+
let mut id = start_id;
|
71
|
+
py.detach(|| {
|
72
|
+
if let Some(slice) = view.as_slice() {
|
73
|
+
for ch in slice.chunks_exact(2) {
|
74
|
+
let (x, y) = (ch[0], ch[1]);
|
75
|
+
if self.inner.insert(Item { id, point: Point { x, y } }) { id += 1; }
|
76
|
+
}
|
77
|
+
} else {
|
78
|
+
for row in view.outer_iter() {
|
79
|
+
let (x, y) = (row[0], row[1]);
|
80
|
+
if self.inner.insert(Item { id, point: Point { x, y } }) { id += 1; }
|
81
|
+
}
|
82
|
+
}
|
83
|
+
});
|
84
|
+
Ok(id.saturating_sub(1))
|
85
|
+
}
|
86
|
+
|
56
87
|
pub fn delete(&mut self, id: u64, xy: (f32, f32)) -> bool {
|
57
88
|
let (x, y) = xy;
|
58
89
|
self.inner.delete(id, Point { x, y })
|
@@ -138,6 +169,35 @@ impl PyRectQuadTree {
|
|
138
169
|
id.saturating_sub(1)
|
139
170
|
}
|
140
171
|
|
172
|
+
/// Assume (n x 4) numpy array of float32 box [[min_x, min_y, max_x, max_y], ...]
|
173
|
+
pub fn insert_many_np<'py>(
|
174
|
+
&mut self,
|
175
|
+
py: Python<'py>, // Allow releasing the GIL during insertion
|
176
|
+
start_id: u64,
|
177
|
+
points: PyReadonlyArray2<'py, f32>,
|
178
|
+
) -> PyResult<u64> {
|
179
|
+
let view = points.as_array();
|
180
|
+
if view.ncols() != 4 {
|
181
|
+
return Err(PyValueError::new_err("points must have shape (N, 4)"));
|
182
|
+
}
|
183
|
+
|
184
|
+
let mut id = start_id;
|
185
|
+
py.detach(|| {
|
186
|
+
if let Some(slice) = view.as_slice() {
|
187
|
+
for ch in slice.chunks_exact(4) {
|
188
|
+
let r = Rect { min_x: ch[0], min_y: ch[1], max_x: ch[2], max_y: ch[3] };
|
189
|
+
if self.inner.insert(RectItem { id, rect: r }) { id += 1; }
|
190
|
+
}
|
191
|
+
} else {
|
192
|
+
for row in view.outer_iter() {
|
193
|
+
let r = Rect { min_x: row[0], min_y: row[1], max_x: row[2], max_y: row[3] };
|
194
|
+
if self.inner.insert(RectItem { id, rect: r }) { id += 1; }
|
195
|
+
}
|
196
|
+
}
|
197
|
+
});
|
198
|
+
Ok(id.saturating_sub(1))
|
199
|
+
}
|
200
|
+
|
141
201
|
/// Delete by id and exact rect.
|
142
202
|
pub fn delete(&mut self, id: u64, rect: (f32, f32, f32, f32)) -> bool {
|
143
203
|
let (min_x, min_y, max_x, max_y) = rect;
|
@@ -0,0 +1,183 @@
|
|
1
|
+
import numpy as np
|
2
|
+
import pytest
|
3
|
+
|
4
|
+
from fastquadtree import Item, QuadTree, RectQuadTree
|
5
|
+
|
6
|
+
BOUNDS = (0, 0, 1000, 1000)
|
7
|
+
|
8
|
+
|
9
|
+
def ids(hits):
|
10
|
+
"""Return sorted list of ids from [(id, x, y), ...]."""
|
11
|
+
return sorted(h[0] for h in hits)
|
12
|
+
|
13
|
+
|
14
|
+
def test_insert_many_seeds_items_and_query_as_items_round_trip():
|
15
|
+
qt = QuadTree(BOUNDS, capacity=8, track_objects=True)
|
16
|
+
n = qt.insert_many([(10, 10), (20, 20), (30, 30)])
|
17
|
+
assert n == 3
|
18
|
+
|
19
|
+
qt_np = QuadTree(BOUNDS, capacity=8, track_objects=True)
|
20
|
+
|
21
|
+
points = np.array([[10, 10], [20, 20], [30, 30]], dtype=np.float32)
|
22
|
+
n = qt_np.insert_many(points)
|
23
|
+
assert n == 3
|
24
|
+
|
25
|
+
raw = qt.query((0, 0, 40, 40), as_items=False)
|
26
|
+
its = qt.query((0, 0, 40, 40), as_items=True)
|
27
|
+
|
28
|
+
raw_np = qt_np.query((0, 0, 40, 40), as_items=False)
|
29
|
+
its_np = qt_np.query((0, 0, 40, 40), as_items=True)
|
30
|
+
|
31
|
+
assert len(raw) == len(its) == 3
|
32
|
+
assert len(raw_np) == len(its_np) == 3
|
33
|
+
# ids and positions match
|
34
|
+
m_raw = {t[0]: (t[1], t[2]) for t in raw}
|
35
|
+
for it in its:
|
36
|
+
assert isinstance(it, Item)
|
37
|
+
assert (it.x, it.y) == m_raw[it.id_]
|
38
|
+
|
39
|
+
m_raw_np = {t[0]: (t[1], t[2]) for t in raw_np}
|
40
|
+
for it in its_np:
|
41
|
+
assert isinstance(it, Item)
|
42
|
+
assert (it.x, it.y) == m_raw_np[it.id_]
|
43
|
+
# ids match between raw and raw_np
|
44
|
+
assert ids(raw) == ids(raw_np)
|
45
|
+
|
46
|
+
|
47
|
+
def test_type_error_on_wrong_dtype():
|
48
|
+
qt = QuadTree(BOUNDS, capacity=8, track_objects=True)
|
49
|
+
points = np.array([[10, 10], [20, 20], [30, 30]], dtype=np.float64) # Wrong dtype
|
50
|
+
with pytest.raises(TypeError):
|
51
|
+
qt.insert_many(points)
|
52
|
+
assert len(qt) == 0
|
53
|
+
|
54
|
+
|
55
|
+
def test_insert_empty_numpy_array():
|
56
|
+
qt = QuadTree(BOUNDS, capacity=8, track_objects=True)
|
57
|
+
points = np.empty((0, 2), dtype=np.float32)
|
58
|
+
n = qt.insert_many(points)
|
59
|
+
assert n == 0
|
60
|
+
assert len(qt) == 0
|
61
|
+
|
62
|
+
|
63
|
+
def test_insert_many_numpy_out_of_bounds():
|
64
|
+
qt = QuadTree(BOUNDS, capacity=8, track_objects=True)
|
65
|
+
points = np.array([[10, 10], (2000, 2000), [30, 30]], dtype=np.float32)
|
66
|
+
with pytest.raises(ValueError):
|
67
|
+
qt.insert_many(points)
|
68
|
+
assert len(qt) == 0
|
69
|
+
|
70
|
+
|
71
|
+
def test_insert_many_without_tracking_numpy():
|
72
|
+
qt = QuadTree(BOUNDS, capacity=8, track_objects=False)
|
73
|
+
points = np.array([[10, 10], [20, 20], [30, 30]], dtype=np.float32)
|
74
|
+
n = qt.insert_many(points)
|
75
|
+
assert n == 3
|
76
|
+
assert len(qt) == 3
|
77
|
+
|
78
|
+
raw = qt.query((0, 0, 40, 40), as_items=False)
|
79
|
+
|
80
|
+
assert len(raw) == 3
|
81
|
+
# ids and positions match
|
82
|
+
m_raw = {t[0]: (t[1], t[2]) for t in raw}
|
83
|
+
for t in raw:
|
84
|
+
assert (t[1], t[2]) == m_raw[t[0]]
|
85
|
+
|
86
|
+
|
87
|
+
def test_insert_many_rect_quadtree_numpy():
|
88
|
+
qt = RectQuadTree(BOUNDS, capacity=8, track_objects=True)
|
89
|
+
rects = np.array(
|
90
|
+
[[10, 10, 15, 15], [20, 20, 25, 25], [30, 30, 35, 35]], dtype=np.float32
|
91
|
+
)
|
92
|
+
n = qt.insert_many(rects)
|
93
|
+
assert n == 3
|
94
|
+
|
95
|
+
raw = qt.query((0, 0, 40, 40), as_items=False)
|
96
|
+
its = qt.query((0, 0, 40, 40), as_items=True)
|
97
|
+
assert len(raw) == len(its) == 3
|
98
|
+
# ids and positions match
|
99
|
+
m_raw = {t[0]: (t[1], t[2], t[3], t[4]) for t in raw}
|
100
|
+
for it in its:
|
101
|
+
assert isinstance(it, Item)
|
102
|
+
assert (it.min_x, it.min_y, it.max_x, it.max_y) == m_raw[it.id_]
|
103
|
+
|
104
|
+
# Query that will only hit one rect
|
105
|
+
raw2 = qt.query((12, 12, 13, 13), as_items=False)
|
106
|
+
|
107
|
+
assert len(raw2) == 1
|
108
|
+
assert raw2[0][0] == 0 # id of the first rect
|
109
|
+
|
110
|
+
|
111
|
+
def test_point_query_accuracy_robust_numpy():
|
112
|
+
qt = QuadTree(BOUNDS, capacity=4, track_objects=True)
|
113
|
+
num_points = 10000
|
114
|
+
np.random.seed(42)
|
115
|
+
points = np.random.uniform(0, 999, size=(num_points, 2)).astype(np.float32)
|
116
|
+
qt.insert_many(points)
|
117
|
+
|
118
|
+
# Query a random rectangle and verify all returned points are within it
|
119
|
+
query_rect = (250, 250, 750, 750)
|
120
|
+
results = qt.query(query_rect, as_items=False)
|
121
|
+
|
122
|
+
for _, x, y in results:
|
123
|
+
assert query_rect[0] <= x < query_rect[2]
|
124
|
+
assert query_rect[1] <= y < query_rect[3]
|
125
|
+
|
126
|
+
# Verify that no points within the rectangle are missed
|
127
|
+
all_points_set = {(int(x), int(y)) for x, y in points}
|
128
|
+
queried_points_set = {(int(x), int(y)) for _, x, y in results}
|
129
|
+
|
130
|
+
for x in range(int(query_rect[0]), int(query_rect[2])):
|
131
|
+
for y in range(int(query_rect[1]), int(query_rect[3])):
|
132
|
+
if (x, y) in all_points_set:
|
133
|
+
assert (x, y) in queried_points_set
|
134
|
+
|
135
|
+
|
136
|
+
def test_rect_query_accuracy_robust_numpy():
|
137
|
+
qt = RectQuadTree(BOUNDS, capacity=4, track_objects=True)
|
138
|
+
num_rects = 10000
|
139
|
+
np.random.seed(42)
|
140
|
+
rects = np.random.uniform(0, 950, size=(num_rects, 2)).astype(np.float32)
|
141
|
+
sizes = np.random.uniform(5, 50, size=(num_rects, 2)).astype(np.float32)
|
142
|
+
rects = np.hstack((rects, rects + sizes))
|
143
|
+
qt.insert_many(rects)
|
144
|
+
|
145
|
+
# Query a random rectangle and verify all returned rects intersect it
|
146
|
+
query_rect = (250, 250, 750, 750)
|
147
|
+
results = qt.query(query_rect, as_items=False)
|
148
|
+
|
149
|
+
def intersects(r1, r2):
|
150
|
+
return not (r1[2] < r2[0] or r1[0] > r2[2] or r1[3] < r2[1] or r1[1] > r2[3])
|
151
|
+
|
152
|
+
for _, min_x, min_y, max_x, max_y in results:
|
153
|
+
assert intersects((min_x, min_y, max_x, max_y), query_rect)
|
154
|
+
|
155
|
+
# Verify that no rects intersecting the query rectangle are missed
|
156
|
+
all_rects = list(rects)
|
157
|
+
queried_rects_set = {
|
158
|
+
(int(min_x), int(min_y), int(max_x), int(max_y))
|
159
|
+
for _, min_x, min_y, max_x, max_y in results
|
160
|
+
}
|
161
|
+
|
162
|
+
for rect in all_rects:
|
163
|
+
if intersects(rect, query_rect):
|
164
|
+
assert (
|
165
|
+
int(rect[0]),
|
166
|
+
int(rect[1]),
|
167
|
+
int(rect[2]),
|
168
|
+
int(rect[3]),
|
169
|
+
) in queried_rects_set
|
170
|
+
|
171
|
+
|
172
|
+
def test_insert_objects_numpy():
|
173
|
+
qt = QuadTree(BOUNDS, capacity=8, track_objects=True)
|
174
|
+
qt.insert_many(
|
175
|
+
np.array([[10, 10], [20, 20], [30, 30]], dtype=np.float32),
|
176
|
+
objs=[{"name": "A"}, {"name": "B"}, {"name": "C"}],
|
177
|
+
)
|
178
|
+
|
179
|
+
items = qt.query((0, 0, 40, 40), as_items=True)
|
180
|
+
assert len(items) == 3
|
181
|
+
|
182
|
+
names = {item.obj["name"] for item in items if item.obj is not None}
|
183
|
+
assert names == {"A", "B", "C"}
|
@@ -8,6 +8,8 @@ from fastquadtree.pyqtree import Index as FQTIndex
|
|
8
8
|
|
9
9
|
WORLD = (0.0, 0.0, 100.0, 100.0)
|
10
10
|
|
11
|
+
EPS = 1e-6 # Floating point tolerance for edge cases
|
12
|
+
|
11
13
|
|
12
14
|
def rand_rect(rng, world=WORLD, min_size=1.0, max_size=20.0):
|
13
15
|
x1 = rng.uniform(world[0], world[2] - min_size)
|
@@ -246,3 +248,98 @@ def test_free_slot_reuse_no_growth_under_churn():
|
|
246
248
|
# None of the removed items should be found
|
247
249
|
for obj, box in removed:
|
248
250
|
assert obj not in idx.intersect(box)
|
251
|
+
|
252
|
+
|
253
|
+
def _boxes_touching_edges_and_corners():
|
254
|
+
# World is (0,0,100,100). Partition lines around 50 are common split lines.
|
255
|
+
return [
|
256
|
+
("left_edge", (0.0, 10.0, 5.0, 20.0)), # touches world min-x
|
257
|
+
("right_edge", (95.0, 10.0, 100.0, 20.0)), # touches world max-x
|
258
|
+
("bottom_edge", (10.0, 0.0, 20.0, 5.0)), # touches world min-y
|
259
|
+
("top_edge", (10.0, 95.0, 20.0, 100.0)), # touches world max-y
|
260
|
+
("bottom_left_pt", (0.0, 0.0, 5.0, 5.0)), # corner touch
|
261
|
+
("top_right_pt", (95.0, 95.0, 100.0, 100.0)),
|
262
|
+
# Straddle the vertical split line x=50 with tiny thickness
|
263
|
+
("straddle_x50", (50.0 - EPS, 40.0, 50.0 + EPS, 60.0)),
|
264
|
+
# Straddle the horizontal split line y=50 with tiny thickness
|
265
|
+
("straddle_y50", (40.0, 50.0 - EPS, 60.0, 50.0 + EPS)),
|
266
|
+
# Very thin but > 0 width and height
|
267
|
+
("thin_horizontal", (20.0, 33.333, 80.0, 33.333 + 1e-5)),
|
268
|
+
("thin_vertical", (33.333, 20.0, 33.333 + 1e-5, 80.0)),
|
269
|
+
# Boxes that just touch each other at an edge or corner
|
270
|
+
("touch_A", (30.0, 30.0, 40.0, 40.0)),
|
271
|
+
("touch_B", (40.0, 30.0, 50.0, 40.0)), # shares an edge with A
|
272
|
+
("touch_C", (40.0, 40.0, 50.0, 50.0)), # touches B at one corner
|
273
|
+
]
|
274
|
+
|
275
|
+
|
276
|
+
def _queries_covering_touch_cases():
|
277
|
+
return [
|
278
|
+
(0.0, 0.0, 100.0, 100.0), # world
|
279
|
+
(0.0, 10.0, 5.0, 20.0), # exact edge box
|
280
|
+
(95.0, 10.0, 100.0, 20.0),
|
281
|
+
(10.0, 0.0, 20.0, 5.0),
|
282
|
+
(10.0, 95.0, 20.0, 100.0),
|
283
|
+
(30.0, 30.0, 50.0, 40.0), # spans touch_A and touch_B shared edge
|
284
|
+
(39.9999, 39.9999, 40.0001, 40.0001), # tiny around touching corner
|
285
|
+
(50.0 - 2 * EPS, 49.0, 50.0 + 2 * EPS, 51.0), # around x=50 straddle
|
286
|
+
(49.0, 50.0 - 2 * EPS, 51.0, 50.0 + 2 * EPS), # around y=50 straddle
|
287
|
+
(20.0, 33.333 - 1e-4, 80.0, 33.333 + 1e-4), # thin horizontal
|
288
|
+
(33.333 - 1e-4, 20.0, 33.333 + 1e-4, 80.0), # thin vertical
|
289
|
+
]
|
290
|
+
|
291
|
+
|
292
|
+
@pytest.mark.parametrize("ctor", ["bbox", "xywh"])
|
293
|
+
def test_edge_and_boundary_semantics_match_pyqtree(ctor):
|
294
|
+
items = _boxes_touching_edges_and_corners()
|
295
|
+
fqt, pyq = build_indices(items, ctor=ctor)
|
296
|
+
|
297
|
+
# Check that every crafted query matches exactly
|
298
|
+
for q in _queries_covering_touch_cases():
|
299
|
+
results_match_exact(fqt, pyq, q)
|
300
|
+
|
301
|
+
# Add a few more probes around the partition lines to stress boundary math
|
302
|
+
for dx in (-EPS, 0.0, EPS):
|
303
|
+
for dy in (-EPS, 0.0, EPS):
|
304
|
+
q = (50.0 + dx - 1.0, 50.0 + dy - 1.0, 50.0 + dx + 1.0, 50.0 + dy + 1.0)
|
305
|
+
results_match_exact(fqt, pyq, q)
|
306
|
+
|
307
|
+
|
308
|
+
def test_dense_targets_along_partition_lines_match_pyqtree():
|
309
|
+
"""
|
310
|
+
Insert many small rectangles centered along x=50 and y=50 to stress
|
311
|
+
splitting and equality on boundary math.
|
312
|
+
"""
|
313
|
+
objs = []
|
314
|
+
boxes = []
|
315
|
+
|
316
|
+
# 40 tiny boxes along x=50 at different y
|
317
|
+
for i in range(40):
|
318
|
+
y = 2.0 + i * 2.4 # spread across the world
|
319
|
+
boxes.append((50.0 - 0.25, y - 0.25, 50.0 + 0.25, y + 0.25))
|
320
|
+
objs.append(f"vx_{i}")
|
321
|
+
|
322
|
+
# 40 tiny boxes along y=50 at different x
|
323
|
+
for i in range(40):
|
324
|
+
x = 2.0 + i * 2.4
|
325
|
+
boxes.append((x - 0.25, 50.0 - 0.25, x + 0.25, 50.0 + 0.25))
|
326
|
+
objs.append(f"hy_{i}")
|
327
|
+
|
328
|
+
items = list(zip(objs, boxes))
|
329
|
+
fqt, pyq = build_indices(items, ctor="bbox")
|
330
|
+
|
331
|
+
# Probe a grid of queries around the center to catch any off by epsilon
|
332
|
+
for cx in (49.5, 50.0, 50.5):
|
333
|
+
for cy in (49.5, 50.0, 50.5):
|
334
|
+
q = (cx - 1.0, cy - 1.0, cx + 1.0, cy + 1.0)
|
335
|
+
results_match_exact(fqt, pyq, q)
|
336
|
+
|
337
|
+
# Also check a sweep of thin queries that align to the lines
|
338
|
+
thin_queries = [
|
339
|
+
(49.9, 0.0, 50.1, 100.0),
|
340
|
+
(0.0, 49.9, 100.0, 50.1),
|
341
|
+
(49.999, 10.0, 50.001, 90.0),
|
342
|
+
(10.0, 49.999, 90.0, 50.001),
|
343
|
+
]
|
344
|
+
for q in thin_queries:
|
345
|
+
results_match_exact(fqt, pyq, q)
|
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
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|