Skip to main content

trx_rs/ops/
streamline_ops.rs

1use std::cmp::Ordering;
2use std::collections::{HashMap, HashSet};
3use std::hash::{Hash, Hasher};
4
5use crate::dtype::TrxScalar;
6use crate::error::Result;
7use crate::tractogram::Tractogram;
8use crate::trx_file::TrxFile;
9
10use super::subset::subset_streamlines;
11
12/// A hashable representation of a streamline for set operations.
13/// Uses the raw bytes of the positions to compute identity.
14#[derive(Clone)]
15struct StreamlineKey(Vec<u8>);
16
17impl PartialEq for StreamlineKey {
18    fn eq(&self, other: &Self) -> bool {
19        self.0 == other.0
20    }
21}
22
23impl Eq for StreamlineKey {}
24
25impl Hash for StreamlineKey {
26    fn hash<H: Hasher>(&self, state: &mut H) {
27        self.0.hash(state);
28    }
29}
30
31fn streamline_key<P: TrxScalar>(points: &[[P; 3]]) -> StreamlineKey {
32    StreamlineKey(bytemuck::cast_slice::<[P; 3], u8>(points).to_vec())
33}
34
35/// Compute the set of streamline indices to retain after duplicate removal.
36///
37/// Returns a sorted `Vec<usize>` of indices that are kept according to `params`.
38/// The returned indices can be passed to [`subset_streamlines`] to produce the
39/// deduplicated file.
40pub fn retain_representative_indices<P: TrxScalar>(
41    trx: &TrxFile<P>,
42    params: &DuplicateRemovalParams,
43) -> Vec<usize> {
44    retain_representative_indices_impl(
45        trx.offsets().len().saturating_sub(1),
46        |index| {
47            trx.streamline(index)
48                .iter()
49                .map(|point| [point[0].to_f32(), point[1].to_f32(), point[2].to_f32()])
50                .collect()
51        },
52        params,
53    )
54}
55
56/// Remove duplicate streamlines from a [`TrxFile`], returning a new file.
57///
58/// This is a convenience wrapper around [`retain_representative_indices`]
59/// followed by [`subset_streamlines`].
60pub fn remove_duplicates<P: TrxScalar>(
61    trx: &TrxFile<P>,
62    params: &DuplicateRemovalParams,
63) -> Result<TrxFile<P>> {
64    let indices = retain_representative_indices(trx, params);
65    subset_streamlines(trx, &indices)
66}
67
68/// Compute duplicate-removal indices for a [`Tractogram`] (the format-neutral representation).
69///
70/// Equivalent to [`retain_representative_indices`] but operates on [`Tractogram`]
71/// instead of [`TrxFile`].
72pub fn retain_tractogram_representative_indices(
73    tractogram: &Tractogram,
74    params: &DuplicateRemovalParams,
75) -> Vec<usize> {
76    retain_representative_indices_impl(
77        tractogram.nb_streamlines(),
78        |index| tractogram.streamline(index).to_vec(),
79        params,
80    )
81}
82
83/// Remove duplicate streamlines from a [`Tractogram`], returning a new tractogram.
84///
85/// Convenience wrapper around [`retain_tractogram_representative_indices`] followed by
86/// [`Tractogram::subset_streamlines`].
87pub fn remove_duplicates_tractogram(
88    tractogram: &Tractogram,
89    params: &DuplicateRemovalParams,
90) -> Result<Tractogram> {
91    let indices = retain_tractogram_representative_indices(tractogram, params);
92    tractogram.subset_streamlines(&indices)
93}
94
95fn retain_representative_indices_impl(
96    streamline_count: usize,
97    mut points_for_index: impl FnMut(usize) -> Vec<[f32; 3]>,
98    params: &DuplicateRemovalParams,
99) -> Vec<usize> {
100    let mut retained = Vec::<usize>::new();
101    let mut exact_seen = HashSet::<StreamlineKey>::new();
102
103    if matches!(params.mode, DuplicateRemovalMode::Exact) {
104        for streamline_index in 0..streamline_count {
105            let points = canonicalize_streamline_points(&points_for_index(streamline_index));
106            if exact_seen.insert(canonical_streamline_key(&points)) {
107                retained.push(streamline_index);
108            }
109        }
110        return retained;
111    }
112
113    let mut representatives = Vec::<RepresentativeDescriptor>::new();
114    let mut endpoint_buckets = HashMap::<(VoxelKey, VoxelKey), Vec<usize>>::new();
115
116    for streamline_index in 0..streamline_count {
117        let points = canonicalize_streamline_points(&points_for_index(streamline_index));
118        if !exact_seen.insert(canonical_streamline_key(&points)) {
119            continue;
120        }
121
122        let descriptor = RepresentativeDescriptor::from_points(&points, params);
123        let mut candidate_indices = HashSet::<usize>::new();
124        for start_bucket in neighboring_voxels(descriptor.start_bucket, 1) {
125            for end_bucket in neighboring_voxels(descriptor.end_bucket, 1) {
126                if let Some(indices) = endpoint_buckets.get(&(start_bucket, end_bucket)) {
127                    candidate_indices.extend(indices.iter().copied());
128                }
129            }
130        }
131
132        let is_duplicate = candidate_indices.into_iter().any(|candidate_index| {
133            representatives_match(&representatives[candidate_index], &descriptor, params)
134        });
135        if is_duplicate {
136            continue;
137        }
138
139        let representative_index = representatives.len();
140        representatives.push(descriptor);
141        endpoint_buckets
142            .entry((
143                representatives[representative_index].start_bucket,
144                representatives[representative_index].end_bucket,
145            ))
146            .or_default()
147            .push(representative_index);
148        retained.push(streamline_index);
149    }
150
151    retained
152}
153
154impl RepresentativeDescriptor {
155    fn from_points(points: &[[f32; 3]], params: &DuplicateRemovalParams) -> Self {
156        let (bbox_min, bbox_max) = compute_bounding_box(points);
157        let start = points.first().copied().unwrap_or([0.0, 0.0, 0.0]);
158        let end = points.last().copied().unwrap_or(start);
159        let endpoint_cell = params.endpoint_tolerance_mm.max(1e-3);
160        let tolerance_mm = params.tolerance_mm.max(1e-3);
161
162        Self {
163            points: points.to_vec(),
164            length_mm: streamline_length(points),
165            bbox_min,
166            bbox_max,
167            start,
168            end,
169            start_bucket: quantize_point(start, endpoint_cell),
170            end_bucket: quantize_point(end, endpoint_cell),
171            voxels: rasterize_streamline_voxels(points, tolerance_mm),
172            segment_hash: SegmentSpatialHash::build(points, tolerance_mm),
173        }
174    }
175}
176
177fn representatives_match(
178    left: &RepresentativeDescriptor,
179    right: &RepresentativeDescriptor,
180    params: &DuplicateRemovalParams,
181) -> bool {
182    if !lengths_compatible(left.length_mm, right.length_mm, params) {
183        return false;
184    }
185    if !expanded_aabb_overlap(left, right, params.tolerance_mm) {
186        return false;
187    }
188    if euclidean_distance(left.start, right.start) > params.endpoint_tolerance_mm
189        || euclidean_distance(left.end, right.end) > params.endpoint_tolerance_mm
190    {
191        return false;
192    }
193    if voxel_overlap_fraction(&left.voxels, &right.voxels) < params.min_shared_voxel_fraction {
194        return false;
195    }
196    symmetric_streamline_match(left, right, params.tolerance_mm)
197}
198
199fn symmetric_streamline_match(
200    left: &RepresentativeDescriptor,
201    right: &RepresentativeDescriptor,
202    tolerance_mm: f32,
203) -> bool {
204    points_match_streamline(&left.points, right, tolerance_mm)
205        && points_match_streamline(&right.points, left, tolerance_mm)
206}
207
208fn points_match_streamline(
209    points: &[[f32; 3]],
210    representative: &RepresentativeDescriptor,
211    tolerance_mm: f32,
212) -> bool {
213    if representative.segment_hash.segments.is_empty() {
214        let tol2 = tolerance_mm * tolerance_mm;
215        return points.iter().copied().all(|point| {
216            representative
217                .points
218                .iter()
219                .copied()
220                .any(|other| squared_distance(point, other) <= tol2)
221        });
222    }
223
224    points.iter().copied().all(|point| {
225        representative
226            .segment_hash
227            .point_within_tolerance(point, tolerance_mm)
228    })
229}
230
231fn canonical_streamline_key(points: &[[f32; 3]]) -> StreamlineKey {
232    StreamlineKey(bytemuck::cast_slice::<[f32; 3], u8>(points).to_vec())
233}
234
235fn canonicalize_streamline_points(points: &[[f32; 3]]) -> Vec<[f32; 3]> {
236    if points.len() <= 1 || canonical_orientation(points) {
237        points.to_vec()
238    } else {
239        points.iter().rev().copied().collect()
240    }
241}
242
243fn canonical_orientation(points: &[[f32; 3]]) -> bool {
244    for index in 0..points.len() {
245        let forward = points[index];
246        let reverse = points[points.len() - 1 - index];
247        match compare_point(forward, reverse) {
248            Ordering::Less => return true,
249            Ordering::Greater => return false,
250            Ordering::Equal => continue,
251        }
252    }
253    true
254}
255
256fn compare_point(left: [f32; 3], right: [f32; 3]) -> Ordering {
257    for axis in 0..3 {
258        match left[axis]
259            .partial_cmp(&right[axis])
260            .unwrap_or_else(|| left[axis].to_bits().cmp(&right[axis].to_bits()))
261        {
262            Ordering::Equal => continue,
263            other => return other,
264        }
265    }
266    Ordering::Equal
267}
268
269fn compute_bounding_box(points: &[[f32; 3]]) -> ([f32; 3], [f32; 3]) {
270    let mut min = [f32::INFINITY; 3];
271    let mut max = [f32::NEG_INFINITY; 3];
272    for point in points {
273        for axis in 0..3 {
274            min[axis] = min[axis].min(point[axis]);
275            max[axis] = max[axis].max(point[axis]);
276        }
277    }
278    if points.is_empty() {
279        ([0.0; 3], [0.0; 3])
280    } else {
281        (min, max)
282    }
283}
284
285fn streamline_length(points: &[[f32; 3]]) -> f32 {
286    points
287        .windows(2)
288        .map(|window| euclidean_distance(window[0], window[1]))
289        .sum()
290}
291
292fn lengths_compatible(left: f32, right: f32, params: &DuplicateRemovalParams) -> bool {
293    let shorter = left.min(right);
294    let longer = left.max(right);
295    longer - shorter
296        <= shorter * 0.1 + params.endpoint_tolerance_mm * 2.0 + params.tolerance_mm * 2.0
297}
298
299fn expanded_aabb_overlap(
300    left: &RepresentativeDescriptor,
301    right: &RepresentativeDescriptor,
302    tolerance_mm: f32,
303) -> bool {
304    left.bbox_min[0] - tolerance_mm <= right.bbox_max[0]
305        && left.bbox_max[0] + tolerance_mm >= right.bbox_min[0]
306        && left.bbox_min[1] - tolerance_mm <= right.bbox_max[1]
307        && left.bbox_max[1] + tolerance_mm >= right.bbox_min[1]
308        && left.bbox_min[2] - tolerance_mm <= right.bbox_max[2]
309        && left.bbox_max[2] + tolerance_mm >= right.bbox_min[2]
310}
311
312fn rasterize_streamline_voxels(points: &[[f32; 3]], cell_size: f32) -> Vec<VoxelKey> {
313    let mut voxels = HashSet::<VoxelKey>::new();
314    for point in points.iter().copied() {
315        voxels.insert(quantize_point(point, cell_size));
316    }
317    for window in points.windows(2) {
318        let start = window[0];
319        let end = window[1];
320        let length = euclidean_distance(start, end);
321        let steps = ((length / (cell_size * 0.5)).ceil() as usize).max(1);
322        for step in 0..=steps {
323            let t = step as f32 / steps as f32;
324            let point = [
325                start[0] + (end[0] - start[0]) * t,
326                start[1] + (end[1] - start[1]) * t,
327                start[2] + (end[2] - start[2]) * t,
328            ];
329            voxels.insert(quantize_point(point, cell_size));
330        }
331    }
332    let mut voxels = voxels.into_iter().collect::<Vec<_>>();
333    voxels.sort_unstable();
334    voxels
335}
336
337fn voxel_overlap_fraction(left: &[VoxelKey], right: &[VoxelKey]) -> f32 {
338    if left.is_empty() || right.is_empty() {
339        return if left.is_empty() && right.is_empty() {
340            1.0
341        } else {
342            0.0
343        };
344    }
345
346    let mut intersection = 0usize;
347    let mut left_index = 0usize;
348    let mut right_index = 0usize;
349    while left_index < left.len() && right_index < right.len() {
350        match left[left_index].cmp(&right[right_index]) {
351            Ordering::Less => left_index += 1,
352            Ordering::Greater => right_index += 1,
353            Ordering::Equal => {
354                intersection += 1;
355                left_index += 1;
356                right_index += 1;
357            }
358        }
359    }
360    intersection as f32 / left.len().min(right.len()) as f32
361}
362
363fn neighboring_voxels(center: VoxelKey, radius: i32) -> Vec<VoxelKey> {
364    let mut voxels = Vec::with_capacity(((radius * 2 + 1).pow(3)) as usize);
365    for dx in -radius..=radius {
366        for dy in -radius..=radius {
367            for dz in -radius..=radius {
368                voxels.push((center.0 + dx, center.1 + dy, center.2 + dz));
369            }
370        }
371    }
372    voxels
373}
374
375fn quantize_point(point: [f32; 3], cell_size: f32) -> VoxelKey {
376    (
377        (point[0] / cell_size).floor() as i32,
378        (point[1] / cell_size).floor() as i32,
379        (point[2] / cell_size).floor() as i32,
380    )
381}
382
383fn squared_distance(left: [f32; 3], right: [f32; 3]) -> f32 {
384    let dx = left[0] - right[0];
385    let dy = left[1] - right[1];
386    let dz = left[2] - right[2];
387    dx * dx + dy * dy + dz * dz
388}
389
390fn euclidean_distance(left: [f32; 3], right: [f32; 3]) -> f32 {
391    squared_distance(left, right).sqrt()
392}
393
394fn point_segment_distance_squared(point: [f32; 3], start: [f32; 3], end: [f32; 3]) -> f32 {
395    let ab = [end[0] - start[0], end[1] - start[1], end[2] - start[2]];
396    let ap = [
397        point[0] - start[0],
398        point[1] - start[1],
399        point[2] - start[2],
400    ];
401    let ab_len2 = ab[0] * ab[0] + ab[1] * ab[1] + ab[2] * ab[2];
402    if ab_len2 <= 1e-12 {
403        return squared_distance(point, start);
404    }
405
406    let t = ((ap[0] * ab[0] + ap[1] * ab[1] + ap[2] * ab[2]) / ab_len2).clamp(0.0, 1.0);
407    let closest = [
408        start[0] + ab[0] * t,
409        start[1] + ab[1] * t,
410        start[2] + ab[2] * t,
411    ];
412    squared_distance(point, closest)
413}
414
415/// Controls the duplicate detection strategy.
416#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
417pub enum DuplicateRemovalMode {
418    /// Byte-identical streamlines (after canonicalisation) are duplicates.
419    Exact,
420    /// Streamlines whose endpoint neighbourhoods and voxel paths overlap
421    /// within tolerance are considered duplicates.
422    Near,
423}
424
425/// Parameters that tune duplicate detection behaviour.
426#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
427pub struct DuplicateRemovalParams {
428    /// Detection strategy (Exact or Near).
429    pub mode: DuplicateRemovalMode,
430    /// Spatial tolerance in mm for `Near` mode voxel overlap checks.
431    pub tolerance_mm: f32,
432    /// Tolerance in mm for quantising streamline endpoints into the same cell.
433    pub endpoint_tolerance_mm: f32,
434    /// Minimum fraction of voxels that must be shared between two streamlines
435    /// for them to be considered duplicates in `Near` mode.
436    pub min_shared_voxel_fraction: f32,
437}
438
439impl Default for DuplicateRemovalParams {
440    fn default() -> Self {
441        Self {
442            mode: DuplicateRemovalMode::Near,
443            tolerance_mm: 0.5,
444            endpoint_tolerance_mm: 1.0,
445            min_shared_voxel_fraction: 1.0,
446        }
447    }
448}
449
450type VoxelKey = (i32, i32, i32);
451
452#[derive(Clone)]
453struct RepresentativeDescriptor {
454    points: Vec<[f32; 3]>,
455    length_mm: f32,
456    bbox_min: [f32; 3],
457    bbox_max: [f32; 3],
458    start: [f32; 3],
459    end: [f32; 3],
460    start_bucket: VoxelKey,
461    end_bucket: VoxelKey,
462    voxels: Vec<VoxelKey>,
463    segment_hash: SegmentSpatialHash,
464}
465
466#[derive(Clone, Default)]
467struct SegmentSpatialHash {
468    cell_size: f32,
469    segments: Vec<([f32; 3], [f32; 3])>,
470    cells: HashMap<VoxelKey, Vec<usize>>,
471}
472
473impl SegmentSpatialHash {
474    fn build(points: &[[f32; 3]], tolerance_mm: f32) -> Self {
475        let cell_size = tolerance_mm.max(1e-3);
476        let mut segments = Vec::with_capacity(points.len().saturating_sub(1));
477        let mut cells = HashMap::<VoxelKey, Vec<usize>>::new();
478
479        for window in points.windows(2) {
480            let p0 = window[0];
481            let p1 = window[1];
482            let segment_index = segments.len();
483            segments.push((p0, p1));
484
485            let min = [
486                p0[0].min(p1[0]) - tolerance_mm,
487                p0[1].min(p1[1]) - tolerance_mm,
488                p0[2].min(p1[2]) - tolerance_mm,
489            ];
490            let max = [
491                p0[0].max(p1[0]) + tolerance_mm,
492                p0[1].max(p1[1]) + tolerance_mm,
493                p0[2].max(p1[2]) + tolerance_mm,
494            ];
495            let min_key = quantize_point(min, cell_size);
496            let max_key = quantize_point(max, cell_size);
497
498            for ix in min_key.0..=max_key.0 {
499                for iy in min_key.1..=max_key.1 {
500                    for iz in min_key.2..=max_key.2 {
501                        cells.entry((ix, iy, iz)).or_default().push(segment_index);
502                    }
503                }
504            }
505        }
506
507        Self {
508            cell_size,
509            segments,
510            cells,
511        }
512    }
513
514    fn point_within_tolerance(&self, point: [f32; 3], tolerance_mm: f32) -> bool {
515        if self.segments.is_empty() {
516            return false;
517        }
518        let key = quantize_point(point, self.cell_size);
519        let tol2 = tolerance_mm * tolerance_mm;
520        self.cells.get(&key).is_some_and(|segments| {
521            segments.iter().copied().any(|segment_index| {
522                let (start, end) = self.segments[segment_index];
523                point_segment_distance_squared(point, start, end) <= tol2
524            })
525        })
526    }
527}
528
529/// Compute the intersection: streamlines present in both `a` and `b`.
530/// Returns indices into `a`.
531pub fn intersection_indices<P: TrxScalar>(a: &TrxFile<P>, b: &TrxFile<P>) -> Vec<usize> {
532    let b_set: HashSet<StreamlineKey> = b.streamlines().map(streamline_key).collect();
533
534    a.streamlines()
535        .enumerate()
536        .filter_map(|(index, streamline)| {
537            b_set.contains(&streamline_key(streamline)).then_some(index)
538        })
539        .collect()
540}
541
542/// Compute the difference: streamlines in `a` but not in `b`.
543/// Returns indices into `a`.
544pub fn difference_indices<P: TrxScalar>(a: &TrxFile<P>, b: &TrxFile<P>) -> Vec<usize> {
545    let b_set: HashSet<StreamlineKey> = b.streamlines().map(streamline_key).collect();
546
547    a.streamlines()
548        .enumerate()
549        .filter_map(|(index, streamline)| {
550            (!b_set.contains(&streamline_key(streamline))).then_some(index)
551        })
552        .collect()
553}
554
555/// Intersection: return a new TrxFile with streamlines present in both.
556pub fn intersection<P: TrxScalar>(a: &TrxFile<P>, b: &TrxFile<P>) -> Result<TrxFile<P>> {
557    let indices = intersection_indices(a, b);
558    subset_streamlines(a, &indices)
559}
560
561/// Difference: return a new TrxFile with streamlines in `a` but not in `b`.
562pub fn difference<P: TrxScalar>(a: &TrxFile<P>, b: &TrxFile<P>) -> Result<TrxFile<P>> {
563    let indices = difference_indices(a, b);
564    subset_streamlines(a, &indices)
565}
566
567/// Union: return a new TrxFile with all unique streamlines from both.
568pub fn streamline_union<P: TrxScalar>(a: &TrxFile<P>, b: &TrxFile<P>) -> Result<TrxFile<P>> {
569    // Start with all of a, add streamlines from b not already in a
570    let a_set: HashSet<StreamlineKey> = a.streamlines().map(streamline_key).collect();
571
572    let mut stream =
573        crate::stream::TrxStream::<P>::new(a.header().voxel_to_rasmm, a.header().dimensions);
574
575    // Add all streamlines from a
576    for streamline in a.streamlines() {
577        stream.push_streamline(streamline);
578    }
579
580    // Add unique streamlines from b
581    for streamline in b.streamlines() {
582        let key = streamline_key(streamline);
583        if !a_set.contains(&key) {
584            stream.push_streamline(streamline);
585        }
586    }
587
588    Ok(stream.finalize())
589}
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594
595    fn tractogram_from_streamlines(streamlines: &[Vec<[f32; 3]>]) -> Tractogram {
596        let mut tractogram = Tractogram::new();
597        for streamline in streamlines {
598            tractogram.push_streamline(streamline).unwrap();
599        }
600        tractogram
601    }
602
603    #[test]
604    fn exact_mode_retains_one_representative_for_reversed_duplicates() {
605        let tractogram = tractogram_from_streamlines(&[
606            vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]],
607            vec![[1.0, 0.0, 0.0], [0.0, 0.0, 0.0]],
608            vec![[0.0, 1.0, 0.0], [1.0, 1.0, 0.0]],
609        ]);
610
611        let params = DuplicateRemovalParams {
612            mode: DuplicateRemovalMode::Exact,
613            ..DuplicateRemovalParams::default()
614        };
615        let kept = retain_tractogram_representative_indices(&tractogram, &params);
616        assert_eq!(kept, vec![0, 2]);
617    }
618
619    #[test]
620    fn near_mode_retains_one_representative_for_offset_duplicates() {
621        let tractogram = tractogram_from_streamlines(&[
622            vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]],
623            vec![[0.1, 0.0, 0.0], [1.1, 0.0, 0.0], [2.1, 0.0, 0.0]],
624            vec![[0.0, 1.0, 0.0], [1.0, 1.0, 0.0], [2.0, 1.0, 0.0]],
625        ]);
626
627        let kept = retain_tractogram_representative_indices(
628            &tractogram,
629            &DuplicateRemovalParams {
630                mode: DuplicateRemovalMode::Near,
631                tolerance_mm: 0.35,
632                endpoint_tolerance_mm: 0.5,
633                min_shared_voxel_fraction: 1.0,
634            },
635        );
636        assert_eq!(kept, vec![0, 2]);
637    }
638
639    #[test]
640    fn near_mode_preserves_endpoint_mismatched_streamlines() {
641        let tractogram = tractogram_from_streamlines(&[
642            vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]],
643            vec![[0.0, 0.0, 0.0], [1.0, 0.1, 0.0], [3.0, 0.0, 0.0]],
644        ]);
645
646        let kept = retain_tractogram_representative_indices(
647            &tractogram,
648            &DuplicateRemovalParams {
649                mode: DuplicateRemovalMode::Near,
650                tolerance_mm: 0.35,
651                endpoint_tolerance_mm: 0.5,
652                min_shared_voxel_fraction: 0.75,
653            },
654        );
655        assert_eq!(kept, vec![0, 1]);
656    }
657
658    #[test]
659    fn near_mode_preserves_distinct_parallel_streamlines() {
660        let tractogram = tractogram_from_streamlines(&[
661            vec![[0.0, 0.0, 0.0], [3.0, 0.0, 0.0]],
662            vec![[0.0, 1.5, 0.0], [3.0, 1.5, 0.0]],
663        ]);
664
665        let kept = retain_tractogram_representative_indices(
666            &tractogram,
667            &DuplicateRemovalParams {
668                mode: DuplicateRemovalMode::Near,
669                tolerance_mm: 0.5,
670                endpoint_tolerance_mm: 0.75,
671                min_shared_voxel_fraction: 0.8,
672            },
673        );
674        assert_eq!(kept, vec![0, 1]);
675    }
676}