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#[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
35pub 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
56pub 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
68pub 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
83pub 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
417pub enum DuplicateRemovalMode {
418 Exact,
420 Near,
423}
424
425#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
427pub struct DuplicateRemovalParams {
428 pub mode: DuplicateRemovalMode,
430 pub tolerance_mm: f32,
432 pub endpoint_tolerance_mm: f32,
434 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
529pub 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
542pub 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
555pub 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
561pub 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
567pub fn streamline_union<P: TrxScalar>(a: &TrxFile<P>, b: &TrxFile<P>) -> Result<TrxFile<P>> {
569 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 for streamline in a.streamlines() {
577 stream.push_streamline(streamline);
578 }
579
580 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, ¶ms);
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}