Skip to content

Commit 4554c5b

Browse files
Fix intersection bug and add Python 3.13 support (v0.7.0)
BREAKING CHANGES: - Changed internal Real type from float to double to fix precision bug - Binary serialization format changed (incompatible with v0.6.x) - Updated pybind11 from v2.12.0 to v2.13.6 Bug Fix: - Fixed critical bug where boxes with small gaps (< 1e-5) were incorrectly reported as intersecting due to float32 precision loss - Root cause: PRTree constructor and insert method were using py::array_t<float> instead of py::array_t<Real>, causing float64 -> float32 conversion - Example: boxes separated by 5.39e-06 would collapse to same float32 value Changes: - cpp/prtree.h: Changed 'using Real = float' to 'using Real = double' - cpp/prtree.h: Updated PRTree constructor to use py::array_t<Real> - cpp/prtree.h: Updated insert method to use py::array_t<Real> - cpp/main.cc: Updated all pybind11 bindings to use py::array_t<double> - third/pybind11: Updated submodule from v2.12.0 to v2.13.6 - setup.py: Bumped version to v0.7.0, added Python 3.13 support - requirements.txt: Removed numpy<2.0 constraint for numpy 2.0 support - .github/workflows/cibuildwheel.yml: Added Python 3.13 builds - README.md: Added breaking changes notice and bug fix details - tests/test_PRTree.py: Added comprehensive tests for: - Disjoint boxes with small gaps (regression test) - Touching boxes (closed interval semantics) - Large magnitude coordinates - Degenerate boxes - Query vs batch_query consistency All 121 tests pass. Fixes issue reported by Matteo Lacki. Co-Authored-By: atksh <[email protected]>
1 parent 4624469 commit 4554c5b

File tree

8 files changed

+171
-9
lines changed

8 files changed

+171
-9
lines changed

.github/workflows/cibuildwheel.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ jobs:
5151
python: 312
5252
platform_id: win_amd64
5353
arch: AMD64
54+
- os: windows-latest
55+
python: 313
56+
platform_id: win_amd64
57+
arch: AMD64
5458

5559
# Linux 64 bit manylinux2014
5660
- os: ubuntu-latest
@@ -88,6 +92,11 @@ jobs:
8892
platform_id: manylinux_x86_64
8993
manylinux_image: manylinux2014
9094
arch: x86_64
95+
- os: ubuntu-latest
96+
python: 313
97+
platform_id: manylinux_x86_64
98+
manylinux_image: manylinux2014
99+
arch: x86_64
91100

92101
# Linux 64 bit aarch64
93102
- os: ubuntu-latest
@@ -125,6 +134,11 @@ jobs:
125134
platform_id: manylinux_aarch64
126135
manylinux_image: manylinux2014
127136
arch: aarch64
137+
- os: ubuntu-latest
138+
python: 313
139+
platform_id: manylinux_aarch64
140+
manylinux_image: manylinux2014
141+
arch: aarch64
128142

129143
# MacOS x86_64
130144
- os: macos-13
@@ -162,6 +176,11 @@ jobs:
162176
platform_id: macosx_x86_64
163177
macosx_deployment_target: 10.14
164178
arch: x86_64
179+
- os: macos-13
180+
python: 313
181+
platform_id: macosx_x86_64
182+
macosx_deployment_target: 10.14
183+
arch: x86_64
165184

166185
# MacOS arm64
167186
- os: macos-14
@@ -189,6 +208,11 @@ jobs:
189208
platform_id: macosx_arm64
190209
macosx_deployment_target: 11.7
191210
arch: arm64
211+
- os: macos-14
212+
python: 313
213+
platform_id: macosx_arm64
214+
macosx_deployment_target: 11.7
215+
arch: arm64
192216

193217

194218
steps:

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,20 @@ Note that cross-version compatibility is **NOT** guaranteed, so please reconstru
174174

175175
## New Features and Changes
176176

177+
### `python-prtree>=0.7.0`
178+
179+
**BREAKING CHANGES:**
180+
181+
- **Fixed critical intersection bug**: Boxes with small gaps (< 1e-5) were incorrectly reported as intersecting due to float32 precision loss. The internal `Real` type has been changed from `float` to `double` to preserve float64 precision from NumPy arrays.
182+
- **Serialization format changed**: Binary files saved with previous versions are incompatible with 0.7.0+. You must rebuild and re-save your trees after upgrading.
183+
- **Updated pybind11**: Upgraded from v2.12.0 to v2.13.6 for Python 3.13+ support.
184+
- **Python 3.13 support**: Added official support for Python 3.13.
185+
- **Improved test coverage**: Added comprehensive tests for edge cases including disjoint boxes with small gaps, touching boxes, large magnitude coordinates, and degenerate boxes.
186+
187+
**Bug Fix Details:**
188+
189+
The bug occurred when two bounding boxes were separated by a very small gap (e.g., 5.39e-06). When converted from float64 to float32, the values would collapse to the same float32 value, causing the intersection check to incorrectly report them as intersecting. This has been fixed by using double precision throughout the library.
190+
177191
### `python-prtree>=0.5.8`
178192

