use std::path::{Path, PathBuf};
use crate::common::error::{BioFormatsError, Result};
use crate::common::metadata::ImageMetadata;
use crate::common::reader::FormatReader;
pub struct FileStitcher {
files: Vec<PathBuf>,
meta: Option<ImageMetadata>,
plane_map: Vec<(usize, u32)>,
current_reader: Option<(usize, Box<dyn FormatReader>)>,
}
impl FileStitcher {
pub fn open(path: &Path) -> Result<Self> {
let files = discover_sequence(path)?;
if files.is_empty() {
return Err(BioFormatsError::Format(
"No files found for stitching".into(),
));
}
let mut first = crate::registry::ImageReader::open(&files[0])?;
let base_meta = first.metadata().clone();
let pattern = FilePattern::from_file(&files[0]).ok();
let (meta, plane_map) = stitch_layout(&files, &base_meta, pattern.as_ref())?;
let _ = first.close();
Ok(FileStitcher {
files,
meta: Some(meta),
plane_map,
current_reader: None,
})
}
pub fn from_files(files: Vec<PathBuf>) -> Result<Self> {
if files.is_empty() {
return Err(BioFormatsError::Format("Empty file list".into()));
}
let mut first = crate::registry::ImageReader::open(&files[0])?;
let base_meta = first.metadata().clone();
let pattern = FilePattern::from_file(&files[0]).ok();
let (meta, plane_map) = stitch_layout(&files, &base_meta, pattern.as_ref())?;
let _ = first.close();
Ok(FileStitcher {
files,
meta: Some(meta),
plane_map,
current_reader: None,
})
}
fn resolve_plane(&self, plane_index: u32) -> Result<(usize, u32)> {
let meta = self.meta.as_ref().ok_or(BioFormatsError::NotInitialized)?;
if plane_index >= meta.image_count {
return Err(BioFormatsError::PlaneOutOfRange(plane_index));
}
self.plane_map
.get(plane_index as usize)
.copied()
.ok_or(BioFormatsError::PlaneOutOfRange(plane_index))
}
fn ensure_reader(&mut self, file_idx: usize) -> Result<&mut Box<dyn FormatReader>> {
if let Some((idx, _)) = &self.current_reader {
if *idx == file_idx {
return Ok(&mut self.current_reader.as_mut().unwrap().1);
}
}
if let Some((_, mut r)) = self.current_reader.take() {
let _ = r.close();
}
let reader = open_reader(&self.files[file_idx])?;
self.current_reader = Some((file_idx, reader));
Ok(&mut self.current_reader.as_mut().unwrap().1)
}
}
fn open_reader(path: &Path) -> Result<Box<dyn FormatReader>> {
let header = crate::common::io::peek_header(path, 512).unwrap_or_default();
for r in crate::registry::all_readers_pub() {
if r.is_this_type_by_bytes(&header) {
let mut r = r;
r.set_id(path)?;
return Ok(r);
}
}
for r in crate::registry::all_readers_pub() {
if r.is_this_type_by_name(path) {
let mut r = r;
r.set_id(path)?;
return Ok(r);
}
}
Err(BioFormatsError::UnsupportedFormat(
path.display().to_string(),
))
}
fn discover_sequence(path: &Path) -> Result<Vec<PathBuf>> {
if let Ok(pattern) = FilePattern::from_file(path) {
let mut files: Vec<PathBuf> = pattern
.filenames()
.into_iter()
.filter(|p| p.exists())
.collect();
if !files.is_empty() {
files.sort();
return Ok(files);
}
}
let parent = path.parent().unwrap_or(Path::new("."));
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| BioFormatsError::Format("Invalid filename".into()))?;
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let chars: Vec<char> = stem.chars().collect();
let num_end = chars.len();
let mut num_start = num_end;
while num_start > 0 && chars[num_start - 1].is_ascii_digit() {
num_start -= 1;
}
if num_start == num_end {
return Ok(vec![path.to_path_buf()]);
}
let prefix: String = chars[..num_start].iter().collect();
let suffix: String = chars[num_end..].iter().collect();
let num_width = num_end - num_start;
let entries = std::fs::read_dir(parent).map_err(BioFormatsError::Io)?;
let mut matches: Vec<(u64, PathBuf)> = Vec::new();
for entry in entries.flatten() {
let entry_ext = entry
.path()
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_string();
if entry_ext != ext {
continue;
}
let entry_stem = entry
.path()
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
if !entry_stem.starts_with(&prefix) {
continue;
}
if !entry_stem.ends_with(&suffix) {
continue;
}
let mid = &entry_stem[prefix.len()..entry_stem.len() - suffix.len()];
if mid.len() != num_width {
continue;
}
if let Ok(n) = mid.parse::<u64>() {
matches.push((n, entry.path()));
}
}
matches.sort_by_key(|(n, _)| *n);
Ok(matches.into_iter().map(|(_, p)| p).collect())
}
fn stitch_layout(
files: &[PathBuf],
base_meta: &ImageMetadata,
pattern: Option<&FilePattern>,
) -> Result<(ImageMetadata, Vec<(usize, u32)>)> {
let mut meta = base_meta.clone();
let file_axes = pattern
.and_then(|pattern| infer_file_axes(files, pattern, base_meta))
.unwrap_or_else(|| FileAxisLayout {
file_coords: (0..files.len()).map(|i| (i as u32, 0, 0)).collect(),
size_z: files.len() as u32,
size_c: 1,
size_t: 1,
});
meta.size_z = checked_axis_mul(base_meta.size_z, file_axes.size_z, "Z")?;
meta.size_c = checked_axis_mul(base_meta.size_c, file_axes.size_c, "C")?;
meta.size_t = checked_axis_mul(base_meta.size_t, file_axes.size_t, "T")?;
meta.image_count = meta
.size_z
.checked_mul(meta.size_c)
.and_then(|v| v.checked_mul(meta.size_t))
.ok_or_else(|| BioFormatsError::Format("Stitched plane count overflow".into()))?;
let mut plane_map = vec![None; meta.image_count as usize];
for (file_idx, &(file_z, file_c, file_t)) in file_axes.file_coords.iter().enumerate() {
for local_plane in 0..base_meta.image_count {
let (local_z, local_c, local_t) = plane_to_zct(local_plane, base_meta)
.ok_or_else(|| BioFormatsError::Format("Invalid base plane index".into()))?;
let z = file_z * base_meta.size_z + local_z;
let c = file_c * base_meta.size_c + local_c;
let t = file_t * base_meta.size_t + local_t;
let stitched = zct_to_plane(z, c, t, &meta)
.ok_or_else(|| BioFormatsError::Format("Invalid stitched plane index".into()))?;
plane_map[stitched as usize] = Some((file_idx, local_plane));
}
}
let plane_map = plane_map
.into_iter()
.collect::<Option<Vec<_>>>()
.ok_or_else(|| BioFormatsError::Format("Incomplete stitched plane map".into()))?;
Ok((meta, plane_map))
}
struct FileAxisLayout {
file_coords: Vec<(u32, u32, u32)>,
size_z: u32,
size_c: u32,
size_t: u32,
}
fn infer_file_axes(
files: &[PathBuf],
pattern: &FilePattern,
base_meta: &ImageMetadata,
) -> Option<FileAxisLayout> {
let guess = AxisGuesser::guess_with_dims(
pattern,
dimension_order_str(base_meta.dimension_order),
base_meta.size_z,
base_meta.size_t,
base_meta.size_c,
false,
);
let guessed = guess.axis_types;
if !guessed
.iter()
.any(|axis| matches!(axis, AxisType::Z | AxisType::Channel | AxisType::Time))
{
return None;
}
if has_duplicate_inferred_axis(&guessed) {
return None;
}
let mut file_values = Vec::with_capacity(files.len());
for file in files {
let name = file.file_name()?.to_str()?;
file_values.push(pattern.match_filename(name)?);
}
let axis_len = |axis_type| {
guessed
.iter()
.position(|axis| *axis == axis_type)
.map(|idx| pattern.blocks[idx].values.len() as u32)
.unwrap_or(1)
};
let size_z = axis_len(AxisType::Z);
let size_c = axis_len(AxisType::Channel);
let size_t = axis_len(AxisType::Time);
let mut file_coords = Vec::with_capacity(files.len());
for values in file_values {
let mut z = 0;
let mut c = 0;
let mut t = 0;
for (idx, value) in values.iter().enumerate() {
let ordinal = pattern.blocks[idx].values.iter().position(|v| v == value)? as u32;
match guessed[idx] {
AxisType::Z => z = ordinal,
AxisType::Channel => c = ordinal,
AxisType::Time => t = ordinal,
AxisType::Series | AxisType::Unknown => {}
}
}
file_coords.push((z, c, t));
}
Some(FileAxisLayout {
file_coords,
size_z,
size_c,
size_t,
})
}
fn dimension_order_str(order: crate::common::metadata::DimensionOrder) -> &'static str {
use crate::common::metadata::DimensionOrder::*;
match order {
XYCTZ => "XYCTZ",
XYCZT => "XYCZT",
XYTCZ => "XYTCZ",
XYTZC => "XYTZC",
XYZCT => "XYZCT",
XYZTC => "XYZTC",
}
}
fn has_duplicate_inferred_axis(axes: &[AxisType]) -> bool {
[AxisType::Z, AxisType::Channel, AxisType::Time]
.iter()
.any(|axis| axes.iter().filter(|candidate| *candidate == axis).count() > 1)
}
fn checked_axis_mul(base: u32, files: u32, axis: &str) -> Result<u32> {
base.checked_mul(files)
.ok_or_else(|| BioFormatsError::Format(format!("Stitched {axis} size overflow")))
}
fn plane_to_zct(plane_index: u32, meta: &ImageMetadata) -> Option<(u32, u32, u32)> {
for t in 0..meta.size_t {
for z in 0..meta.size_z {
for c in 0..meta.size_c {
if zct_to_plane(z, c, t, meta)? == plane_index {
return Some((z, c, t));
}
}
}
}
None
}
fn zct_to_plane(z: u32, c: u32, t: u32, meta: &ImageMetadata) -> Option<u32> {
if z >= meta.size_z || c >= meta.size_c || t >= meta.size_t {
return None;
}
Some(match meta.dimension_order {
crate::common::metadata::DimensionOrder::XYZCT => {
t * meta.size_z * meta.size_c + c * meta.size_z + z
}
crate::common::metadata::DimensionOrder::XYZTC => {
c * meta.size_z * meta.size_t + t * meta.size_z + z
}
crate::common::metadata::DimensionOrder::XYCZT => {
t * meta.size_c * meta.size_z + z * meta.size_c + c
}
crate::common::metadata::DimensionOrder::XYCTZ => {
z * meta.size_c * meta.size_t + t * meta.size_c + c
}
crate::common::metadata::DimensionOrder::XYTCZ => {
z * meta.size_t * meta.size_c + c * meta.size_t + t
}
crate::common::metadata::DimensionOrder::XYTZC => {
c * meta.size_t * meta.size_z + z * meta.size_t + t
}
})
}
impl FormatReader for FileStitcher {
fn is_this_type_by_name(&self, _path: &Path) -> bool {
false
}
fn is_this_type_by_bytes(&self, _header: &[u8]) -> bool {
false
}
fn set_id(&mut self, path: &Path) -> Result<()> {
*self = Self::open(path)?;
Ok(())
}
fn close(&mut self) -> Result<()> {
if let Some((_, mut r)) = self.current_reader.take() {
let _ = r.close();
}
self.meta = None;
self.files.clear();
self.plane_map.clear();
Ok(())
}
fn series_count(&self) -> usize {
1
}
fn set_series(&mut self, s: usize) -> Result<()> {
if s != 0 {
Err(BioFormatsError::SeriesOutOfRange(s))
} else {
Ok(())
}
}
fn series(&self) -> usize {
0
}
fn metadata(&self) -> &ImageMetadata {
self.meta.as_ref().expect("FileStitcher not initialized")
}
fn open_bytes(&mut self, plane_index: u32) -> Result<Vec<u8>> {
let (file_idx, local_plane) = self.resolve_plane(plane_index)?;
let reader = self.ensure_reader(file_idx)?;
reader.open_bytes(local_plane)
}
fn open_bytes_region(
&mut self,
plane_index: u32,
x: u32,
y: u32,
w: u32,
h: u32,
) -> Result<Vec<u8>> {
let (file_idx, local_plane) = self.resolve_plane(plane_index)?;
let reader = self.ensure_reader(file_idx)?;
reader.open_bytes_region(local_plane, x, y, w, h)
}
fn open_thumb_bytes(&mut self, plane_index: u32) -> Result<Vec<u8>> {
let (file_idx, local_plane) = self.resolve_plane(plane_index)?;
let reader = self.ensure_reader(file_idx)?;
reader.open_thumb_bytes(local_plane)
}
}
#[derive(Debug, Clone)]
pub struct FilePattern {
pub dir: PathBuf,
pub prefix: String,
pub suffix: String,
pub blocks: Vec<FilePatternBlock>,
}
#[derive(Debug, Clone)]
pub struct FilePatternBlock {
pub separator: String,
pub width: usize,
pub min: u64,
pub max: u64,
pub values: Vec<u64>,
}
impl FilePattern {
pub fn from_file(path: &Path) -> Result<Self> {
let dir = path.parent().unwrap_or(Path::new(".")).to_path_buf();
let filename = path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| BioFormatsError::Format("Invalid filename".into()))?;
let chars: Vec<char> = filename.chars().collect();
let mut runs: Vec<(usize, usize)> = Vec::new(); let mut i = 0;
while i < chars.len() {
if chars[i].is_ascii_digit() {
let start = i;
while i < chars.len() && chars[i].is_ascii_digit() {
i += 1;
}
runs.push((start, i));
} else {
i += 1;
}
}
if runs.is_empty() {
return Ok(FilePattern {
dir,
prefix: filename.to_string(),
suffix: String::new(),
blocks: Vec::new(),
});
}
let mut blocks = Vec::new();
let mut last_end = 0;
for &(start, end) in &runs {
let separator: String = chars[last_end..start].iter().collect();
let width = end - start;
let val_str: String = chars[start..end].iter().collect();
let val: u64 = val_str.parse().unwrap_or(0);
blocks.push(FilePatternBlock {
separator,
width,
min: val,
max: val,
values: vec![val],
});
last_end = end;
}
let suffix: String = chars[last_end..].iter().collect();
let prefix = String::new();
let mut pattern = FilePattern {
dir: dir.clone(),
prefix,
suffix: suffix.clone(),
blocks,
};
pattern.scan_directory()?;
Ok(pattern)
}
fn scan_directory(&mut self) -> Result<()> {
let entries = std::fs::read_dir(&self.dir).map_err(BioFormatsError::Io)?;
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if !name_str.ends_with(&self.suffix) {
continue;
}
if let Some(values) = self.match_filename(&name_str) {
for (i, val) in values.into_iter().enumerate() {
if i < self.blocks.len() {
let block = &mut self.blocks[i];
if val < block.min {
block.min = val;
}
if val > block.max {
block.max = val;
}
if !block.values.contains(&val) {
block.values.push(val);
}
}
}
}
}
for block in &mut self.blocks {
block.values.sort();
}
Ok(())
}
fn match_filename(&self, name: &str) -> Option<Vec<u64>> {
let mut pos = 0;
let mut values = Vec::new();
for block in &self.blocks {
if !name[pos..].starts_with(&block.separator) {
return None;
}
pos += block.separator.len();
let digit_start = pos;
while pos < name.len() && name.as_bytes()[pos].is_ascii_digit() {
pos += 1;
}
if pos == digit_start {
return None;
}
let val: u64 = name[digit_start..pos].parse().ok()?;
values.push(val);
}
if &name[pos..] != self.suffix {
return None;
}
Some(values)
}
pub fn filenames(&self) -> Vec<PathBuf> {
if self.blocks.is_empty() {
return vec![self.dir.join(format!("{}{}", self.prefix, self.suffix))];
}
self.enumerate_blocks(0, String::new())
}
fn enumerate_blocks(&self, block_idx: usize, current: String) -> Vec<PathBuf> {
if block_idx >= self.blocks.len() {
let name = format!("{}{}", current, self.suffix);
return vec![self.dir.join(name)];
}
let block = &self.blocks[block_idx];
let mut results = Vec::new();
for &val in &block.values {
let next = format!(
"{}{}{:0>width$}",
current,
block.separator,
val,
width = block.width
);
results.extend(self.enumerate_blocks(block_idx + 1, next));
}
results
}
pub fn file_count(&self) -> usize {
self.blocks
.iter()
.map(|b| b.values.len())
.product::<usize>()
.max(1)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AxisType {
Z,
Channel,
Time,
Series,
Unknown,
}
#[derive(Debug, Clone)]
pub struct AxisGuess {
pub axis_types: Vec<AxisType>,
pub adjusted_order: String,
pub certain: bool,
}
pub struct AxisGuesser;
const Z_PREFIXES: &[&str] = &["fp", "sec", "z", "zs", "focal", "focalplane"];
const T_PREFIXES: &[&str] = &["t", "tl", "tp", "time"];
const C_PREFIXES: &[&str] = &["c", "ch", "w", "wavelength"];
const S_PREFIXES: &[&str] = &["s", "series", "sp"];
impl AxisGuesser {
pub fn guess(pattern: &FilePattern) -> Vec<AxisType> {
Self::guess_with_dims(pattern, "XYZCT", 1, 1, 1, false).axis_types
}
pub fn guess_with_dims(
pattern: &FilePattern,
dim_order: &str,
size_z: u32,
size_t: u32,
size_c: u32,
is_certain: bool,
) -> AxisGuess {
let mut axis_types: Vec<AxisType> = pattern
.blocks
.iter()
.map(|block| Self::guess_from_separator(&block.separator))
.collect();
let found_z = axis_types.iter().any(|a| *a == AxisType::Z);
let found_t = axis_types.iter().any(|a| *a == AxisType::Time);
let found_c = axis_types.iter().any(|a| *a == AxisType::Channel);
let mut new_order: Vec<char> = dim_order.chars().collect();
let mut size_z = size_z;
let mut size_t = size_t;
if !is_certain
&& ((found_z && !found_t && size_z > 1 && size_t == 1)
|| (found_t && !found_z && size_t > 1 && size_z == 1))
{
if let (Some(index_z), Some(index_t)) = (
new_order.iter().position(|&c| c == 'Z'),
new_order.iter().position(|&c| c == 'T'),
) {
new_order[index_z] = 'T';
new_order[index_t] = 'Z';
}
std::mem::swap(&mut size_z, &mut size_t);
}
let mut can_be_z = !found_z && size_z == 1;
let mut can_be_t = !found_t && size_t == 1;
let mut can_be_c = !found_c && size_c == 1;
let mut certain = is_certain;
let last_axis = new_order.last().copied().unwrap_or('T');
for axis in axis_types.iter_mut() {
if *axis != AxisType::Unknown {
continue;
}
certain = false;
if can_be_z {
*axis = AxisType::Z;
can_be_z = false;
} else if can_be_t {
*axis = AxisType::Time;
can_be_t = false;
} else if can_be_c {
*axis = AxisType::Channel;
can_be_c = false;
} else {
*axis = match last_axis {
'C' => AxisType::Channel,
'Z' => AxisType::Z,
_ => AxisType::Time,
};
}
}
AxisGuess {
axis_types,
adjusted_order: new_order.into_iter().collect(),
certain,
}
}
fn guess_from_separator(sep: &str) -> AxisType {
let p = Self::trailing_segment(sep);
if Z_PREFIXES.contains(&p.as_str()) {
return AxisType::Z;
}
if T_PREFIXES.contains(&p.as_str()) {
return AxisType::Time;
}
if C_PREFIXES.contains(&p.as_str()) {
return AxisType::Channel;
}
if S_PREFIXES.contains(&p.as_str()) {
return AxisType::Series;
}
AxisType::Unknown
}
fn trailing_segment(sep: &str) -> String {
let ch: Vec<char> = sep.to_ascii_lowercase().chars().collect();
if ch.is_empty() {
return String::new();
}
let mut l: isize = ch.len() as isize - 1;
while l >= 0 {
let c = ch[l as usize];
if c.is_ascii_digit() || c == ' ' || c == '-' || c == '_' || c == '.' {
l -= 1;
} else {
break;
}
}
let mut f: isize = l;
while f >= 0 && ch[f as usize].is_ascii_lowercase() {
f -= 1;
}
if l < 0 || f + 1 > l {
return String::new();
}
ch[(f + 1) as usize..=(l as usize)].iter().collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn pattern(blocks: &[(&str, usize)]) -> FilePattern {
FilePattern {
dir: PathBuf::from("."),
prefix: String::new(),
suffix: ".tif".into(),
blocks: blocks
.iter()
.map(|(sep, n)| FilePatternBlock {
separator: (*sep).into(),
width: 3,
min: 0,
max: (*n as u64).saturating_sub(1),
values: (0..*n as u64).collect(),
})
.collect(),
}
}
#[test]
fn zt_swap_when_only_z_found_and_size_z_gt_one() {
let fp = pattern(&[("z", 2), ("_", 2)]);
let guess = AxisGuesser::guess_with_dims(&fp, "XYZCT", 2, 1, 1, false);
assert_eq!(guess.adjusted_order, "XYTCZ");
assert_eq!(guess.axis_types[0], AxisType::Z);
assert_eq!(guess.axis_types[1], AxisType::Channel);
assert!(!guess.certain);
}
#[test]
fn no_swap_when_order_certain() {
let fp = pattern(&[("z", 2), ("_", 2)]);
let guess = AxisGuesser::guess_with_dims(&fp, "XYZCT", 2, 1, 1, true);
assert_eq!(guess.adjusted_order, "XYZCT");
assert_eq!(guess.axis_types[0], AxisType::Z);
assert_eq!(guess.axis_types[1], AxisType::Time);
}
#[test]
fn no_swap_when_both_sizes_one() {
let fp = pattern(&[("t", 2), ("_", 2)]);
let guess = AxisGuesser::guess_with_dims(&fp, "XYZCT", 1, 1, 1, false);
assert_eq!(guess.adjusted_order, "XYZCT");
assert_eq!(guess.axis_types[0], AxisType::Time);
assert_eq!(guess.axis_types[1], AxisType::Z);
}
#[test]
fn unknown_blocks_backfill_then_last_axis() {
let fp = pattern(&[("a", 2), ("b", 2), ("d", 2)]);
let guess = AxisGuesser::guess_with_dims(&fp, "XYZCT", 1, 1, 1, false);
assert_eq!(guess.axis_types[0], AxisType::Z);
assert_eq!(guess.axis_types[1], AxisType::Time);
assert_eq!(guess.axis_types[2], AxisType::Channel);
}
}