Skip to main content

scirs2_python/
spatial.rs

1//! Python bindings for scirs2-spatial
2//!
3//! Provides spatial algorithms similar to scipy.spatial
4
5use pyo3::prelude::*;
6use pyo3::types::PyDict;
7use scirs2_core::ndarray::Array2 as Array2_17;
8use scirs2_core::python::numpy_compat::{
9    scirs_to_numpy_array1, scirs_to_numpy_array2, Array1, Array2,
10};
11use scirs2_numpy::{PyArray1, PyArray2, PyReadonlyArray1, PyReadonlyArray2};
12
13// Import KDTree
14use scirs2_spatial::distance::EuclideanDistance;
15use scirs2_spatial::KDTree;
16
17// Import ConvexHull
18use scirs2_spatial::convex_hull::ConvexHull;
19
20// =============================================================================
21// Distance Functions
22// =============================================================================
23
24/// Euclidean distance between two points
25#[pyfunction]
26fn euclidean_py(u: PyReadonlyArray1<f64>, v: PyReadonlyArray1<f64>) -> PyResult<f64> {
27    let u_arr = u.as_array();
28    let v_arr = v.as_array();
29
30    if u_arr.len() != v_arr.len() {
31        return Err(pyo3::exceptions::PyValueError::new_err(
32            "Arrays must have same length",
33        ));
34    }
35
36    let dist: f64 = u_arr
37        .iter()
38        .zip(v_arr.iter())
39        .map(|(a, b)| (a - b).powi(2))
40        .sum::<f64>()
41        .sqrt();
42
43    Ok(dist)
44}
45
46/// Manhattan (city block) distance between two points
47#[pyfunction]
48fn cityblock_py(u: PyReadonlyArray1<f64>, v: PyReadonlyArray1<f64>) -> PyResult<f64> {
49    let u_arr = u.as_array();
50    let v_arr = v.as_array();
51
52    if u_arr.len() != v_arr.len() {
53        return Err(pyo3::exceptions::PyValueError::new_err(
54            "Arrays must have same length",
55        ));
56    }
57
58    let dist: f64 = u_arr
59        .iter()
60        .zip(v_arr.iter())
61        .map(|(a, b)| (a - b).abs())
62        .sum();
63
64    Ok(dist)
65}
66
67/// Chebyshev distance between two points
68#[pyfunction]
69fn chebyshev_py(u: PyReadonlyArray1<f64>, v: PyReadonlyArray1<f64>) -> PyResult<f64> {
70    let u_arr = u.as_array();
71    let v_arr = v.as_array();
72
73    if u_arr.len() != v_arr.len() {
74        return Err(pyo3::exceptions::PyValueError::new_err(
75            "Arrays must have same length",
76        ));
77    }
78
79    let dist: f64 = u_arr
80        .iter()
81        .zip(v_arr.iter())
82        .map(|(a, b)| (a - b).abs())
83        .fold(0.0, f64::max);
84
85    Ok(dist)
86}
87
88/// Minkowski distance between two points
89#[pyfunction]
90fn minkowski_py(u: PyReadonlyArray1<f64>, v: PyReadonlyArray1<f64>, p: f64) -> PyResult<f64> {
91    let u_arr = u.as_array();
92    let v_arr = v.as_array();
93
94    if u_arr.len() != v_arr.len() {
95        return Err(pyo3::exceptions::PyValueError::new_err(
96            "Arrays must have same length",
97        ));
98    }
99
100    let dist: f64 = u_arr
101        .iter()
102        .zip(v_arr.iter())
103        .map(|(a, b)| (a - b).abs().powf(p))
104        .sum::<f64>()
105        .powf(1.0 / p);
106
107    Ok(dist)
108}
109
110/// Cosine distance between two points
111#[pyfunction]
112fn cosine_py(u: PyReadonlyArray1<f64>, v: PyReadonlyArray1<f64>) -> PyResult<f64> {
113    let u_arr = u.as_array();
114    let v_arr = v.as_array();
115
116    if u_arr.len() != v_arr.len() {
117        return Err(pyo3::exceptions::PyValueError::new_err(
118            "Arrays must have same length",
119        ));
120    }
121
122    let dot: f64 = u_arr.iter().zip(v_arr.iter()).map(|(a, b)| a * b).sum();
123    let norm_u: f64 = u_arr.iter().map(|a| a.powi(2)).sum::<f64>().sqrt();
124    let norm_v: f64 = v_arr.iter().map(|a| a.powi(2)).sum::<f64>().sqrt();
125
126    if norm_u == 0.0 || norm_v == 0.0 {
127        return Err(pyo3::exceptions::PyValueError::new_err("Zero vector"));
128    }
129
130    Ok(1.0 - dot / (norm_u * norm_v))
131}
132
133/// Hamming distance between two points
134///
135/// Returns the proportion of coordinates at which `u` and `v` differ, matching
136/// `scipy.spatial.distance.hamming`.
137///
138/// This is intentionally *not* named `hamming_py` or `hamming_distance_py` to
139/// avoid two separate naming collisions:
140/// - `hamming_py` (in the `signal` bindings) is the Hamming *window* function
141///   used in signal processing, not a distance metric (see
142///   `tests/scipy_comparison/test_spatial_vs_scipy.py`).
143/// - `hamming_distance_py` is reserved for a string/character Hamming distance
144///   over equal-length strings (backed by
145///   `scirs2_text::simd_ops::basic_ops::SimdStringOps::hamming_distance`, see
146///   `tests/test_text.py::test_hamming_distance`), which counts differing
147///   characters rather than returning a normalized proportion over arrays.
148#[pyfunction]
149fn spatial_hamming_distance_py(
150    u: PyReadonlyArray1<f64>,
151    v: PyReadonlyArray1<f64>,
152) -> PyResult<f64> {
153    let u_arr = u.as_array();
154    let v_arr = v.as_array();
155
156    if u_arr.len() != v_arr.len() {
157        return Err(pyo3::exceptions::PyValueError::new_err(
158            "Arrays must have same length",
159        ));
160    }
161
162    if u_arr.is_empty() {
163        return Ok(0.0);
164    }
165
166    let n_diff = u_arr
167        .iter()
168        .zip(v_arr.iter())
169        .filter(|&(a, b)| (a - b).abs() > f64::EPSILON)
170        .count();
171
172    Ok(n_diff as f64 / u_arr.len() as f64)
173}
174
175// =============================================================================
176// Pairwise Distance Matrix
177// =============================================================================
178
179/// Compute pairwise distances between observations
180#[pyfunction]
181#[pyo3(signature = (x, metric="euclidean"))]
182fn pdist_py(py: Python, x: PyReadonlyArray2<f64>, metric: &str) -> PyResult<Py<PyArray1<f64>>> {
183    let x_arr = x.as_array();
184    let n = x_arr.nrows();
185
186    // Number of pairwise distances
187    let n_dist = n * (n - 1) / 2;
188    let mut result = Vec::with_capacity(n_dist);
189
190    for i in 0..n {
191        for j in (i + 1)..n {
192            let dist = match metric {
193                "euclidean" => x_arr
194                    .row(i)
195                    .iter()
196                    .zip(x_arr.row(j).iter())
197                    .map(|(a, b)| (a - b).powi(2))
198                    .sum::<f64>()
199                    .sqrt(),
200                "cityblock" | "manhattan" => x_arr
201                    .row(i)
202                    .iter()
203                    .zip(x_arr.row(j).iter())
204                    .map(|(a, b)| (a - b).abs())
205                    .sum(),
206                "chebyshev" => x_arr
207                    .row(i)
208                    .iter()
209                    .zip(x_arr.row(j).iter())
210                    .map(|(a, b)| (a - b).abs())
211                    .fold(0.0, f64::max),
212                "hamming" => {
213                    let n_cols = x_arr.ncols();
214                    if n_cols == 0 {
215                        0.0
216                    } else {
217                        let n_diff = x_arr
218                            .row(i)
219                            .iter()
220                            .zip(x_arr.row(j).iter())
221                            .filter(|&(a, b)| (a - b).abs() > f64::EPSILON)
222                            .count();
223                        n_diff as f64 / n_cols as f64
224                    }
225                }
226                _ => x_arr
227                    .row(i)
228                    .iter()
229                    .zip(x_arr.row(j).iter())
230                    .map(|(a, b)| (a - b).powi(2))
231                    .sum::<f64>()
232                    .sqrt(),
233            };
234            result.push(dist);
235        }
236    }
237
238    scirs_to_numpy_array1(Array1::from_vec(result), py)
239}
240
241/// Compute pairwise distances between two sets of observations
242#[pyfunction]
243#[pyo3(signature = (xa, xb, metric="euclidean"))]
244fn cdist_py(
245    py: Python,
246    xa: PyReadonlyArray2<f64>,
247    xb: PyReadonlyArray2<f64>,
248    metric: &str,
249) -> PyResult<Py<PyArray2<f64>>> {
250    let xa_arr = xa.as_array();
251    let xb_arr = xb.as_array();
252    let na = xa_arr.nrows();
253    let nb = xb_arr.nrows();
254
255    if xa_arr.ncols() != xb_arr.ncols() {
256        return Err(pyo3::exceptions::PyValueError::new_err(
257            "Arrays must have same number of columns",
258        ));
259    }
260
261    let mut result = Vec::with_capacity(na * nb);
262
263    for i in 0..na {
264        for j in 0..nb {
265            let dist = match metric {
266                "euclidean" => xa_arr
267                    .row(i)
268                    .iter()
269                    .zip(xb_arr.row(j).iter())
270                    .map(|(a, b)| (a - b).powi(2))
271                    .sum::<f64>()
272                    .sqrt(),
273                "cityblock" | "manhattan" => xa_arr
274                    .row(i)
275                    .iter()
276                    .zip(xb_arr.row(j).iter())
277                    .map(|(a, b)| (a - b).abs())
278                    .sum(),
279                "chebyshev" => xa_arr
280                    .row(i)
281                    .iter()
282                    .zip(xb_arr.row(j).iter())
283                    .map(|(a, b)| (a - b).abs())
284                    .fold(0.0, f64::max),
285                "hamming" => {
286                    let n_cols = xa_arr.ncols();
287                    if n_cols == 0 {
288                        0.0
289                    } else {
290                        let n_diff = xa_arr
291                            .row(i)
292                            .iter()
293                            .zip(xb_arr.row(j).iter())
294                            .filter(|&(a, b)| (a - b).abs() > f64::EPSILON)
295                            .count();
296                        n_diff as f64 / n_cols as f64
297                    }
298                }
299                _ => xa_arr
300                    .row(i)
301                    .iter()
302                    .zip(xb_arr.row(j).iter())
303                    .map(|(a, b)| (a - b).powi(2))
304                    .sum::<f64>()
305                    .sqrt(),
306            };
307            result.push(dist);
308        }
309    }
310
311    // Reshape to 2D array
312    let arr = Array2::from_shape_vec((na, nb), result)
313        .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("{e}")))?;
314
315    scirs_to_numpy_array2(arr, py)
316}
317
318/// Convert condensed distance matrix to square form
319#[pyfunction]
320fn squareform_py(py: Python, x: PyReadonlyArray1<f64>) -> PyResult<Py<PyArray2<f64>>> {
321    let x_arr = x.as_array();
322    let n_dist = x_arr.len();
323
324    // Solve n*(n-1)/2 = n_dist for n
325    let n = ((1.0 + (1.0 + 8.0 * n_dist as f64).sqrt()) / 2.0) as usize;
326
327    let mut result = Array2::zeros((n, n));
328
329    let mut idx = 0;
330    for i in 0..n {
331        for j in (i + 1)..n {
332            result[[i, j]] = x_arr[idx];
333            result[[j, i]] = x_arr[idx];
334            idx += 1;
335        }
336    }
337
338    scirs_to_numpy_array2(result, py)
339}
340
341// =============================================================================
342// Convex Hull
343// =============================================================================
344
345/// Compute the convex hull of a set of points
346///
347/// Returns indices of points that form the convex hull vertices
348#[pyfunction]
349fn convex_hull_py(py: Python, points: PyReadonlyArray2<f64>) -> PyResult<Py<PyAny>> {
350    let points_arr = points.as_array();
351    let n = points_arr.nrows();
352    let k = points_arr.ncols();
353
354    // Convert to Array2_17
355    let mut pts = Vec::with_capacity(n * k);
356    for row in points_arr.rows() {
357        for &val in row.iter() {
358            pts.push(val);
359        }
360    }
361    let arr = Array2_17::from_shape_vec((n, k), pts)
362        .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("{e}")))?;
363
364    let hull = ConvexHull::new(&arr.view())
365        .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("{e}")))?;
366
367    // Get vertices and simplices
368    let vertices: Vec<i64> = hull.vertex_indices().iter().map(|&i| i as i64).collect();
369    let simplices: Vec<Vec<i64>> = hull
370        .simplices()
371        .iter()
372        .map(|s| s.iter().map(|&i| i as i64).collect())
373        .collect();
374
375    // Calculate volume and area
376    let volume = hull.volume().unwrap_or(0.0);
377    let area = hull.area().unwrap_or(0.0);
378
379    let dict = PyDict::new(py);
380    dict.set_item(
381        "vertices",
382        scirs_to_numpy_array1(Array1::from_vec(vertices), py)?,
383    )?;
384
385    // Convert simplices to a flat representation for Python
386    let simplices_py: Vec<Vec<i64>> = simplices;
387    dict.set_item("simplices", simplices_py)?;
388    dict.set_item("volume", volume)?;
389    dict.set_item("area", area)?;
390
391    Ok(dict.into())
392}
393
394/// ConvexHull class for working with convex hulls
395#[pyclass(name = "ConvexHullPy", unsendable)]
396pub struct PyConvexHull {
397    hull: ConvexHull,
398}
399
400#[pymethods]
401impl PyConvexHull {
402    /// Create a new ConvexHull from a 2D array of points
403    ///
404    /// Parameters:
405    /// - points: Array of shape (n, k) containing n points in k dimensions
406    #[new]
407    fn new(points: PyReadonlyArray2<f64>) -> PyResult<Self> {
408        let points_arr = points.as_array();
409        let n = points_arr.nrows();
410        let k = points_arr.ncols();
411
412        // Convert to Array2_17
413        let mut pts = Vec::with_capacity(n * k);
414        for row in points_arr.rows() {
415            for &val in row.iter() {
416                pts.push(val);
417            }
418        }
419        let arr = Array2_17::from_shape_vec((n, k), pts)
420            .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("{e}")))?;
421
422        let hull = ConvexHull::new(&arr.view())
423            .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("{e}")))?;
424
425        Ok(PyConvexHull { hull })
426    }
427
428    /// Get the indices of vertices that form the convex hull
429    fn vertices(&self, py: Python) -> PyResult<Py<PyArray1<i64>>> {
430        let vertices: Vec<i64> = self
431            .hull
432            .vertex_indices()
433            .iter()
434            .map(|&i| i as i64)
435            .collect();
436        scirs_to_numpy_array1(Array1::from_vec(vertices), py)
437    }
438
439    /// Get the simplices (facets) of the convex hull
440    fn simplices(&self) -> Vec<Vec<i64>> {
441        self.hull
442            .simplices()
443            .iter()
444            .map(|s| s.iter().map(|&i| i as i64).collect())
445            .collect()
446    }
447
448    /// Calculate the volume of the convex hull
449    fn volume(&self) -> PyResult<f64> {
450        self.hull
451            .volume()
452            .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("{e}")))
453    }
454
455    /// Calculate the surface area of the convex hull
456    fn area(&self) -> PyResult<f64> {
457        self.hull
458            .area()
459            .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("{e}")))
460    }
461
462    /// Check if a point is inside the convex hull
463    fn contains(&self, point: PyReadonlyArray1<f64>) -> PyResult<bool> {
464        let point_vec: Vec<f64> = point.as_array().to_vec();
465        self.hull
466            .contains(&point_vec)
467            .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("{e}")))
468    }
469}
470
471// =============================================================================
472// KD-Tree
473// =============================================================================
474
475/// KD-Tree for efficient nearest neighbor searches
476#[pyclass(name = "KDTree")]
477pub struct PyKDTree {
478    tree: KDTree<f64, EuclideanDistance<f64>>,
479}
480
481#[pymethods]
482impl PyKDTree {
483    /// Create a new KD-Tree from a 2D array of points
484    ///
485    /// Parameters:
486    /// - data: Array of shape (n, k) containing n points in k dimensions
487    #[new]
488    fn new(data: PyReadonlyArray2<f64>) -> PyResult<Self> {
489        let data_arr = data.as_array();
490        let n = data_arr.nrows();
491        let k = data_arr.ncols();
492
493        // Convert to Array2_17
494        let mut points = Vec::with_capacity(n * k);
495        for row in data_arr.rows() {
496            for &val in row.iter() {
497                points.push(val);
498            }
499        }
500        let arr = Array2_17::from_shape_vec((n, k), points)
501            .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("{e}")))?;
502
503        let tree = KDTree::new(&arr)
504            .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("{e}")))?;
505
506        Ok(PyKDTree { tree })
507    }
508
509    /// Query the tree for the k nearest neighbors to a point
510    ///
511    /// Parameters:
512    /// - point: Query point
513    /// - k: Number of nearest neighbors to find
514    ///
515    /// Returns:
516    /// - Tuple of (indices, distances) arrays
517    fn query(&self, py: Python, point: PyReadonlyArray1<f64>, k: usize) -> PyResult<Py<PyAny>> {
518        let point_vec: Vec<f64> = point.as_array().to_vec();
519
520        let (indices, distances) = self
521            .tree
522            .query(&point_vec, k)
523            .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("{e}")))?;
524
525        let dict = PyDict::new(py);
526        dict.set_item(
527            "indices",
528            scirs_to_numpy_array1(
529                Array1::from_vec(indices.iter().map(|&i| i as i64).collect()),
530                py,
531            )?,
532        )?;
533        dict.set_item(
534            "distances",
535            scirs_to_numpy_array1(Array1::from_vec(distances), py)?,
536        )?;
537
538        Ok(dict.into())
539    }
540
541    /// Query the tree for all points within a given radius
542    ///
543    /// Parameters:
544    /// - point: Query point
545    /// - r: Radius
546    ///
547    /// Returns:
548    /// - Tuple of (indices, distances) arrays
549    fn query_radius(
550        &self,
551        py: Python,
552        point: PyReadonlyArray1<f64>,
553        r: f64,
554    ) -> PyResult<Py<PyAny>> {
555        let point_vec: Vec<f64> = point.as_array().to_vec();
556
557        let (indices, distances) = self
558            .tree
559            .query_radius(&point_vec, r)
560            .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("{e}")))?;
561
562        let dict = PyDict::new(py);
563        dict.set_item(
564            "indices",
565            scirs_to_numpy_array1(
566                Array1::from_vec(indices.iter().map(|&i| i as i64).collect()),
567                py,
568            )?,
569        )?;
570        dict.set_item(
571            "distances",
572            scirs_to_numpy_array1(Array1::from_vec(distances), py)?,
573        )?;
574
575        Ok(dict.into())
576    }
577}
578
579/// Python module registration
580pub fn register_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
581    // Distance functions
582    m.add_function(wrap_pyfunction!(euclidean_py, m)?)?;
583    m.add_function(wrap_pyfunction!(cityblock_py, m)?)?;
584    m.add_function(wrap_pyfunction!(chebyshev_py, m)?)?;
585    m.add_function(wrap_pyfunction!(minkowski_py, m)?)?;
586    m.add_function(wrap_pyfunction!(cosine_py, m)?)?;
587    m.add_function(wrap_pyfunction!(spatial_hamming_distance_py, m)?)?;
588
589    // Pairwise distances
590    m.add_function(wrap_pyfunction!(pdist_py, m)?)?;
591    m.add_function(wrap_pyfunction!(cdist_py, m)?)?;
592    m.add_function(wrap_pyfunction!(squareform_py, m)?)?;
593
594    // Convex hull
595    m.add_function(wrap_pyfunction!(convex_hull_py, m)?)?;
596    m.add_class::<PyConvexHull>()?;
597
598    // Spatial data structures
599    m.add_class::<PyKDTree>()?;
600
601    Ok(())
602}