179193
- The insert method has been improved to select the node with the smallest mbb expansion.

cpp/main.cc

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ PYBIND11_MODULE(PRTree, m)
1818
)pbdoc";
1919

2020
py::class_<PRTree<T, B, 2>>(m, "_PRTree2D")
21-
.def(py::init<py::array_t<T>, py::array_t<float>>(), R"pbdoc(
21+
.def(py::init<py::array_t<T>, py::array_t<double>>(), R"pbdoc(
2222
Construct PRTree with init.
2323
)pbdoc")
2424
.def(py::init<>(), R"pbdoc(
@@ -62,7 +62,7 @@ PYBIND11_MODULE(PRTree, m)
6262
)pbdoc");
6363

6464
py::class_<PRTree<T, B, 3>>(m, "_PRTree3D")
65-
.def(py::init<py::array_t<T>, py::array_t<float>>(), R"pbdoc(
65+
.def(py::init<py::array_t<T>, py::array_t<double>>(), R"pbdoc(
6666
Construct PRTree with init.
6767
)pbdoc")
6868
.def(py::init<>(), R"pbdoc(
@@ -106,7 +106,7 @@ PYBIND11_MODULE(PRTree, m)
106106
)pbdoc");
107107

108108
py::class_<PRTree<T, B, 4>>(m, "_PRTree4D")
109-
.def(py::init<py::array_t<T>, py::array_t<float>>(), R"pbdoc(
109+
.def(py::init<py::array_t<T>, py::array_t<double>>(), R"pbdoc(
110110
Construct PRTree with init.
111111
)pbdoc")
112112
.def(py::init<>(), R"pbdoc(

cpp/prtree.h

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
#include <gperftools/profiler.h>
4545
#endif
4646

47-
using Real = float;
47+
using Real = double;
4848

4949
namespace py = pybind11;
5050

@@ -790,7 +790,7 @@ class PRTree
790790

791791
PRTree(std::string fname) { load(fname); }
792792

793-
PRTree(const py::array_t<T> &idx, const py::array_t<float> &x)
793+
PRTree(const py::array_t<T> &idx, const py::array_t<Real> &x)
794794
{
795795
const auto &buff_info_idx = idx.request();
796796
const auto &shape_idx = buff_info_idx.shape;
@@ -869,7 +869,7 @@ class PRTree
869869
return obj;
870870
}
871871

872-
void insert(const T &idx, const py::array_t<float> &x, const std::optional<std::string> objdumps = std::nullopt)
872+
void insert(const T &idx, const py::array_t<Real> &x, const std::optional<std::string> objdumps = std::nullopt)
873873
{
874874
#ifdef MY_DEBUG
875875
ProfilerStart("insert.prof");

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
numpy>=1.16,<2.0
1+
numpy>=1.16

setup.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from setuptools import Extension, find_packages, setup
1010
from setuptools.command.build_ext import build_ext
1111

12-
version = "v0.6.1"
12+
version = "v0.7.0"
1313

1414
sys.path.append("./tests")
1515

@@ -114,5 +114,6 @@ def build_extension(self, ext):
114114
"Programming Language :: Python :: 3.10",
115115
"Programming Language :: Python :: 3.11",
116116
"Programming Language :: Python :: 3.12",
117+
"Programming Language :: Python :: 3.13",
117118
],
118119
)

tests/test_PRTree.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,3 +168,126 @@ def test_readme():
168168
# point query
169169
assert prtree.query([0.5, 0.5]) == [1]
170170
assert prtree.query(0.5, 0.5) == [1]
171+
172+
173+
@pytest.mark.parametrize("PRTree, dim", [(PRTree2D, 2), (PRTree3D, 3), (PRTree4D, 4)])
174+
def test_disjoint_small_gap(PRTree, dim):
175+
"""Test for the bug reported by Matteo Lacki where boxes with small gaps are incorrectly reported as intersecting.
176+
177+
This was caused by float32 precision loss where values like 75.02750896 and 75.02751435
178+
would collapse to the same float32 value (75.02751159667968750000).
179+
"""
180+
if dim == 2:
181+
A = np.array([[72.47410062, 80.52848893, 75.02750896, 85.40646976]])
182+
B = np.array([[75.02751435, 80.0, 78.71358218, 85.0]])
183+
gap_dim = 0
184+
elif dim == 3:
185+
A = np.array([[72.47410062, 80.52848893, 54.68197159, 75.02750896, 85.40646976, 62.42859506]])
186+
B = np.array([[75.02751435, 74.65699325, 61.09751679, 78.71358218, 82.4585436, 67.24904609]])
187+
gap_dim = 0
188+
else: # dim == 4
189+
A = np.array([[72.47410062, 80.52848893, 54.68197159, 60.0, 75.02750896, 85.40646976, 62.42859506, 70.0]])
190+
B = np.array([[75.02751435, 74.65699325, 61.09751679, 55.0, 78.71358218, 82.4585436, 67.24904609, 65.0]])
191+
gap_dim = 0
192+
193+
assert A[0][gap_dim + dim] < B[0][gap_dim], f"A_max ({A[0][gap_dim + dim]}) should be < B_min ({B[0][gap_dim]})"
194+
gap = B[0][gap_dim] - A[0][gap_dim + dim]
195+
assert gap > 0, f"Gap should be positive, got {gap}"
196+
197+
tree = PRTree(np.array([0]), A)
198+
199+
result = tree.batch_query(B)
200+
assert result == [[]], f"Expected [[]] (no intersection), got {result}. Gap was {gap}"
201+
202+
result_query = tree.query(B[0])
203+
assert result_query == [], f"Expected [] (no intersection), got {result_query}. Gap was {gap}"
204+
205+
206+
@pytest.mark.parametrize("PRTree, dim", [(PRTree2D, 2), (PRTree3D, 3), (PRTree4D, 4)])
207+
def test_touching_boxes(PRTree, dim):
208+
"""Test that boxes that exactly touch (share a boundary) are considered intersecting.
209+
210+
This documents the intended closed-interval semantics where touching counts as intersecting.
211+
"""
212+
A = np.zeros((1, 2 * dim))
213+
B = np.zeros((1, 2 * dim))
214+
215+
for i in range(dim):
216+
A[0][i] = 0.0 # min coords
217+
A[0][i + dim] = 1.0 # max coords
218+
B[0][i] = 1.0 # min coords
219+
B[0][i + dim] = 2.0 # max coords
220+
221+
tree = PRTree(np.array([0]), A)
222+
223+
result = tree.batch_query(B)
224+
assert result == [[0]], f"Expected [[0]] (touching boxes intersect), got {result}"
225+
226+
result_query = tree.query(B[0])
227+
assert result_query == [0], f"Expected [0] (touching boxes intersect), got {result_query}"
228+
229+
230+
@pytest.mark.parametrize("PRTree, dim", [(PRTree2D, 2), (PRTree3D, 3), (PRTree4D, 4)])
231+
def test_large_magnitude_coords(PRTree, dim):
232+
"""Test boxes with large magnitude coordinates to ensure precision is maintained."""
233+
A = np.zeros((1, 2 * dim))
234+
B = np.zeros((1, 2 * dim))
235+
236+
base = 1e6
237+
for i in range(dim):
238+
A[0][i] = base + i # min coords
239+
A[0][i + dim] = base + i + 1.0 # max coords
240+
B[0][i] = base + i + 1.1 # min coords (larger gap for double precision limits)
241+
B[0][i + dim] = base + i + 2.0 # max coords
242+
243+
tree = PRTree(np.array([0]), A)
244+
245+
result = tree.batch_query(B)
246+
assert result == [[]], f"Expected [[]] (no intersection at large magnitude), got {result}"
247+
248+
249+
@pytest.mark.parametrize("PRTree, dim", [(PRTree2D, 2), (PRTree3D, 3), (PRTree4D, 4)])
250+
def test_degenerate_boxes(PRTree, dim):
251+
"""Test boxes with zero volume (degenerate in one or more dimensions)."""
252+
A = np.zeros((1, 2 * dim))
253+
B = np.zeros((1, 2 * dim))
254+
255+
for i in range(dim):
256+
if i == 0:
257+
A[0][i] = 1.0
258+
A[0][i + dim] = 1.0
259+
B[0][i] = 1.0
260+
B[0][i + dim] = 1.0
261+
else:
262+
A[0][i] = 0.0
263+
A[0][i + dim] = 1.0
264+
B[0][i] = 0.5
265+
B[0][i + dim] = 1.5
266+
267+
tree = PRTree(np.array([0]), A)
268+
269+
result = tree.batch_query(B)
270+
assert result == [[0]], f"Expected [[0]] (degenerate boxes intersect), got {result}"
271+
272+
273+
@pytest.mark.parametrize("PRTree, dim", [(PRTree2D, 2), (PRTree3D, 3), (PRTree4D, 4)])
274+
def test_query_vs_batch_query_consistency(PRTree, dim):
275+
"""Test that query and batch_query return consistent results."""
276+
np.random.seed(42)
277+
N = 50
278+
idx = np.arange(N)
279+
x = np.random.rand(N, 2 * dim) * 100 # Use larger range to test precision
280+
for i in range(dim):
281+
x[:, i + dim] += x[:, i] + 0.1 # Ensure valid boxes with small width
282+
283+
tree = PRTree(idx, x)
284+
285+
queries = np.random.rand(20, 2 * dim) * 100
286+
for i in range(dim):
287+
queries[:, i + dim] += queries[:, i] + 0.1
288+
289+
batch_results = tree.batch_query(queries)
290+
for i, query in enumerate(queries):
291+
single_result = tree.query(query)
292+
assert set(batch_results[i]) == set(single_result), \
293+
f"Query {i}: batch_query returned {batch_results[i]}, query returned {single_result}"

third/pybind11

Submodule pybind11 updated 178 files

0 commit comments

Comments
 (0)