1use std::collections::HashMap;
8
9use serde::{Deserialize, Serialize};
10
11use crate::dtype::DType;
12use crate::error::{Result, TrxError};
13use crate::mmap_backing::vec_to_bytes;
14use crate::tractogram::Tractogram;
15use crate::trx_file::DataArray;
16
17use super::catmull_rom::simplify_streamline;
18
19pub const FITTED_MARKER_KEY: &str = "catmull_rom_fitted";
22
23#[derive(Clone, Debug, Serialize, Deserialize)]
26pub struct FittedMarker {
27 pub version: u32,
28 pub epsilon_mm: f32,
29 pub fitter: String,
30}
31
32impl FittedMarker {
33 pub fn new(epsilon_mm: f32) -> Self {
34 Self {
35 version: 1,
36 epsilon_mm,
37 fitter: "trx-rs".to_string(),
38 }
39 }
40}
41
42#[derive(Clone, Debug)]
43pub struct SimplifyOptions {
44 pub epsilon_mm: f32,
47 pub group: Option<String>,
50 pub default_width_mm: f32,
54 pub default_tension: f32,
56 pub _parallel_chunks: Option<usize>,
60}
61
62impl Default for SimplifyOptions {
63 fn default() -> Self {
64 Self {
65 epsilon_mm: 1.0,
66 group: None,
67 default_width_mm: 1.0,
68 default_tension: 0.5,
69 _parallel_chunks: None,
70 }
71 }
72}
73
74#[derive(Clone, Copy, Debug, Default)]
75pub struct SimplifyStats {
76 pub input_streamlines: usize,
77 pub output_streamlines: usize,
78 pub input_vertices: usize,
79 pub output_vertices: usize,
80 pub groups_preserved: usize,
81}
82
83impl SimplifyStats {
84 pub fn vertex_compression_ratio(&self) -> f32 {
85 if self.output_vertices == 0 {
86 0.0
87 } else {
88 self.input_vertices as f32 / self.output_vertices as f32
89 }
90 }
91}
92
93pub fn simplify_tractogram(
102 input: &Tractogram,
103 opts: &SimplifyOptions,
104) -> Result<(Tractogram, SimplifyStats)> {
105 if !opts.epsilon_mm.is_finite() || opts.epsilon_mm <= 0.0 {
106 return Err(TrxError::Argument(format!(
107 "epsilon_mm must be a positive finite value, got {}",
108 opts.epsilon_mm
109 )));
110 }
111
112 let nb_in = input.nb_streamlines();
115 let keep_set: Option<std::collections::HashSet<u32>> = match &opts.group {
116 Some(name) => {
117 let members = input
118 .group(name)
119 .ok_or_else(|| TrxError::Argument(format!("group `{name}` not found")))?;
120 Some(members.iter().copied().collect())
121 }
122 None => None,
123 };
124
125 let mut output = Tractogram::with_header(input.header().clone());
126 let mut stats = SimplifyStats::default();
127 let mut new_widths: Vec<f32> = Vec::new();
128 let mut new_tensions: Vec<f32> = Vec::new();
129 let mut remap: Vec<Option<u32>> = vec![None; nb_in];
131
132 for (input_idx, remap_slot) in remap.iter_mut().enumerate() {
133 if let Some(keep) = &keep_set {
134 if !keep.contains(&(input_idx as u32)) {
135 continue;
136 }
137 }
138 let dense = input.streamline(input_idx);
139 stats.input_streamlines += 1;
140 stats.input_vertices += dense.len();
141
142 let simplified = simplify_streamline(dense, opts.epsilon_mm);
143 let out_idx = output.nb_streamlines() as u32;
144 output.push_streamline(&simplified)?;
145 let cp_count = simplified.len();
146 stats.output_vertices += cp_count;
147 new_widths.extend(std::iter::repeat_n(opts.default_width_mm, cp_count));
148 new_tensions.extend(std::iter::repeat_n(opts.default_tension, cp_count));
149 *remap_slot = Some(out_idx);
150 }
151 stats.output_streamlines = output.nb_streamlines();
152
153 if !new_widths.is_empty() {
154 output.insert_dpv("width", scalar_dpv(new_widths));
155 output.insert_dpv("tension", scalar_dpv(new_tensions));
156 }
157
158 let mut group_remap: HashMap<String, Vec<u32>> = HashMap::new();
159 for (name, members) in input.groups() {
160 if let Some(filter) = &opts.group {
161 if name != filter {
162 continue;
163 }
164 }
165 let mut remapped: Vec<u32> = members
166 .iter()
167 .filter_map(|&idx| remap.get(idx as usize).copied().flatten())
168 .collect();
169 if remapped.is_empty() {
170 continue;
171 }
172 remapped.sort_unstable();
173 group_remap.insert(name.clone(), remapped);
174 }
175 stats.groups_preserved = group_remap.len();
176 for (name, members) in group_remap {
177 output.insert_group(name, members);
178 }
179
180 let marker = FittedMarker::new(opts.epsilon_mm);
181 let json = serde_json::to_value(&marker)
182 .map_err(|e| TrxError::Argument(format!("serialise fitted marker: {e}")))?;
183 output
184 .extra_mut()
185 .insert(FITTED_MARKER_KEY.to_string(), json);
186
187 Ok((output, stats))
188}
189
190fn scalar_dpv(values: Vec<f32>) -> DataArray {
191 DataArray::owned_bytes(vec_to_bytes(values), 1, DType::Float32)
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197 use crate::tractogram::Tractogram;
198
199 fn dense_streamline(seed: f32, n: usize) -> Vec<[f32; 3]> {
200 (0..=n)
201 .map(|i| {
202 let t = i as f32 / n as f32;
203 let x = seed + 50.0 * t;
204 let y = 10.0 * (t * std::f32::consts::PI).sin();
205 let z = 5.0 * t;
206 [x, y, z]
207 })
208 .collect()
209 }
210
211 fn build_input(streamline_count: usize, vertices: usize) -> Tractogram {
212 let mut t = Tractogram::new();
213 for s in 0..streamline_count {
214 let pts = dense_streamline(s as f32 * 1.0, vertices);
215 t.push_streamline(&pts).unwrap();
216 }
217 t.insert_group("even", (0..streamline_count as u32).step_by(2).collect());
218 t.insert_group("odd", (1..streamline_count as u32).step_by(2).collect());
219 t
220 }
221
222 #[test]
223 fn simplify_compresses_and_marks() {
224 let input = build_input(4, 100);
225 let (output, stats) = simplify_tractogram(&input, &SimplifyOptions::default()).unwrap();
226
227 assert_eq!(stats.input_streamlines, 4);
228 assert_eq!(stats.output_streamlines, 4);
229 assert!(stats.output_vertices < stats.input_vertices);
230 assert!(stats.vertex_compression_ratio() > 4.0);
231
232 let marker = output.extra().get(FITTED_MARKER_KEY).unwrap();
234 let parsed: FittedMarker = serde_json::from_value(marker.clone()).unwrap();
235 assert_eq!(parsed.version, 1);
236 assert!((parsed.epsilon_mm - 1.0).abs() < 1e-6);
237
238 let widths = output.dpv_arrays().get("width").unwrap();
240 assert_eq!(widths.dtype(), DType::Float32);
241 assert_eq!(widths.cast_slice::<f32>().len(), output.nb_vertices());
242
243 assert_eq!(stats.groups_preserved, 2);
245 assert_eq!(output.group("even").unwrap().len(), 2);
246 assert_eq!(output.group("odd").unwrap().len(), 2);
247 }
248
249 #[test]
250 fn group_filter_drops_others() {
251 let input = build_input(4, 100);
252 let opts = SimplifyOptions {
253 group: Some("even".to_string()),
254 ..SimplifyOptions::default()
255 };
256 let (output, stats) = simplify_tractogram(&input, &opts).unwrap();
257 assert_eq!(stats.input_streamlines, 2);
258 assert_eq!(stats.output_streamlines, 2);
259 assert_eq!(output.groups().len(), 1);
260 assert_eq!(output.group("even").unwrap(), &[0, 1]);
261 }
262
263 #[test]
264 fn unknown_group_is_an_error() {
265 let input = build_input(2, 50);
266 let opts = SimplifyOptions {
267 group: Some("nonexistent".to_string()),
268 ..SimplifyOptions::default()
269 };
270 let err = simplify_tractogram(&input, &opts).unwrap_err();
271 assert!(err.to_string().contains("nonexistent"));
272 }
273
274 #[test]
275 fn invalid_epsilon_rejected() {
276 let input = build_input(1, 50);
277 for bad in [0.0, -1.0, f32::NAN, f32::INFINITY] {
278 let opts = SimplifyOptions {
279 epsilon_mm: bad,
280 ..SimplifyOptions::default()
281 };
282 assert!(simplify_tractogram(&input, &opts).is_err());
283 }
284 }
285}