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.
Files changed (74) hide show
  1. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/.pre-commit-config.yaml +2 -0
  2. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/Cargo.lock +91 -1
  3. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/Cargo.toml +2 -1
  4. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/PKG-INFO +4 -2
  5. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/README.md +2 -1
  6. fastquadtree-1.1.0/benchmarks/benchmark_np_vs_list.py +178 -0
  7. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/benchmarks/requirements.txt +4 -1
  8. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/docs/benchmark.md +30 -0
  9. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/docs/index.md +1 -1
  10. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/mkdocs.yml +3 -3
  11. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/pyproject.toml +2 -0
  12. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/pysrc/fastquadtree/_base_quadtree.py +51 -5
  13. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/src/lib.rs +60 -0
  14. fastquadtree-1.1.0/tests/test_insert_many_numpy.py +183 -0
  15. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/tests/test_pyqtree_shim_compat.py +97 -0
  16. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/.github/workflows/docs.yml +0 -0
  17. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/.github/workflows/release.yml +0 -0
  18. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/.gitignore +0 -0
  19. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/LICENSE +0 -0
  20. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/assets/ballpit.png +0 -0
  21. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/assets/interactive_v2_rect_screenshot.png +0 -0
  22. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/assets/interactive_v2_screenshot.png +0 -0
  23. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/assets/quadtree_bench_throughput.png +0 -0
  24. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/assets/quadtree_bench_time.png +0 -0
  25. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/benchmarks/benchmark_native_vs_shim.py +0 -0
  26. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/benchmarks/cross_library_bench.py +0 -0
  27. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/benchmarks/quadtree_bench/__init__.py +0 -0
  28. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/benchmarks/quadtree_bench/engines.py +0 -0
  29. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/benchmarks/quadtree_bench/main.py +0 -0
  30. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/benchmarks/quadtree_bench/plotting.py +0 -0
  31. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/benchmarks/quadtree_bench/runner.py +0 -0
  32. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/benchmarks/runner.py +0 -0
  33. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/benchmarks/system_info_collector.py +0 -0
  34. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/docs/api/point_item.md +0 -0
  35. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/docs/api/pyqtree.md +0 -0
  36. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/docs/api/quadtree.md +0 -0
  37. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/docs/api/rect_item.md +0 -0
  38. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/docs/api/rect_quadtree.md +0 -0
  39. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/docs/future_features.md +0 -0
  40. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/docs/quickstart.md +0 -0
  41. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/docs/runnables.md +0 -0
  42. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/docs/styles/overrides.css +0 -0
  43. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/interactive/ballpit.py +0 -0
  44. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/interactive/interactive.py +0 -0
  45. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/interactive/interactive_v2.py +0 -0
  46. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/interactive/interactive_v2_rect.py +0 -0
  47. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/interactive/requirements.txt +0 -0
  48. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/pysrc/fastquadtree/__init__.py +0 -0
  49. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/pysrc/fastquadtree/_item.py +0 -0
  50. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/pysrc/fastquadtree/_obj_store.py +0 -0
  51. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/pysrc/fastquadtree/point_quadtree.py +0 -0
  52. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/pysrc/fastquadtree/py.typed +0 -0
  53. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/pysrc/fastquadtree/pyqtree.py +0 -0
  54. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/pysrc/fastquadtree/rect_quadtree.py +0 -0
  55. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/src/geom.rs +0 -0
  56. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/src/quadtree.rs +0 -0
  57. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/src/rect_quadtree.rs +0 -0
  58. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/tests/insertions.rs +0 -0
  59. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/tests/nearest_neighbor.rs +0 -0
  60. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/tests/query.rs +0 -0
  61. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/tests/rect_quadtree.rs +0 -0
  62. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/tests/rectangle_traversal.rs +0 -0
  63. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/tests/test_base_quadtree.py +0 -0
  64. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/tests/test_clear.py +0 -0
  65. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/tests/test_delete.rs +0 -0
  66. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/tests/test_delete_by_object.py +0 -0
  67. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/tests/test_delete_python.py +0 -0
  68. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/tests/test_obj_store.py +0 -0
  69. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/tests/test_point_quadtree_nn_runtime.py +0 -0
  70. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/tests/test_python.py +0 -0
  71. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/tests/test_rect_quadtree.py +0 -0
  72. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/tests/test_unconventional_bounds.py +0 -0
  73. {fastquadtree-1.0.2 → fastquadtree-1.1.0}/tests/test_wrapper_edges.py +0 -0
  74. {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.2"
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.2"
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.2
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
  [![Python versions](https://img.shields.io/pypi/pyversions/fastquadtree.svg)](https://pypi.org/project/fastquadtree/)
49
50
  [![Downloads](https://static.pepy.tech/personalized-badge/fastquadtree?period=total&units=INTERNATIONAL_SYSTEM&left_color=GRAY&right_color=BLUE&left_text=Total%20Downloads)](https://pepy.tech/projects/fastquadtree)
50
51
  [![Build](https://github.com/Elan456/fastquadtree/actions/workflows/release.yml/badge.svg)](https://github.com/Elan456/fastquadtree/actions/workflows/release.yml)
52
+ ![No runtime deps](https://img.shields.io/badge/deps-none-success)
51
53
 
52
54
  [![PyO3](https://img.shields.io/badge/Rust-core%20via%20PyO3-orange)](https://pyo3.rs/)
53
55
  [![maturin](https://img.shields.io/badge/Built%20with-maturin-1f6feb)](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
  [![Python versions](https://img.shields.io/pypi/pyversions/fastquadtree.svg)](https://pypi.org/project/fastquadtree/)
10
10
  [![Downloads](https://static.pepy.tech/personalized-badge/fastquadtree?period=total&units=INTERNATIONAL_SYSTEM&left_color=GRAY&right_color=BLUE&left_text=Total%20Downloads)](https://pepy.tech/projects/fastquadtree)
11
11
  [![Build](https://github.com/Elan456/fastquadtree/actions/workflows/release.yml/badge.svg)](https://github.com/Elan456/fastquadtree/actions/workflows/release.yml)
12
+ ![No runtime deps](https://img.shields.io/badge/deps-none-success)
12
13
 
13
14
  [![PyO3](https://img.shields.io/badge/Rust-core%20via%20PyO3-orange)](https://pyo3.rs/)
14
15
  [![maturin](https://img.shields.io/badge/Built%20with-maturin-1f6feb)](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()
@@ -17,4 +17,7 @@ shapely>=2.0.0 # STRtree comparator
17
17
  # For getting system info
18
18
  psutil
19
19
  py-cpuinfo
20
- distro
20
+ distro
21
+
22
+ # For benchmarking NumPy bulk insert vs list insert
23
+ numpy
@@ -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 Any, Generic, Iterable, Tuple, TypeVar
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
- def insert_many(self, geoms: list[G], objs: list[Any] | None = None) -> int:
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 not geoms:
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
- last_id = self._native.insert_many(start_id, geoms)
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
- last_id = self._native.insert_many(start_id, geoms)
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