mod chunk_cache;
use std::path::{Path, PathBuf};
use std::time::Instant;
use anyhow::{anyhow, Context};
use clap::{Parser, Subcommand};
use tomoxide::io::DatasetReader;
use tomoxide::Algorithm;
use tomoxide::Center;
use tomoxide::{
Angles, BackendKind, Dtype, Engine, FilterName, Geometry, PhaseMethod, PrepOptions,
ReconParams, StripeMethod,
};
use tomoxide::config::Config;
#[derive(Parser, Debug)]
#[command(name = "tomoxide", version, about)]
struct Cli {
#[arg(long, global = true, default_value = "auto")]
backend: String,
#[arg(long, short, global = true)]
verbose: bool,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand, Debug)]
#[command(rename_all = "snake_case")] enum Command {
Init {
#[arg(long, default_value = "tomoxide.toml")]
config: PathBuf,
},
Status {
#[arg(long)]
config: Option<PathBuf>,
},
Recon {
#[command(flatten)]
common: CommonRecon,
#[arg(long)]
chunk: Option<usize>,
#[arg(long)]
start_row: Option<usize>,
#[arg(long)]
end_row: Option<usize>,
#[arg(long)]
lamino_angle: Option<f32>,
#[arg(long)]
lamino_rh: Option<usize>,
},
ReconSteps {
#[command(flatten)]
common: CommonRecon,
#[arg(long)]
chunk: Option<usize>,
},
TuneChunk {
file: PathBuf,
#[arg(long, default_value = "fbp")]
algorithm: String,
#[arg(long)]
center: Option<f32>,
#[arg(long, default_value = "float32")]
dtype: String,
},
}
#[derive(clap::Args, Debug)]
#[command(rename_all = "snake_case")]
struct CommonRecon {
file: PathBuf,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
algorithm: Option<String>,
#[arg(long)]
center: Option<f32>,
#[arg(long)]
dtype: Option<String>,
#[arg(long)]
save_format: Option<String>,
#[arg(long)]
output: Option<PathBuf>,
#[arg(long)]
filter: Option<String>,
#[arg(long)]
remove_stripe: Option<String>,
#[arg(long)]
retrieve_phase: Option<String>,
#[arg(long)]
num_iter: Option<usize>,
#[arg(long)]
reg_par: Option<String>,
#[arg(long, num_args = 0..=1, default_missing_value = "true")]
ext_pad: Option<bool>,
#[arg(long)]
fw_sigma: Option<f32>,
#[arg(long)]
fw_level: Option<usize>,
#[arg(long)]
ti_nblock: Option<usize>,
#[arg(long)]
ti_beta: Option<f32>,
#[arg(long)]
sf_size: Option<usize>,
#[arg(long)]
vo_snr: Option<f32>,
#[arg(long)]
vo_la_size: Option<usize>,
#[arg(long)]
vo_sm_size: Option<usize>,
#[arg(long)]
vo_sort_size: Option<usize>,
#[arg(long)]
vo_sort_dim: Option<u8>,
#[arg(long)]
vo_filter_sigma: Option<f32>,
#[arg(long)]
vo_filter_size: Option<usize>,
#[arg(long)]
vo_filter_dim: Option<u8>,
#[arg(long)]
vo_large_snr: Option<f32>,
#[arg(long)]
vo_large_size: Option<usize>,
#[arg(long)]
vo_large_drop_ratio: Option<f32>,
#[arg(long)]
vo_large_norm: Option<bool>,
#[arg(long)]
vo_dead_snr: Option<f32>,
#[arg(long)]
vo_dead_size: Option<usize>,
#[arg(long)]
vo_dead_norm: Option<bool>,
#[arg(long)]
vo_fit_order: Option<usize>,
#[arg(long)]
vo_fit_sigma_x: Option<f32>,
#[arg(long)]
vo_fit_sigma_y: Option<f32>,
#[arg(long)]
pixel_size: Option<f32>,
#[arg(long)]
propagation_distance: Option<f32>,
#[arg(long)]
energy: Option<f32>,
#[arg(long)]
alpha: Option<f32>,
#[arg(long)]
db: Option<f32>,
#[arg(long)]
w: Option<f32>,
#[arg(long)]
progress_json: bool,
}
#[derive(Clone, Copy, Debug)]
struct ChainStage {
algo: Algorithm,
num_iter: usize,
}
struct ReconPlan {
algorithm: String,
chain: Vec<ChainStage>,
algo: Algorithm,
center: Option<f32>,
dtype: Dtype,
out: Option<String>,
save_format: tomoxide::io::SaveFormat,
save_format_str: String,
filter: FilterName,
filter_str: String,
num_iter: usize,
reg_par: Vec<f32>,
ext_pad: bool,
prep: PrepOptions,
stripe_str: String,
phase_str: String,
stripe_params: StripeParams,
phase_params: PhaseParams,
}
#[derive(Clone, Copy)]
struct StripeParams {
fw_sigma: f32,
fw_level: Option<usize>,
ti_nblock: usize,
ti_beta: f32,
sf_size: usize,
vo_snr: f32,
vo_la_size: usize,
vo_sm_size: usize,
vo_sort_size: Option<usize>,
vo_sort_dim: u8,
vo_filter_sigma: f32,
vo_filter_size: Option<usize>,
vo_filter_dim: u8,
vo_large_snr: f32,
vo_large_size: usize,
vo_large_drop_ratio: f32,
vo_large_norm: bool,
vo_dead_snr: f32,
vo_dead_size: usize,
vo_dead_norm: bool,
vo_fit_order: usize,
vo_fit_sigma: (f32, f32),
}
#[derive(Clone, Copy)]
struct PhaseParams {
pixel_size: f32,
dist: f32,
energy: f32,
alpha: f32,
db: f32,
w: f32,
}
fn build_stripe(name: &str, p: &StripeParams) -> anyhow::Result<StripeMethod> {
Ok(match name.to_ascii_lowercase().as_str() {
"none" => StripeMethod::None,
"fw" => StripeMethod::Fw {
sigma: p.fw_sigma,
level: p.fw_level,
},
"ti" => StripeMethod::Ti {
nblock: p.ti_nblock,
beta: p.ti_beta,
},
"sf" => StripeMethod::Sf { size: p.sf_size },
"vo-all" | "vo_all" | "voall" => StripeMethod::VoAll {
snr: p.vo_snr,
la_size: p.vo_la_size,
sm_size: p.vo_sm_size,
},
"vo-sort" | "vo_sort" => StripeMethod::VoSort {
size: p.vo_sort_size,
dim: p.vo_sort_dim,
},
"vo-filter" | "vo_filter" => StripeMethod::VoFilter {
sigma: p.vo_filter_sigma,
size: p.vo_filter_size,
dim: p.vo_filter_dim,
},
"vo-large" | "vo_large" => StripeMethod::VoLarge {
snr: p.vo_large_snr,
size: p.vo_large_size,
drop_ratio: p.vo_large_drop_ratio,
norm: p.vo_large_norm,
},
"vo-dead" | "vo_dead" => StripeMethod::VoDead {
snr: p.vo_dead_snr,
size: p.vo_dead_size,
norm: p.vo_dead_norm,
},
"vo-fit" | "vo_fit" => StripeMethod::VoFit {
order: p.vo_fit_order,
sigma: p.vo_fit_sigma,
},
other => {
return Err(anyhow!(
"unknown stripe method '{other}' \
(none|fw|ti|sf|vo-all|vo-sort|vo-filter|vo-large|vo-dead|vo-fit)"
))
}
})
}
fn build_phase(name: &str, p: &PhaseParams) -> anyhow::Result<PhaseMethod> {
Ok(match name.to_ascii_lowercase().as_str() {
"none" => PhaseMethod::None,
"paganin" => PhaseMethod::Paganin {
pixel_size: p.pixel_size,
dist: p.dist,
energy: p.energy,
alpha: p.alpha,
},
"gpaganin" => PhaseMethod::GPaganin {
pixel_size: p.pixel_size,
dist: p.dist,
energy: p.energy,
db: p.db,
w: p.w,
},
"farago" => PhaseMethod::Farago {
pixel_size: p.pixel_size,
dist: p.dist,
energy: p.energy,
db: p.db,
},
other => {
return Err(anyhow!(
"unknown phase method '{other}' (none|paganin|Gpaganin|farago)"
))
}
})
}
fn parse_f32_list(s: &str) -> anyhow::Result<Vec<f32>> {
s.split(',')
.map(str::trim)
.filter(|t| !t.is_empty())
.map(|t| {
t.parse::<f32>()
.map_err(|e| anyhow!("bad value '{t}': {e}"))
})
.collect()
}
fn parse_chain_stage(seg: &str, default_iter: usize) -> anyhow::Result<ChainStage> {
let (name, iters) = match seg.split_once(':') {
Some((n, i)) => {
let it = i
.trim()
.parse::<usize>()
.map_err(|e| anyhow!("bad iteration count '{}' in stage '{seg}': {e}", i.trim()))?;
if it == 0 {
return Err(anyhow!("stage '{seg}': iteration count must be >= 1"));
}
(n.trim(), Some(it))
}
None => (seg, None),
};
let algo: Algorithm = name.parse().map_err(|e| anyhow!("{e}"))?;
if let Some(it) = iters {
if algo.is_analytic() {
return Err(anyhow!(
"analytic algorithm '{name}' takes no iteration count; drop the \
':{it}' (per-stage iters apply only to iterative methods)"
));
}
}
Ok(ChainStage {
algo,
num_iter: iters.unwrap_or(default_iter),
})
}
fn resolve(c: &CommonRecon) -> anyhow::Result<(ReconPlan, Config)> {
let cfg = match &c.config {
Some(p) => Config::load(p).with_context(|| format!("loading {}", p.display()))?,
None => Config::default(),
};
let algorithm = c.algorithm.clone().unwrap_or_else(|| cfg.algorithm.clone());
let global_num_iter = c.num_iter.unwrap_or(cfg.num_iter);
let chain: Vec<ChainStage> = algorithm
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| parse_chain_stage(s, global_num_iter))
.collect::<anyhow::Result<Vec<_>>>()?;
let algo = chain
.first()
.ok_or_else(|| anyhow!("no algorithm given (empty --algorithm)"))?
.algo;
let center = c.center.or(cfg.rotation_axis);
let dtype: Dtype = c
.dtype
.clone()
.unwrap_or_else(|| cfg.dtype.clone())
.parse()
.map_err(|e| anyhow!("{e}"))?;
let out = c
.output
.as_ref()
.map(|p| p.display().to_string())
.or_else(|| cfg.output.clone().filter(|s| !s.is_empty()));
let save_format_str = c
.save_format
.clone()
.unwrap_or_else(|| cfg.save_format.clone());
let save_format: tomoxide::io::SaveFormat =
save_format_str.parse().map_err(|e| anyhow!("{e}"))?;
let filter_str = c.filter.clone().unwrap_or_else(|| cfg.filter_name.clone());
let filter: FilterName = filter_str.parse().map_err(|e| anyhow!("{e}"))?;
let num_iter = chain[0].num_iter;
let reg_par = match &c.reg_par {
Some(s) => parse_f32_list(s)?,
None => cfg.reg_par.clone(),
};
let ext_pad = c.ext_pad.unwrap_or(cfg.ext_pad);
let stripe_str = c
.remove_stripe
.clone()
.unwrap_or_else(|| cfg.remove_stripe_method.clone());
let phase_str = c
.retrieve_phase
.clone()
.unwrap_or_else(|| cfg.retrieve_phase_method.clone());
let stripe_params = StripeParams {
fw_sigma: c.fw_sigma.unwrap_or(cfg.fw_sigma),
fw_level: {
let l = c.fw_level.unwrap_or(cfg.fw_level);
(l != 0).then_some(l)
},
ti_nblock: c.ti_nblock.unwrap_or(cfg.ti_nblock),
ti_beta: c.ti_beta.unwrap_or(cfg.ti_beta),
sf_size: c.sf_size.unwrap_or(cfg.sf_size),
vo_snr: c.vo_snr.unwrap_or(cfg.vo_snr),
vo_la_size: c.vo_la_size.unwrap_or(cfg.vo_la_size),
vo_sm_size: c.vo_sm_size.unwrap_or(cfg.vo_sm_size),
vo_sort_size: {
let s = c.vo_sort_size.unwrap_or(cfg.vo_sort_size);
(s != 0).then_some(s)
},
vo_sort_dim: c.vo_sort_dim.unwrap_or(cfg.vo_sort_dim),
vo_filter_sigma: c.vo_filter_sigma.unwrap_or(cfg.vo_filter_sigma),
vo_filter_size: {
let s = c.vo_filter_size.unwrap_or(cfg.vo_filter_size);
(s != 0).then_some(s)
},
vo_filter_dim: c.vo_filter_dim.unwrap_or(cfg.vo_filter_dim),
vo_large_snr: c.vo_large_snr.unwrap_or(cfg.vo_large_snr),
vo_large_size: c.vo_large_size.unwrap_or(cfg.vo_large_size),
vo_large_drop_ratio: c.vo_large_drop_ratio.unwrap_or(cfg.vo_large_drop_ratio),
vo_large_norm: c.vo_large_norm.unwrap_or(cfg.vo_large_norm),
vo_dead_snr: c.vo_dead_snr.unwrap_or(cfg.vo_dead_snr),
vo_dead_size: c.vo_dead_size.unwrap_or(cfg.vo_dead_size),
vo_dead_norm: c.vo_dead_norm.unwrap_or(cfg.vo_dead_norm),
vo_fit_order: c.vo_fit_order.unwrap_or(cfg.vo_fit_order),
vo_fit_sigma: (
c.vo_fit_sigma_x.unwrap_or(cfg.vo_fit_sigma_x),
c.vo_fit_sigma_y.unwrap_or(cfg.vo_fit_sigma_y),
),
};
let phase_params = PhaseParams {
pixel_size: c.pixel_size.unwrap_or(cfg.pixel_size as f32),
dist: c
.propagation_distance
.unwrap_or(cfg.propagation_distance as f32),
energy: c.energy.unwrap_or(cfg.energy as f32),
alpha: c.alpha.unwrap_or(cfg.alpha as f32),
db: c.db.unwrap_or(cfg.db as f32),
w: c.w.unwrap_or(cfg.w as f32),
};
let prep = PrepOptions {
stripe: build_stripe(&stripe_str, &stripe_params)?,
phase: build_phase(&phase_str, &phase_params)?,
};
Ok((
ReconPlan {
algorithm,
chain,
algo,
center,
dtype,
out,
save_format,
save_format_str,
filter,
filter_str,
num_iter,
reg_par,
ext_pad,
prep,
stripe_str,
phase_str,
stripe_params,
phase_params,
},
cfg,
))
}
const DEFAULT_PIPELINE_CHUNK: usize = 8;
const MULTI_GPU_MIN_NX: usize = 2048;
fn pipelines_well(engine: &Engine, algo: Algorithm) -> bool {
match engine.name() {
"cuda" => matches!(
algo,
Algorithm::Fbp | Algorithm::Linerec | Algorithm::Fourierrec | Algorithm::Lprec
),
"wgpu" => matches!(
algo,
Algorithm::Fbp | Algorithm::Linerec | Algorithm::Fourierrec | Algorithm::Lprec
),
_ => false,
}
}
fn parse_backend(s: &str) -> anyhow::Result<BackendKind> {
Ok(match s {
"auto" => BackendKind::Auto,
"cpu" => BackendKind::Cpu,
"cuda" => BackendKind::Cuda,
"wgpu" => BackendKind::Wgpu,
other => return Err(anyhow!("unknown backend '{other}' (auto|cpu|cuda|wgpu)")),
})
}
fn resolve_backend(flag: &str, cfg: &Config) -> anyhow::Result<BackendKind> {
let chosen = if flag == "auto" { &cfg.backend } else { flag };
parse_backend(chosen)
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
let level = if cli.verbose { "debug" } else { "info" };
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(level)).init();
let backend_kind = parse_backend(&cli.backend)?;
let backend_flag = cli.backend.clone();
match cli.command {
Command::Init { config } => {
let cfg = Config::default();
cfg.write(&config)
.with_context(|| format!("writing {}", config.display()))?;
println!("wrote default config to {}", config.display());
}
Command::Status { config } => {
let engine = Engine::new(backend_kind)?;
println!("tomoxide {}", env!("CARGO_PKG_VERSION"));
println!("backend (requested {}): {}", cli.backend, engine.name());
if let Some(path) = config {
let cfg =
Config::load(&path).with_context(|| format!("loading {}", path.display()))?;
println!("config: {cfg:#?}");
}
}
Command::Recon {
common,
chunk,
start_row,
end_row,
lamino_angle,
lamino_rh,
} => {
let (plan, cfg) = resolve(&common)?;
let engine = Engine::new(resolve_backend(&backend_flag, &cfg)?)?;
let file = common.file.clone();
let lamino_angle = lamino_angle.or(cfg.lamino_angle);
let dtype = plan.dtype;
let save_format = plan.save_format;
println!(
"recon: file={} algorithm={:?} center={:?} dtype={} filter={} stripe={} phase={} backend={}",
file.display(),
plan.algorithm,
plan.center,
dtype.as_str(),
plan.filter_str,
plan.stripe_str,
plan.phase_str,
engine.name()
);
log::debug!(
"resolved prep={:?} num_iter={} reg_par={:?}",
plan.prep,
plan.num_iter,
plan.reg_par
);
let out = plan.out.clone().unwrap_or_else(|| recon_out_path(&file));
if plan.chain.len() > 1 {
let mut reader = tomoxide::io::open_dxchange(&file.to_string_lossy())?;
let mut geom = geometry_from_reader(reader.as_mut(), plan.center)?;
let (z0, z1, banded) =
row_band(start_row, end_row, geom.detector.height, lamino_angle)?;
if let Some(deg) = lamino_angle {
use std::f32::consts::PI;
geom.beam = tomoxide::Beam::Laminography {
phi: PI / 2.0 + deg * PI / 180.0,
};
println!(" laminography: tilt={deg}° rh={lamino_rh:?}");
}
let nz_total = geom.detector.height;
let ds = if banded {
geom.detector.height = z1 - z0;
reader.read_chunk(z0, z1)?
} else {
reader.read_all()?
};
let vol = reconstruct_chain(ds, &geom, &plan, lamino_rh, &engine)?;
let mut writer = maybe_progress(
tomoxide::io::create_writer(&out, save_format)?,
common.progress_json,
);
let nz = vol.dims().0;
if banded {
writer.reserve(nz_total)?;
writer.write_chunk(&vol, z0, z0 + nz)?;
} else {
writer.reserve(nz)?;
writer.write_chunk(&vol, 0, nz)?;
}
writer.finalize()?;
} else if pipelines_well(&engine, plan.algo) && lamino_angle.is_none() {
let mut probe = tomoxide::io::open_dxchange(&file.to_string_lossy())?;
let (nproj, nz, nx, _nflat, _ndark) = probe.read_sizes()?;
drop(probe);
let (chunk, source) =
resolve_chunk(chunk, &file, &plan.algorithm, dtype, nx, nproj, nz);
println!(" chunk: {chunk} ({source})");
let devices = tomoxide::cuda::selected_devices();
let top_level = start_row.is_none() && end_row.is_none();
let shardable = engine.name() == "cuda"
&& matches!(save_format, tomoxide::io::SaveFormat::Tiff)
&& top_level
&& devices.len() > 1
&& nz > devices.len()
&& nx >= MULTI_GPU_MIN_NX;
if shardable {
run_sharded_subprocesses(
&file,
&out,
&plan,
chunk,
nz,
&devices,
common.progress_json,
)?;
} else {
run_pipelined(
&file,
&out,
plan.algo,
plan.center,
dtype,
save_format,
chunk,
start_row,
end_row,
plan.filter,
plan.num_iter,
plan.reg_par.clone(),
plan.ext_pad,
plan.prep,
common.progress_json,
&engine,
)?;
}
} else {
let mut reader = tomoxide::io::open_dxchange(&file.to_string_lossy())?;
let mut geom = geometry_from_reader(reader.as_mut(), plan.center)?;
let (z0, z1, banded) =
row_band(start_row, end_row, geom.detector.height, lamino_angle)?;
let mut params = recon_params(
&geom,
dtype,
plan.filter,
plan.num_iter,
plan.reg_par.clone(),
plan.ext_pad,
);
if let Some(deg) = lamino_angle {
use std::f32::consts::PI;
geom.beam = tomoxide::Beam::Laminography {
phi: PI / 2.0 + deg * PI / 180.0,
};
params.lamino_rh = lamino_rh;
println!(" laminography: tilt={deg}° rh={lamino_rh:?}");
}
let nz_total = geom.detector.height;
let ds = if banded {
geom.detector.height = z1 - z0;
reader.read_chunk(z0, z1)?
} else {
reader.read_all()?
};
let vol =
tomoxide::reconstruct(ds, &geom, plan.algo, ¶ms, &plan.prep, &engine)?;
let mut writer = maybe_progress(
tomoxide::io::create_writer(&out, save_format)?,
common.progress_json,
);
let nz = vol.dims().0;
if banded {
writer.reserve(nz_total)?;
writer.write_chunk(&vol, z0, z0 + nz)?;
} else {
writer.reserve(nz)?;
writer.write_chunk(&vol, 0, nz)?;
}
writer.finalize()?;
}
println!("wrote reconstruction to {out}");
}
Command::ReconSteps { common, chunk } => {
let (plan, cfg) = resolve(&common)?;
if plan.chain.len() > 1 {
return Err(anyhow!(
"algorithm chaining ({}) needs the whole prior volume to \
warm-start; it is supported only by `recon` (whole-volume), \
not the streaming `recon_steps`",
plan.algorithm
));
}
if cfg.lamino_angle.is_some() {
return Err(anyhow!(
"lamino_angle (from config) needs the whole-volume path; it is \
supported only by `recon`, not the streaming `recon_steps`"
));
}
let engine = Engine::new(resolve_backend(&backend_flag, &cfg)?)?;
let file = common.file.clone();
let chunk = chunk.unwrap_or(cfg.nsino_per_chunk);
println!(
"recon_steps: file={} algorithm={:?} center={:?} dtype={} filter={} stripe={} phase={} chunk={} backend={}",
file.display(),
plan.algo,
plan.center,
plan.dtype.as_str(),
plan.filter_str,
plan.stripe_str,
plan.phase_str,
chunk,
engine.name()
);
log::debug!(
"resolved prep={:?} num_iter={} reg_par={:?}",
plan.prep,
plan.num_iter,
plan.reg_par
);
let out = plan.out.clone().unwrap_or_else(|| recon_out_path(&file));
run_pipelined(
&file,
&out,
plan.algo,
plan.center,
plan.dtype,
plan.save_format,
chunk,
None,
None,
plan.filter,
plan.num_iter,
plan.reg_par,
plan.ext_pad,
plan.prep,
common.progress_json,
&engine,
)?;
println!("wrote streamed reconstruction to {out}");
}
Command::TuneChunk {
file,
algorithm,
center,
dtype,
} => {
let engine = Engine::new(backend_kind)?;
let algo: Algorithm = algorithm.parse().map_err(|e| anyhow!("{e}"))?;
let dtype: Dtype = dtype.parse().map_err(|e| anyhow!("{e}"))?;
if !pipelines_well(&engine, algo) {
return Err(anyhow!(
"tune_chunk applies only to CUDA pipelined algorithms \
(fbp, linerec, fourierrec, lprec); {:?} on backend {} uses the \
whole-volume path, where --chunk has no effect",
algo,
engine.name()
));
}
tune_chunk(&file, &algorithm, algo, center, dtype, &engine)?;
}
}
Ok(())
}
fn progress_line(start: usize, end: usize, total: usize, secs: f64) -> String {
format!("{{\"start\":{start},\"end\":{end},\"total\":{total},\"secs\":{secs:.3}}}\n")
}
struct ProgressJsonWriter {
inner: Box<dyn tomoxide::io::VolumeWriter>,
total: usize,
t0: std::time::Instant,
}
impl tomoxide::io::VolumeWriter for ProgressJsonWriter {
fn reserve(&mut self, total_nz: usize) -> tomoxide::Result<()> {
self.total = total_nz;
self.inner.reserve(total_nz)
}
fn write_chunk(
&mut self,
vol: &tomoxide::Volume<f32>,
start: usize,
end: usize,
) -> tomoxide::Result<()> {
self.inner.write_chunk(vol, start, end)?;
use std::io::Write as _;
let line = progress_line(start, end, self.total, self.t0.elapsed().as_secs_f64());
let mut out = std::io::stdout().lock();
let _ = out.write_all(line.as_bytes());
let _ = out.flush();
Ok(())
}
fn finalize(&mut self) -> tomoxide::Result<()> {
self.inner.finalize()
}
}
fn maybe_progress(
writer: Box<dyn tomoxide::io::VolumeWriter>,
enabled: bool,
) -> Box<dyn tomoxide::io::VolumeWriter> {
if enabled {
Box::new(ProgressJsonWriter {
inner: writer,
total: 0,
t0: std::time::Instant::now(),
})
} else {
writer
}
}
#[allow(clippy::too_many_arguments)]
fn run_pipelined(
file: &Path,
out: &str,
algo: Algorithm,
center: Option<f32>,
dtype: Dtype,
save_format: tomoxide::io::SaveFormat,
chunk: usize,
start_row: Option<usize>,
end_row: Option<usize>,
filter: FilterName,
num_iter: usize,
reg_par: Vec<f32>,
ext_pad: bool,
prep: PrepOptions,
progress_json: bool,
engine: &Engine,
) -> anyhow::Result<()> {
let path = file.to_string_lossy().into_owned();
let mut probe = tomoxide::io::open_dxchange(&path)?;
let geom = geometry_from_reader(probe.as_mut(), center)?;
drop(probe);
let params = recon_params(&geom, dtype, filter, num_iter, reg_par, ext_pad);
let read_path = path;
let write_path = out.to_string();
let z_start = start_row.unwrap_or(0);
let z_end = end_row.unwrap_or(usize::MAX);
tomoxide::ReconSteps::new(chunk).run_streaming_pipelined_range(
z_start,
z_end,
move || tomoxide::io::open_dxchange(&read_path),
move || {
tomoxide::io::create_writer(&write_path, save_format)
.map(|w| maybe_progress(w, progress_json))
},
&geom,
algo,
¶ms,
&prep,
engine,
)?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn run_sharded_subprocesses(
file: &Path,
out: &str,
plan: &ReconPlan,
chunk: usize,
nz: usize,
devices: &[i32],
progress_json: bool,
) -> anyhow::Result<()> {
let exe = std::env::current_exe().context("locating current executable")?;
let n = devices.len();
let base = nz / n;
let rem = nz % n;
println!(" multi-GPU: {n} shards across devices {devices:?}");
let mut children = Vec::with_capacity(n);
let mut z0 = 0usize;
for (i, &dev) in devices.iter().enumerate() {
let rows = base + if i < rem { 1 } else { 0 };
let z1 = z0 + rows;
let mut cmd = std::process::Command::new(&exe);
cmd.arg("--backend")
.arg("cuda")
.arg("recon")
.arg(file)
.arg("--algorithm")
.arg(&plan.algorithm)
.arg("--dtype")
.arg(plan.dtype.as_str())
.arg("--save_format")
.arg(&plan.save_format_str)
.arg("--filter")
.arg(&plan.filter_str)
.arg("--remove_stripe")
.arg(&plan.stripe_str)
.arg("--retrieve_phase")
.arg(&plan.phase_str)
.arg("--num_iter")
.arg(plan.num_iter.to_string())
.arg("--chunk")
.arg(chunk.to_string())
.arg("--start_row")
.arg(z0.to_string())
.arg("--end_row")
.arg(z1.to_string())
.arg("--output")
.arg(out);
if plan.ext_pad {
cmd.arg("--ext_pad").arg("true");
}
if !plan.reg_par.is_empty() {
let csv = plan
.reg_par
.iter()
.map(|v| v.to_string())
.collect::<Vec<_>>()
.join(",");
cmd.arg("--reg_par").arg(csv);
}
let sp = &plan.stripe_params;
match plan.stripe_str.to_ascii_lowercase().as_str() {
"fw" => {
cmd.arg("--fw_sigma").arg(sp.fw_sigma.to_string());
if let Some(l) = sp.fw_level {
cmd.arg("--fw_level").arg(l.to_string());
}
}
"ti" => {
cmd.arg("--ti_nblock")
.arg(sp.ti_nblock.to_string())
.arg("--ti_beta")
.arg(sp.ti_beta.to_string());
}
"sf" => {
cmd.arg("--sf_size").arg(sp.sf_size.to_string());
}
"vo-all" | "vo_all" | "voall" => {
cmd.arg("--vo_snr")
.arg(sp.vo_snr.to_string())
.arg("--vo_la_size")
.arg(sp.vo_la_size.to_string())
.arg("--vo_sm_size")
.arg(sp.vo_sm_size.to_string());
}
"vo-sort" | "vo_sort" => {
cmd.arg("--vo_sort_size")
.arg(sp.vo_sort_size.unwrap_or(0).to_string())
.arg("--vo_sort_dim")
.arg(sp.vo_sort_dim.to_string());
}
"vo-filter" | "vo_filter" => {
cmd.arg("--vo_filter_sigma")
.arg(sp.vo_filter_sigma.to_string())
.arg("--vo_filter_size")
.arg(sp.vo_filter_size.unwrap_or(0).to_string())
.arg("--vo_filter_dim")
.arg(sp.vo_filter_dim.to_string());
}
"vo-large" | "vo_large" => {
cmd.arg("--vo_large_snr")
.arg(sp.vo_large_snr.to_string())
.arg("--vo_large_size")
.arg(sp.vo_large_size.to_string())
.arg("--vo_large_drop_ratio")
.arg(sp.vo_large_drop_ratio.to_string())
.arg("--vo_large_norm")
.arg(sp.vo_large_norm.to_string());
}
"vo-dead" | "vo_dead" => {
cmd.arg("--vo_dead_snr")
.arg(sp.vo_dead_snr.to_string())
.arg("--vo_dead_size")
.arg(sp.vo_dead_size.to_string())
.arg("--vo_dead_norm")
.arg(sp.vo_dead_norm.to_string());
}
"vo-fit" | "vo_fit" => {
cmd.arg("--vo_fit_order")
.arg(sp.vo_fit_order.to_string())
.arg("--vo_fit_sigma_x")
.arg(sp.vo_fit_sigma.0.to_string())
.arg("--vo_fit_sigma_y")
.arg(sp.vo_fit_sigma.1.to_string());
}
_ => {}
}
if !plan.phase_str.eq_ignore_ascii_case("none") {
let pp = &plan.phase_params;
cmd.arg("--pixel_size")
.arg(pp.pixel_size.to_string())
.arg("--propagation_distance")
.arg(pp.dist.to_string())
.arg("--energy")
.arg(pp.energy.to_string())
.arg("--alpha")
.arg(pp.alpha.to_string())
.arg("--db")
.arg(pp.db.to_string())
.arg("--w")
.arg(pp.w.to_string());
}
if let Some(c) = plan.center {
cmd.arg("--center").arg(c.to_string());
}
if progress_json {
cmd.arg("--progress_json");
}
cmd.env("CUDA_VISIBLE_DEVICES", dev.to_string())
.env("TOMOXIDE_CUDA_DEVICES", "0")
.stdout(if progress_json {
std::process::Stdio::inherit()
} else {
std::process::Stdio::null()
})
.stderr(std::process::Stdio::piped());
let child = cmd
.spawn()
.with_context(|| format!("spawning shard {i} on device {dev}"))?;
children.push((i, dev, z0, z1, child));
z0 = z1;
}
let mut failures = Vec::new();
for (i, dev, s, e, child) in children {
let output = child
.wait_with_output()
.with_context(|| format!("waiting for shard {i} on device {dev}"))?;
if !output.status.success() {
let reason = subprocess_failure_reason(&output);
failures.push(format!(
"shard {i} (device {dev}, rows [{s}, {e})): {reason}"
));
}
}
if !failures.is_empty() {
return Err(anyhow!(
"multi-GPU recon failed:\n {}",
failures.join("\n ")
));
}
Ok(())
}
fn resolve_chunk(
explicit: Option<usize>,
file: &Path,
algorithm: &str,
dtype: Dtype,
nx: usize,
nproj: usize,
nz: usize,
) -> (usize, &'static str) {
if let Some(c) = explicit {
return (c.max(1), "--chunk");
}
let gpu = tomoxide::cuda::device_name().unwrap_or_else(|| "unknown".into());
let key = chunk_cache::key(file, algorithm, dtype.as_str(), &gpu);
if let Some(c) = chunk_cache::ChunkCache::load().get(&key, nx, nproj, nz) {
return (c, "from cache");
}
(DEFAULT_PIPELINE_CHUNK, "default")
}
fn chunk_candidates(nz: usize) -> Vec<usize> {
let mut c = Vec::new();
let mut k = DEFAULT_PIPELINE_CHUNK;
while k <= nz / 2 {
c.push(k);
k *= 2;
}
if c.is_empty() {
c.push(nz.max(1));
}
c
}
fn tune_scratch_dir(file: &Path) -> PathBuf {
let parent = file
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
parent.join(format!(".tomoxide_tune_{}", std::process::id()))
}
#[allow(clippy::too_many_arguments)]
fn measure_chunk_subprocess(
exe: &Path,
backend: &str,
link: &Path,
algorithm: &str,
center: Option<f32>,
dtype: Dtype,
chunk: usize,
) -> anyhow::Result<f64> {
let mut cmd = std::process::Command::new(exe);
cmd.arg("--backend")
.arg(backend)
.arg("recon")
.arg(link)
.arg("--algorithm")
.arg(algorithm)
.arg("--dtype")
.arg(dtype.as_str())
.arg("--chunk")
.arg(chunk.to_string());
if let Some(c) = center {
cmd.arg("--center").arg(c.to_string());
}
cmd.env("TOMOXIDE_CUDA_DEVICES", "0")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped());
let t = Instant::now();
let output = cmd
.output()
.with_context(|| format!("spawning {} recon", exe.display()))?;
let secs = t.elapsed().as_secs_f64();
if !output.status.success() {
return Err(anyhow!("{}", subprocess_failure_reason(&output)));
}
Ok(secs)
}
fn subprocess_failure_reason(output: &std::process::Output) -> String {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if let Some(sig) = output.status.signal() {
return format!("killed by signal {sig} (does not fit in device memory)");
}
}
let stderr = String::from_utf8_lossy(&output.stderr);
if let Some(e) = stderr
.lines()
.rev()
.find(|l| l.trim_start().starts_with("Error:"))
{
return e
.trim_start()
.trim_start_matches("Error:")
.trim()
.to_string();
}
stderr
.lines()
.rev()
.map(str::trim)
.find(|l| !l.is_empty() && !l.starts_with('['))
.unwrap_or("recon subprocess failed")
.to_string()
}
fn tune_chunk(
file: &Path,
algorithm: &str,
algo: Algorithm,
center: Option<f32>,
dtype: Dtype,
engine: &Engine,
) -> anyhow::Result<()> {
let mut probe = tomoxide::io::open_dxchange(&file.to_string_lossy())?;
let (nproj, nz, nx, _nflat, _ndark) = probe.read_sizes()?;
drop(probe);
let gpu = tomoxide::cuda::device_name().unwrap_or_else(|| "unknown".into());
let candidates = chunk_candidates(nz);
println!(
"tune_chunk: file={} algorithm={:?} dtype={} gpu={} dims=(nx={} nproj={} nz={})",
file.display(),
algo,
dtype.as_str(),
gpu,
nx,
nproj,
nz
);
println!(" candidates (powers of two, ≥2 chunks): {candidates:?}");
let exe = std::env::current_exe().context("locating the tomoxide executable")?;
let scratch = tune_scratch_dir(file);
std::fs::create_dir_all(&scratch)
.with_context(|| format!("creating scratch dir {}", scratch.display()))?;
let link = scratch.join("in.h5");
std::fs::hard_link(file, &link)
.with_context(|| format!("hard-linking {} -> {}", file.display(), link.display()))?;
let cand_out = recon_out_path(&link);
let mut results: Vec<(usize, f64)> = Vec::new();
for &c in &candidates {
match measure_chunk_subprocess(&exe, engine.name(), &link, algorithm, center, dtype, c) {
Ok(secs) => {
println!(" chunk={c:>4}: {secs:.2}s (wall, incl. process+CUDA init)");
results.push((c, secs));
}
Err(e) => println!(" chunk={c:>4}: skipped ({e})"),
}
let _ = std::fs::remove_dir_all(&cand_out);
let _ = std::fs::remove_file(&cand_out);
}
let _ = std::fs::remove_dir_all(&scratch);
let (best_chunk, best_secs) = results
.iter()
.copied()
.min_by(|a, b| a.1.total_cmp(&b.1))
.ok_or_else(|| anyhow!("all chunk candidates failed to run (see skip reasons above)"))?;
let key = chunk_cache::key(file, algorithm, dtype.as_str(), &gpu);
let mut cache = chunk_cache::ChunkCache::load();
cache.insert(
key,
chunk_cache::Entry {
chunk: best_chunk,
nx,
nproj,
nz,
},
);
cache.save()?;
println!(
" best chunk = {best_chunk} ({best_secs:.2}s) → cached to {}",
chunk_cache::CACHE_FILE
);
Ok(())
}
fn row_band(
start: Option<usize>,
end: Option<usize>,
nz: usize,
lamino_angle: Option<f32>,
) -> anyhow::Result<(usize, usize, bool)> {
let banded = start.is_some() || end.is_some();
if banded && lamino_angle.is_some() {
return Err(anyhow!(
"laminography reconstructs the whole stack; --start_row/--end_row are unsupported"
));
}
let z0 = start.unwrap_or(0);
let z1 = end.unwrap_or(nz).min(nz);
if z0 >= z1 {
return Err(anyhow!(
"row range [{z0}, {z1}) is empty (dataset has {nz} rows)"
));
}
Ok((z0, z1, banded))
}
fn geometry_from_reader(
reader: &mut dyn DatasetReader,
center: Option<f32>,
) -> anyhow::Result<Geometry> {
let (_nproj, nz, nx, _nflat, _ndark) = reader.read_sizes()?;
let theta = reader.read_theta()?;
let mut geom = Geometry::parallel(Angles(theta), nx, nz, 1.0);
if let Some(c) = center {
geom.center = Center::Scalar(c);
}
Ok(geom)
}
fn recon_params(
geom: &Geometry,
dtype: Dtype,
filter_name: FilterName,
num_iter: usize,
reg_par: Vec<f32>,
ext_pad: bool,
) -> ReconParams {
ReconParams {
num_gridx: Some(geom.detector.width),
dtype,
filter_name,
num_iter,
reg_par,
ext_pad,
..Default::default()
}
}
fn reconstruct_chain(
mut ds: tomoxide::Dataset<f32>,
geom: &Geometry,
plan: &ReconPlan,
lamino_rh: Option<usize>,
engine: &Engine,
) -> anyhow::Result<tomoxide::Volume<f32>> {
let backend = engine.backend();
tomoxide::prep::normalize_dataset(&mut ds, backend)?;
tomoxide::prep::retrieve_phase(&mut ds.data, plan.prep.phase, backend)?;
let mut sino = ds.data.to_layout(tomoxide::Layout::Sinogram);
tomoxide::prep::remove_stripe(&mut sino, plan.prep.stripe)?;
let n = plan.chain.len();
let mut init: Option<tomoxide::Volume<f32>> = None;
for (i, stage) in plan.chain.iter().enumerate() {
let mut params = recon_params(
geom,
plan.dtype,
plan.filter,
stage.num_iter,
plan.reg_par.clone(),
plan.ext_pad,
);
params.lamino_rh = lamino_rh;
params.init = init.take();
let warm = params.init.is_some();
let vol = tomoxide::recon::recon(&sino, geom, stage.algo, ¶ms, backend)?;
let iters_note = if stage.algo.is_analytic() {
String::new()
} else {
format!(" ×{} iters", stage.num_iter)
};
println!(
" chain [{}/{n}] {:?}{iters_note}{}",
i + 1,
stage.algo,
if warm { " (warm-started)" } else { "" }
);
init = Some(vol);
}
init.ok_or_else(|| anyhow!("empty algorithm chain"))
}
fn recon_out_path(file: &Path) -> String {
format!("{}_rec", file.with_extension("").display())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chain_stage_uses_default_iters_without_suffix() {
let s = parse_chain_stage("sirt", 25).unwrap();
assert_eq!(s.algo, Algorithm::Sirt);
assert_eq!(s.num_iter, 25);
}
#[test]
fn chain_stage_suffix_overrides_default() {
let s = parse_chain_stage("sirt:30", 25).unwrap();
assert_eq!(s.algo, Algorithm::Sirt);
assert_eq!(s.num_iter, 30);
}
#[test]
fn chain_stage_trims_whitespace() {
let s = parse_chain_stage(" tv : 10 ", 5).unwrap();
assert_eq!(s.algo, Algorithm::Tv);
assert_eq!(s.num_iter, 10);
}
#[test]
fn analytic_stage_rejects_iters() {
let err = parse_chain_stage("fbp:5", 25).unwrap_err().to_string();
assert!(err.contains("analytic"), "{err}");
}
#[test]
fn analytic_stage_without_iters_ok() {
let s = parse_chain_stage("fbp", 25).unwrap();
assert_eq!(s.algo, Algorithm::Fbp);
}
#[test]
fn zero_iters_rejected() {
let err = parse_chain_stage("sirt:0", 25).unwrap_err().to_string();
assert!(err.contains(">= 1"), "{err}");
}
#[test]
fn non_numeric_iters_rejected() {
assert!(parse_chain_stage("sirt:abc", 25).is_err());
}
#[test]
fn unknown_algorithm_rejected() {
assert!(parse_chain_stage("nope", 25).is_err());
}
#[test]
fn vo_variant_stripe_methods_parse() {
let p = StripeParams {
fw_sigma: 2.0,
fw_level: None,
ti_nblock: 0,
ti_beta: 1.5,
sf_size: 5,
vo_snr: 3.0,
vo_la_size: 61,
vo_sm_size: 21,
vo_sort_size: None,
vo_sort_dim: 1,
vo_filter_sigma: 3.0,
vo_filter_size: Some(7),
vo_filter_dim: 2,
vo_large_snr: 3.0,
vo_large_size: 51,
vo_large_drop_ratio: 0.1,
vo_large_norm: true,
vo_dead_snr: 4.0,
vo_dead_size: 41,
vo_dead_norm: false,
vo_fit_order: 3,
vo_fit_sigma: (5.0, 20.0),
};
assert_eq!(
build_stripe("vo-sort", &p).unwrap(),
StripeMethod::VoSort { size: None, dim: 1 }
);
assert_eq!(
build_stripe("vo_filter", &p).unwrap(),
StripeMethod::VoFilter {
sigma: 3.0,
size: Some(7),
dim: 2
}
);
assert_eq!(
build_stripe("vo-large", &p).unwrap(),
StripeMethod::VoLarge {
snr: 3.0,
size: 51,
drop_ratio: 0.1,
norm: true
}
);
assert_eq!(
build_stripe("vo-dead", &p).unwrap(),
StripeMethod::VoDead {
snr: 4.0,
size: 41,
norm: false
}
);
assert_eq!(
build_stripe("vo-fit", &p).unwrap(),
StripeMethod::VoFit {
order: 3,
sigma: (5.0, 20.0)
}
);
let err = build_stripe("nope", &p).unwrap_err().to_string();
assert!(err.contains("vo-fit"), "{err}");
}
#[test]
fn progress_line_format() {
assert_eq!(
progress_line(8, 16, 128, 1.23456),
"{\"start\":8,\"end\":16,\"total\":128,\"secs\":1.235}\n"
);
}
#[test]
fn progress_tee_forwards_all_calls() {
use std::sync::{Arc, Mutex};
#[derive(Default)]
struct Log {
reserved: Option<usize>,
chunks: Vec<(usize, usize, usize)>, finalized: bool,
}
struct MockWriter(Arc<Mutex<Log>>);
impl tomoxide::io::VolumeWriter for MockWriter {
fn reserve(&mut self, total_nz: usize) -> tomoxide::Result<()> {
self.0.lock().unwrap().reserved = Some(total_nz);
Ok(())
}
fn write_chunk(
&mut self,
vol: &tomoxide::Volume<f32>,
start: usize,
end: usize,
) -> tomoxide::Result<()> {
self.0
.lock()
.unwrap()
.chunks
.push((start, end, vol.dims().0));
Ok(())
}
fn finalize(&mut self) -> tomoxide::Result<()> {
self.0.lock().unwrap().finalized = true;
Ok(())
}
}
let log = Arc::new(Mutex::new(Log::default()));
let mut w = maybe_progress(Box::new(MockWriter(log.clone())), true);
w.reserve(64).unwrap();
let vol = tomoxide::Volume::new(ndarray::Array3::<f32>::zeros((4, 2, 2)));
w.write_chunk(&vol, 8, 12).unwrap();
w.finalize().unwrap();
let l = log.lock().unwrap();
assert_eq!(l.reserved, Some(64));
assert_eq!(l.chunks, vec![(8, 12, 4)]);
assert!(l.finalized);
}
#[test]
fn maybe_progress_disabled_is_passthrough() {
struct Counting(Arc<std::sync::atomic::AtomicUsize>);
use std::sync::Arc;
impl tomoxide::io::VolumeWriter for Counting {
fn write_chunk(
&mut self,
_vol: &tomoxide::Volume<f32>,
_start: usize,
_end: usize,
) -> tomoxide::Result<()> {
self.0.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Ok(())
}
}
let n = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let mut w = maybe_progress(Box::new(Counting(n.clone())), false);
let vol = tomoxide::Volume::new(ndarray::Array3::<f32>::zeros((1, 2, 2)));
w.write_chunk(&vol, 0, 1).unwrap();
assert_eq!(n.load(std::sync::atomic::Ordering::Relaxed), 1);
}
}