#![forbid(unsafe_code)]
use std::{path::PathBuf, time::Duration};
use clap::{Args, Parser, Subcommand, ValueEnum};
use wsi_dicom::{
default_transfer_syntax_for_source, profile_dicom_route_corpus_coverage,
profile_dicom_route_coverage, profile_dicom_routes, CodecValidation,
DefaultTransferSyntaxRequest, DicomExport, DicomExportOptions, DicomExportReport,
DicomMetadata, DicomRouteCorpusCoverageProgress, DicomRouteCorpusCoverageRequest,
DicomRouteCoverageProgress, DicomRouteCoverageReport, DicomRouteCoverageRequest,
DicomRouteProfileRequest, EncodeBackendPreference, MetadataSource, TransferSyntax,
WsiDicomError,
};
mod cli_report;
use cli_report::{
duration_as_reported_micros, format_corpus_coverage_summary, format_coverage_summary,
format_profile_summary, format_report_summary, format_sustain_export_iteration_summary,
format_sustain_iteration_summary, process_memory_pressure, process_resident_memory_bytes,
process_thermal_state,
};
#[derive(Debug, Parser)]
#[command(name = "wsi-dicom")]
#[command(about = "Convert statumen-readable whole-slide images to DICOM VL WSI")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Convert {
source: PathBuf,
#[arg(long)]
out: PathBuf,
#[arg(long)]
metadata: Option<PathBuf>,
#[arg(long)]
research_placeholder: bool,
#[arg(long, value_enum, default_value_t = BackendArg::Auto)]
backend: BackendArg,
#[arg(long, default_value_t = 512)]
tile_size: u32,
#[arg(long, default_value_t = 90)]
jpeg_quality: u8,
#[arg(long, value_enum)]
transfer_syntax: Option<TransferSyntaxArg>,
#[arg(long)]
j2k_decomposition_levels: Option<u8>,
#[arg(long, value_enum, default_value_t = CodecValidationArg::Disabled)]
codec_validation: CodecValidationArg,
#[arg(long)]
source_device_decode: bool,
#[command(flatten)]
gpu_encode: GpuEncodeArgs,
#[arg(long)]
level: Option<u32>,
#[arg(long)]
json: bool,
},
Profile {
source: PathBuf,
#[arg(long, value_enum, default_value_t = BackendArg::Auto)]
backend: BackendArg,
#[arg(long, default_value_t = 512)]
tile_size: u32,
#[arg(long, default_value_t = 90)]
jpeg_quality: u8,
#[arg(long, value_enum)]
transfer_syntax: Option<TransferSyntaxArg>,
#[arg(long)]
j2k_decomposition_levels: Option<u8>,
#[arg(long, value_enum, default_value_t = CodecValidationArg::Disabled)]
codec_validation: CodecValidationArg,
#[arg(long, default_value_t = 0)]
level: u32,
#[arg(long, default_value_t = 64)]
max_frames: u64,
#[arg(long)]
source_device_decode: bool,
#[command(flatten)]
gpu_encode: GpuEncodeArgs,
#[arg(long)]
json: bool,
},
Coverage {
source: PathBuf,
#[arg(long, value_enum, default_value_t = BackendArg::Auto)]
backend: BackendArg,
#[arg(long, default_value_t = 512)]
tile_size: u32,
#[arg(long, default_value_t = 90)]
jpeg_quality: u8,
#[arg(long, value_enum)]
transfer_syntax: Option<TransferSyntaxArg>,
#[arg(long)]
j2k_decomposition_levels: Option<u8>,
#[arg(long, value_enum, default_value_t = CodecValidationArg::Disabled)]
codec_validation: CodecValidationArg,
#[arg(long, default_value_t = 64)]
max_frames_per_level: u64,
#[arg(long)]
full_frame_coverage: bool,
#[arg(long)]
max_levels: Option<u32>,
#[arg(long)]
max_level_ms: Option<u64>,
#[arg(long)]
source_device_decode: bool,
#[command(flatten)]
gpu_encode: GpuEncodeArgs,
#[arg(long)]
json: bool,
},
CoverageCorpus {
root: PathBuf,
#[arg(long, value_enum, default_value_t = BackendArg::Auto)]
backend: BackendArg,
#[arg(long, default_value_t = 512)]
tile_size: u32,
#[arg(long, default_value_t = 90)]
jpeg_quality: u8,
#[arg(long, value_enum)]
transfer_syntax: Option<TransferSyntaxArg>,
#[arg(long)]
j2k_decomposition_levels: Option<u8>,
#[arg(long, value_enum, default_value_t = CodecValidationArg::Disabled)]
codec_validation: CodecValidationArg,
#[arg(long, default_value_t = 64)]
max_frames_per_level: u64,
#[arg(long)]
full_frame_coverage: bool,
#[arg(long)]
max_levels: Option<u32>,
#[arg(long)]
max_level_ms: Option<u64>,
#[arg(long)]
source_device_decode: bool,
#[command(flatten)]
gpu_encode: GpuEncodeArgs,
#[arg(long)]
json: bool,
},
SustainConvert {
source: PathBuf,
#[arg(long)]
out: PathBuf,
#[arg(long)]
metadata: Option<PathBuf>,
#[arg(long)]
research_placeholder: bool,
#[arg(long, value_enum, default_value_t = BackendArg::Auto)]
backend: BackendArg,
#[arg(long, default_value_t = 512)]
tile_size: u32,
#[arg(long, default_value_t = 90)]
jpeg_quality: u8,
#[arg(long, value_enum)]
transfer_syntax: Option<TransferSyntaxArg>,
#[arg(long)]
j2k_decomposition_levels: Option<u8>,
#[arg(long, value_enum, default_value_t = CodecValidationArg::Disabled)]
codec_validation: CodecValidationArg,
#[arg(long)]
source_device_decode: bool,
#[command(flatten)]
gpu_encode: GpuEncodeArgs,
#[arg(long)]
level: Option<u32>,
#[arg(long, default_value_t = 5)]
iterations: u32,
#[arg(long, default_value_t = 0)]
interval_ms: u64,
#[arg(long)]
json: bool,
},
Sustain {
source: PathBuf,
#[arg(long, value_enum, default_value_t = BackendArg::Auto)]
backend: BackendArg,
#[arg(long, default_value_t = 512)]
tile_size: u32,
#[arg(long, default_value_t = 90)]
jpeg_quality: u8,
#[arg(long, value_enum)]
transfer_syntax: Option<TransferSyntaxArg>,
#[arg(long)]
j2k_decomposition_levels: Option<u8>,
#[arg(long, value_enum, default_value_t = CodecValidationArg::Disabled)]
codec_validation: CodecValidationArg,
#[arg(long, default_value_t = 64)]
max_frames_per_level: u64,
#[arg(long)]
full_frame_coverage: bool,
#[arg(long)]
max_levels: Option<u32>,
#[arg(long)]
max_level_ms: Option<u64>,
#[arg(long, default_value_t = 5)]
iterations: u32,
#[arg(long, default_value_t = 0)]
interval_ms: u64,
#[arg(long)]
source_device_decode: bool,
#[command(flatten)]
gpu_encode: GpuEncodeArgs,
#[arg(long)]
json: bool,
},
}
#[derive(Debug, Clone, Copy, Default, Args)]
struct GpuEncodeArgs {
#[arg(long)]
gpu_encode_inflight_tiles: Option<usize>,
#[arg(long)]
gpu_encode_memory_mib: Option<u64>,
}
impl GpuEncodeArgs {
fn into_options_fields(self, options: &mut DicomExportOptions) {
options.gpu_encode_inflight_tiles = self.gpu_encode_inflight_tiles;
options.gpu_encode_memory_mib = self.gpu_encode_memory_mib;
}
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum BackendArg {
Auto,
Cpu,
PreferDevice,
RequireDevice,
}
impl BackendArg {
fn into_preference(self) -> EncodeBackendPreference {
match self {
Self::Auto => EncodeBackendPreference::Auto,
Self::Cpu => EncodeBackendPreference::CpuOnly,
Self::PreferDevice => EncodeBackendPreference::PreferDevice,
Self::RequireDevice => EncodeBackendPreference::RequireDevice,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum TransferSyntaxArg {
JpegBaseline8Bit,
Jpeg2000,
Jpeg2000Lossless,
Htj2kLossless,
Htj2kLosslessRpcl,
}
impl TransferSyntaxArg {
fn into_transfer_syntax(self) -> TransferSyntax {
match self {
Self::JpegBaseline8Bit => TransferSyntax::JpegBaseline8Bit,
Self::Jpeg2000 => TransferSyntax::Jpeg2000,
Self::Jpeg2000Lossless => TransferSyntax::Jpeg2000Lossless,
Self::Htj2kLossless => TransferSyntax::Htj2kLossless,
Self::Htj2kLosslessRpcl => TransferSyntax::Htj2kLosslessRpcl,
}
}
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum CodecValidationArg {
Disabled,
RoundTrip,
}
impl CodecValidationArg {
fn into_codec_validation(self) -> CodecValidation {
match self {
Self::Disabled => CodecValidation::Disabled,
Self::RoundTrip => CodecValidation::RoundTrip,
}
}
}
fn main() {
if let Err(err) = run() {
eprintln!("{err}");
std::process::exit(1);
}
}
fn run() -> Result<(), WsiDicomError> {
match Cli::parse().command {
Command::Convert {
source,
out,
metadata,
research_placeholder,
backend,
tile_size,
jpeg_quality,
transfer_syntax,
j2k_decomposition_levels,
codec_validation,
source_device_decode,
gpu_encode,
level,
json,
} => {
let metadata = load_metadata_source(metadata, research_placeholder)?;
let mut export = DicomExport::from_slide(source)
.to_directory(out)
.with_metadata(metadata);
if let Some(level) = level {
export = export.level(level);
}
export = export.with_options(dicom_export_options(
tile_size,
jpeg_quality,
transfer_syntax
.map(TransferSyntaxArg::into_transfer_syntax)
.unwrap_or(TransferSyntax::Htj2kLosslessRpcl),
j2k_decomposition_levels,
backend,
codec_validation,
source_device_decode,
gpu_encode,
));
if transfer_syntax.is_none() {
export = export.source_aware_transfer_syntax();
}
let report = export.run()?;
if json {
print_json_line(&report)?;
} else {
println!("{}", format_report_summary(&report));
}
Ok(())
}
Command::Profile {
source,
backend,
tile_size,
jpeg_quality,
transfer_syntax,
j2k_decomposition_levels,
codec_validation,
level,
max_frames,
source_device_decode,
gpu_encode,
json,
} => {
let transfer_syntax =
resolve_transfer_syntax(&source, tile_size, transfer_syntax, Some(level), None)?;
let report = profile_dicom_routes(DicomRouteProfileRequest {
source_path: source,
options: dicom_export_options(
tile_size,
jpeg_quality,
transfer_syntax,
j2k_decomposition_levels,
backend,
codec_validation,
source_device_decode,
gpu_encode,
),
level,
max_frames,
})?;
if json {
print_json_line(&report)?;
} else {
println!("{}", format_profile_summary(&report));
}
Ok(())
}
Command::Coverage {
source,
backend,
tile_size,
jpeg_quality,
transfer_syntax,
j2k_decomposition_levels,
codec_validation,
max_frames_per_level,
full_frame_coverage,
max_levels,
max_level_ms,
source_device_decode,
gpu_encode,
json,
} => {
let max_frames_per_level =
effective_max_frames_per_level(max_frames_per_level, full_frame_coverage);
let max_level_elapsed = max_level_elapsed_from_ms(max_level_ms)?;
let transfer_syntax =
resolve_transfer_syntax(&source, tile_size, transfer_syntax, None, max_levels)?;
let report = profile_dicom_route_coverage(DicomRouteCoverageRequest {
source_path: source,
options: dicom_export_options(
tile_size,
jpeg_quality,
transfer_syntax,
j2k_decomposition_levels,
backend,
codec_validation,
source_device_decode,
gpu_encode,
),
max_frames_per_level,
max_levels,
max_level_elapsed,
progress: (!json).then_some(DicomRouteCoverageProgress::Stderr),
})?;
if json {
print_json_line(&report)?;
} else {
println!("{}", format_coverage_summary(&report));
}
Ok(())
}
Command::CoverageCorpus {
root,
backend,
tile_size,
jpeg_quality,
transfer_syntax,
j2k_decomposition_levels,
codec_validation,
max_frames_per_level,
full_frame_coverage,
max_levels,
max_level_ms,
source_device_decode,
gpu_encode,
json,
} => {
let max_frames_per_level =
effective_max_frames_per_level(max_frames_per_level, full_frame_coverage);
let max_level_elapsed = max_level_elapsed_from_ms(max_level_ms)?;
let transfer_syntax =
resolve_corpus_transfer_syntax(&root, tile_size, transfer_syntax, max_levels)?;
let report = profile_dicom_route_corpus_coverage(DicomRouteCorpusCoverageRequest {
source_root: root,
options: dicom_export_options(
tile_size,
jpeg_quality,
transfer_syntax,
j2k_decomposition_levels,
backend,
codec_validation,
source_device_decode,
gpu_encode,
),
max_frames_per_level,
max_levels,
max_level_elapsed,
progress: (!json).then_some(DicomRouteCorpusCoverageProgress::Stderr),
})?;
if json {
print_json_line(&report)?;
} else {
println!("{}", format_corpus_coverage_summary(&report));
}
Ok(())
}
Command::SustainConvert {
source,
out,
metadata,
research_placeholder,
backend,
tile_size,
jpeg_quality,
transfer_syntax,
j2k_decomposition_levels,
codec_validation,
source_device_decode,
gpu_encode,
level,
iterations,
interval_ms,
json,
} => {
if iterations == 0 {
return Err(WsiDicomError::Unsupported {
reason: "sustain-convert requires iterations > 0".into(),
});
}
let metadata = load_metadata_source(metadata, research_placeholder)?;
let options = dicom_export_options(
tile_size,
jpeg_quality,
transfer_syntax
.map(TransferSyntaxArg::into_transfer_syntax)
.unwrap_or(TransferSyntax::Htj2kLosslessRpcl),
j2k_decomposition_levels,
backend,
codec_validation,
source_device_decode,
gpu_encode,
);
for iteration in 1..=iterations {
let output_dir = out.join(format!("iteration-{iteration:04}"));
let started = std::time::Instant::now();
let mut export = DicomExport::from_slide(source.clone())
.to_directory(output_dir)
.with_metadata(metadata.clone())
.with_options(options.clone());
if let Some(level) = level {
export = export.level(level);
}
if transfer_syntax.is_none() {
export = export.source_aware_transfer_syntax();
}
let report = export.run()?;
let elapsed_micros = duration_as_reported_micros(started.elapsed());
let thermal_state = process_thermal_state();
let memory_pressure = process_memory_pressure();
let rss_bytes = process_resident_memory_bytes();
if json {
print_json_line(&SustainExportIterationJson {
mode: "convert",
iteration,
iterations,
elapsed_micros,
rss_bytes,
thermal_state: thermal_state.as_deref(),
memory_pressure: memory_pressure.as_deref(),
report: &report,
})?;
} else {
println!(
"{}",
format_sustain_export_iteration_summary(
iteration,
iterations,
&report,
elapsed_micros,
rss_bytes,
thermal_state.as_deref(),
memory_pressure.as_deref(),
)
);
}
if interval_ms > 0 && iteration < iterations {
std::thread::sleep(std::time::Duration::from_millis(interval_ms));
}
}
Ok(())
}
Command::Sustain {
source,
backend,
tile_size,
jpeg_quality,
transfer_syntax,
j2k_decomposition_levels,
codec_validation,
max_frames_per_level,
full_frame_coverage,
max_levels,
max_level_ms,
iterations,
interval_ms,
source_device_decode,
gpu_encode,
json,
} => {
if iterations == 0 {
return Err(WsiDicomError::Unsupported {
reason: "sustain requires iterations > 0".into(),
});
}
let transfer_syntax =
resolve_transfer_syntax(&source, tile_size, transfer_syntax, None, max_levels)?;
let options = dicom_export_options(
tile_size,
jpeg_quality,
transfer_syntax,
j2k_decomposition_levels,
backend,
codec_validation,
source_device_decode,
gpu_encode,
);
let max_frames_per_level =
effective_max_frames_per_level(max_frames_per_level, full_frame_coverage);
let max_level_elapsed = max_level_elapsed_from_ms(max_level_ms)?;
for iteration in 1..=iterations {
let report = profile_dicom_route_coverage(DicomRouteCoverageRequest {
source_path: source.clone(),
options: options.clone(),
max_frames_per_level,
max_levels,
max_level_elapsed,
progress: None,
})?;
let thermal_state = process_thermal_state();
let memory_pressure = process_memory_pressure();
let rss_bytes = process_resident_memory_bytes();
if json {
print_json_line(&SustainCoverageIterationJson {
mode: "coverage",
iteration,
iterations,
rss_bytes,
thermal_state: thermal_state.as_deref(),
memory_pressure: memory_pressure.as_deref(),
report: &report,
})?;
} else {
println!(
"{}",
format_sustain_iteration_summary(
iteration,
iterations,
&report,
rss_bytes,
thermal_state.as_deref(),
memory_pressure.as_deref(),
)
);
}
if interval_ms > 0 && iteration < iterations {
std::thread::sleep(std::time::Duration::from_millis(interval_ms));
}
}
Ok(())
}
}
}
#[derive(serde::Serialize)]
struct SustainExportIterationJson<'a> {
mode: &'static str,
iteration: u32,
iterations: u32,
elapsed_micros: u128,
rss_bytes: Option<u64>,
thermal_state: Option<&'a str>,
memory_pressure: Option<&'a str>,
report: &'a DicomExportReport,
}
#[derive(serde::Serialize)]
struct SustainCoverageIterationJson<'a> {
mode: &'static str,
iteration: u32,
iterations: u32,
rss_bytes: Option<u64>,
thermal_state: Option<&'a str>,
memory_pressure: Option<&'a str>,
report: &'a DicomRouteCoverageReport,
}
fn print_json_line<T: serde::Serialize>(value: &T) -> Result<(), WsiDicomError> {
let json = serde_json::to_string(value).map_err(|source| WsiDicomError::JsonSerialize {
message: source.to_string(),
})?;
println!("{json}");
Ok(())
}
fn effective_max_frames_per_level(max_frames_per_level: u64, full_frame_coverage: bool) -> u64 {
if full_frame_coverage {
u64::MAX
} else {
max_frames_per_level
}
}
fn max_level_elapsed_from_ms(max_level_ms: Option<u64>) -> Result<Option<Duration>, WsiDicomError> {
match max_level_ms {
Some(0) => Err(WsiDicomError::Unsupported {
reason: "--max-level-ms must be greater than 0 when provided".into(),
}),
Some(max_level_ms) => Ok(Some(Duration::from_millis(max_level_ms))),
None => Ok(None),
}
}
fn resolve_transfer_syntax(
source: &std::path::Path,
tile_size: u32,
transfer_syntax: Option<TransferSyntaxArg>,
level_filter: Option<u32>,
max_levels: Option<u32>,
) -> Result<TransferSyntax, WsiDicomError> {
match transfer_syntax {
Some(transfer_syntax) => Ok(transfer_syntax.into_transfer_syntax()),
None => default_transfer_syntax_for_source(DefaultTransferSyntaxRequest {
source_path: source.to_path_buf(),
tile_size,
level_filter,
max_levels,
}),
}
}
fn resolve_corpus_transfer_syntax(
root: &std::path::Path,
tile_size: u32,
transfer_syntax: Option<TransferSyntaxArg>,
max_levels: Option<u32>,
) -> Result<TransferSyntax, WsiDicomError> {
match transfer_syntax {
Some(transfer_syntax) => Ok(transfer_syntax.into_transfer_syntax()),
None if root.is_file() => {
default_transfer_syntax_for_source(DefaultTransferSyntaxRequest {
source_path: root.to_path_buf(),
tile_size,
level_filter: None,
max_levels,
})
}
None => Ok(TransferSyntax::Htj2kLosslessRpcl),
}
}
#[allow(clippy::too_many_arguments)]
fn dicom_export_options(
tile_size: u32,
jpeg_quality: u8,
transfer_syntax: TransferSyntax,
j2k_decomposition_levels: Option<u8>,
backend: BackendArg,
codec_validation: CodecValidationArg,
source_device_decode: bool,
gpu_encode: GpuEncodeArgs,
) -> DicomExportOptions {
let mut options = DicomExportOptions {
tile_size,
jpeg_quality,
transfer_syntax,
j2k_decomposition_levels,
encode_backend: backend.into_preference(),
codec_validation: codec_validation.into_codec_validation(),
source_device_decode,
..DicomExportOptions::default()
};
gpu_encode.into_options_fields(&mut options);
options
}
fn load_metadata_source(
metadata_path: Option<PathBuf>,
research_placeholder: bool,
) -> Result<MetadataSource, WsiDicomError> {
if research_placeholder {
return Ok(MetadataSource::ResearchPlaceholder);
}
let Some(path) = metadata_path else {
return Ok(MetadataSource::ResearchPlaceholder);
};
let bytes = std::fs::read(&path).map_err(|source| WsiDicomError::Io {
path: path.clone(),
source,
})?;
let value: serde_json::Value =
serde_json::from_slice(&bytes).map_err(|source| WsiDicomError::Json {
path: path.clone(),
source,
})?;
if looks_like_fhir(&value) {
Ok(MetadataSource::FhirR4Bundle(value))
} else {
let metadata: DicomMetadata =
serde_json::from_value(value).map_err(|source| WsiDicomError::Json {
path: path.clone(),
source,
})?;
Ok(MetadataSource::Strict(Box::new(metadata)))
}
}
fn looks_like_fhir(value: &serde_json::Value) -> bool {
matches!(
value
.get("resourceType")
.and_then(serde_json::Value::as_str),
Some("Bundle" | "Patient" | "Specimen" | "ServiceRequest" | "DiagnosticReport")
)
}
#[cfg(test)]
mod tests {
use super::{
effective_max_frames_per_level, load_metadata_source, max_level_elapsed_from_ms, Cli,
Command, TransferSyntaxArg,
};
use crate::cli_report::{
format_corpus_coverage_summary_with_memory, format_coverage_summary_with_memory,
format_profile_summary_with_memory, format_report_summary_with_memory,
format_sustain_export_iteration_summary, format_sustain_iteration_summary,
};
use clap::Parser;
use std::path::PathBuf;
use wsi_dicom::MetadataSource;
use wsi_dicom::{
DicomExportMetrics, DicomExportReport, DicomRouteCorpusCoverageFailure,
DicomRouteCorpusCoverageReport, DicomRouteCoverageReport, DicomRouteProfileReport,
};
#[test]
fn cli_uses_research_placeholder_metadata_by_default() {
let metadata = load_metadata_source(None, false).unwrap();
assert!(matches!(metadata, MetadataSource::ResearchPlaceholder));
}
#[test]
fn cli_coverage_full_frame_coverage_overrides_bounded_frame_count() {
let cli = Cli::try_parse_from([
"wsi-dicom",
"coverage",
"source.svs",
"--max-frames-per-level",
"1",
"--full-frame-coverage",
])
.unwrap();
let Command::Coverage {
max_frames_per_level,
full_frame_coverage,
..
} = cli.command
else {
panic!("expected coverage command");
};
assert_eq!(
effective_max_frames_per_level(max_frames_per_level, full_frame_coverage),
u64::MAX
);
}
#[test]
fn cli_coverage_accepts_max_level_elapsed_limit_ms() {
let cli = Cli::try_parse_from([
"wsi-dicom",
"coverage",
"source.svs",
"--max-level-ms",
"250",
])
.unwrap();
let Command::Coverage { max_level_ms, .. } = cli.command else {
panic!("expected coverage command");
};
assert_eq!(max_level_ms, Some(250));
}
#[test]
fn cli_coverage_accepts_source_device_decode_opt_in() {
let cli = Cli::try_parse_from([
"wsi-dicom",
"coverage",
"source.svs",
"--source-device-decode",
])
.unwrap();
let Command::Coverage {
source_device_decode,
..
} = cli.command
else {
panic!("expected coverage command");
};
assert!(source_device_decode);
}
#[test]
fn cli_convert_accepts_source_device_decode_opt_in() {
let cli = Cli::try_parse_from([
"wsi-dicom",
"convert",
"source.svs",
"--out",
"out",
"--source-device-decode",
])
.unwrap();
let Command::Convert {
source_device_decode,
..
} = cli.command
else {
panic!("expected convert command");
};
assert!(source_device_decode);
}
#[test]
fn cli_convert_defers_transfer_syntax_when_omitted() {
let cli =
Cli::try_parse_from(["wsi-dicom", "convert", "source.svs", "--out", "out"]).unwrap();
let Command::Convert {
transfer_syntax, ..
} = cli.command
else {
panic!("expected convert command");
};
assert_eq!(transfer_syntax, None);
}
#[test]
fn cli_convert_preserves_explicit_htj2k_lossless_rpcl() {
let cli = Cli::try_parse_from([
"wsi-dicom",
"convert",
"source.svs",
"--out",
"out",
"--transfer-syntax",
"htj2k-lossless-rpcl",
])
.unwrap();
let Command::Convert {
transfer_syntax, ..
} = cli.command
else {
panic!("expected convert command");
};
assert!(matches!(
transfer_syntax,
Some(TransferSyntaxArg::Htj2kLosslessRpcl)
));
}
#[test]
fn cli_convert_accepts_gpu_encode_tuning_flags() {
let cli = Cli::try_parse_from([
"wsi-dicom",
"convert",
"source.svs",
"--out",
"out",
"--gpu-encode-inflight-tiles",
"8",
"--gpu-encode-memory-mib",
"4096",
])
.unwrap();
let Command::Convert { gpu_encode, .. } = cli.command else {
panic!("expected convert command");
};
assert_eq!(gpu_encode.gpu_encode_inflight_tiles, Some(8));
assert_eq!(gpu_encode.gpu_encode_memory_mib, Some(4096));
}
#[test]
fn cli_convert_accepts_jpeg_quality_and_j2k_decomposition_levels() {
let cli = Cli::try_parse_from([
"wsi-dicom",
"convert",
"source.svs",
"--out",
"out",
"--jpeg-quality",
"80",
"--j2k-decomposition-levels",
"0",
])
.unwrap();
let Command::Convert {
jpeg_quality,
j2k_decomposition_levels,
..
} = cli.command
else {
panic!("expected convert command");
};
assert_eq!(jpeg_quality, 80);
assert_eq!(j2k_decomposition_levels, Some(0));
}
#[test]
fn cli_profile_coverage_and_sustain_accept_equivalence_flags() {
let profile = Cli::try_parse_from([
"wsi-dicom",
"profile",
"source.svs",
"--jpeg-quality",
"80",
"--j2k-decomposition-levels",
"0",
])
.unwrap();
let Command::Profile {
jpeg_quality,
j2k_decomposition_levels,
..
} = profile.command
else {
panic!("expected profile command");
};
assert_eq!(jpeg_quality, 80);
assert_eq!(j2k_decomposition_levels, Some(0));
let coverage = Cli::try_parse_from([
"wsi-dicom",
"coverage",
"source.svs",
"--jpeg-quality",
"80",
"--j2k-decomposition-levels",
"0",
])
.unwrap();
let Command::Coverage {
jpeg_quality,
j2k_decomposition_levels,
..
} = coverage.command
else {
panic!("expected coverage command");
};
assert_eq!(jpeg_quality, 80);
assert_eq!(j2k_decomposition_levels, Some(0));
let sustain_convert = Cli::try_parse_from([
"wsi-dicom",
"sustain-convert",
"source.svs",
"--out",
"out",
"--jpeg-quality",
"80",
"--j2k-decomposition-levels",
"0",
])
.unwrap();
let Command::SustainConvert {
jpeg_quality,
j2k_decomposition_levels,
..
} = sustain_convert.command
else {
panic!("expected sustain-convert command");
};
assert_eq!(jpeg_quality, 80);
assert_eq!(j2k_decomposition_levels, Some(0));
let sustain = Cli::try_parse_from([
"wsi-dicom",
"sustain",
"source.svs",
"--jpeg-quality",
"80",
"--j2k-decomposition-levels",
"0",
])
.unwrap();
let Command::Sustain {
jpeg_quality,
j2k_decomposition_levels,
..
} = sustain.command
else {
panic!("expected sustain command");
};
assert_eq!(jpeg_quality, 80);
assert_eq!(j2k_decomposition_levels, Some(0));
}
#[test]
fn cli_coverage_corpus_accepts_max_level_elapsed_limit_ms() {
let cli = Cli::try_parse_from([
"wsi-dicom",
"coverage-corpus",
"slides",
"--max-level-ms",
"250",
])
.unwrap();
let Command::CoverageCorpus { max_level_ms, .. } = cli.command else {
panic!("expected coverage-corpus command");
};
assert_eq!(max_level_ms, Some(250));
}
#[test]
fn cli_sustain_accepts_max_level_elapsed_limit_ms() {
let cli = Cli::try_parse_from([
"wsi-dicom",
"sustain",
"source.svs",
"--max-level-ms",
"250",
])
.unwrap();
let Command::Sustain { max_level_ms, .. } = cli.command else {
panic!("expected sustain command");
};
assert_eq!(max_level_ms, Some(250));
}
#[test]
fn cli_rejects_zero_max_level_elapsed_limit_ms() {
let err = max_level_elapsed_from_ms(Some(0)).unwrap_err();
assert!(
err.to_string().contains("--max-level-ms"),
"unexpected error: {err}"
);
}
#[test]
fn cli_summary_reports_passthrough_and_fallback_counts() {
let summary = format_report_summary_with_memory(
&DicomExportReport {
output_dir: PathBuf::from("out"),
instances: Vec::new(),
metrics: DicomExportMetrics {
total_frames: 27,
cpu_input_frames: 1,
gpu_input_decode_frames: 2,
gpu_encode_frames: 3,
gpu_validation_frames: 4,
jpeg_passthrough_frames: 5,
j2k_passthrough_frames: 6,
gpu_transcode_frames: 7,
resident_gpu_transcode_frames: 4,
partial_gpu_transcode_frames: 3,
gpu_input_decode_batches: 2,
gpu_compose_batches: 1,
gpu_encode_batches: 3,
gpu_encode_configured_inflight_tiles: 8,
gpu_encode_effective_inflight_tiles: 4,
gpu_encode_max_observed_inflight_tiles: 4,
gpu_encode_configured_memory_mib: 4096,
gpu_encode_effective_memory_mib: 3277,
gpu_encode_wall_micros: 5_000,
gpu_dispatch_micros: 6_500,
gpu_encode_hardware_micros: 2_000,
gpu_encode_dispatch_overhead_micros: 4_500,
cpu_fallback_frames: 9,
jpeg_decode_fallback_frames: 1,
jpeg_cpu_encode_frames: 1,
jpeg_metal_encode_frames: 2,
..DicomExportMetrics::default()
},
},
Some(10 * 1024 * 1024),
);
assert!(summary.contains("jpeg_passthrough=5"));
assert!(summary.contains("j2k_passthrough=6"));
assert!(summary.contains("jpeg_decode_fallback=1"));
assert!(summary.contains("jpeg_metal_encode=2"));
assert!(summary.contains("route_passthrough=11"));
assert!(summary.contains("route_passthrough_pct=40.7"));
assert!(summary.contains("route_gpu_transcode=7"));
assert!(summary.contains("route_gpu_transcode_pct=25.9"));
assert!(summary.contains("route_resident_gpu_transcode=4"));
assert!(summary.contains("route_partial_gpu_transcode=3"));
assert!(summary.contains("gpu_input_batches=2"));
assert!(summary.contains("gpu_compose_batches=1"));
assert!(summary.contains("gpu_encode_batches=3"));
assert!(summary.contains("gpu_encode_configured_inflight_tiles=8"));
assert!(summary.contains("gpu_encode_effective_inflight_tiles=4"));
assert!(summary.contains("gpu_encode_max_observed_inflight_tiles=4"));
assert!(summary.contains("gpu_encode_configured_memory_mib=4096"));
assert!(summary.contains("gpu_encode_effective_memory_mib=3277"));
assert!(summary.contains("gpu_encode_wall_ms=5.000"));
assert!(summary.contains("gpu_encode_effective_parallelism=0.400"));
assert!(summary.contains("gpu_dispatch_ms=6.500"));
assert!(summary.contains("gpu_encode_hardware_ms=2.000"));
assert!(summary.contains("gpu_encode_dispatch_overhead_ms=4.500"));
assert!(summary.contains("route_cpu_fallback=9"));
assert!(summary.contains("route_cpu_fallback_pct=33.3"));
assert!(summary.contains("route_unclassified=0"));
assert!(summary.contains("rss_mb=10.0"));
}
#[test]
fn cli_profile_summary_reports_bounded_route_counts() {
let summary = format_profile_summary_with_memory(
&DicomRouteProfileReport {
source_path: PathBuf::from("source.svs"),
transfer_syntax_uid: "1.2.840.10008.1.2.4.202",
level: 2,
requested_frames: 12,
available_frames: 20,
metrics: DicomExportMetrics {
total_frames: 10,
gpu_transcode_frames: 7,
resident_gpu_transcode_frames: 4,
partial_gpu_transcode_frames: 3,
cpu_fallback_frames: 3,
gpu_input_decode_frames: 7,
gpu_encode_frames: 7,
jpeg_passthrough_frames: 2,
jpeg_decode_fallback_frames: 1,
jpeg_cpu_encode_frames: 1,
jpeg_metal_encode_frames: 2,
auto_route_probe_frames: 2,
auto_route_probe_gpu_batches: 3,
auto_route_probe_cpu_micros: 1_200,
auto_route_probe_gpu_micros: 1_300,
gpu_dispatch_micros: 6_500,
write_micros: 1_250,
..DicomExportMetrics::default()
},
elapsed_micros: 42_500,
},
Some(20 * 1024 * 1024),
);
assert!(summary.contains("profiled source.svs"));
assert!(summary.contains("level=2"));
assert!(summary.contains("requested_frames=12"));
assert!(summary.contains("available_frames=20"));
assert!(summary.contains("sampled_frames_pct=50.0000"));
assert!(summary.contains("frames total=10"));
assert!(summary.contains("route_gpu_transcode=7"));
assert!(summary.contains("route_gpu_transcode_pct=70.0"));
assert!(summary.contains("route_resident_gpu_transcode=4"));
assert!(summary.contains("route_partial_gpu_transcode=3"));
assert!(summary.contains("route_cpu_fallback=3"));
assert!(summary.contains("jpeg_passthrough=2"));
assert!(summary.contains("jpeg_decode_fallback=1"));
assert!(summary.contains("jpeg_cpu_encode=1"));
assert!(summary.contains("jpeg_metal_encode=2"));
assert!(summary.contains("auto_probe_frames=2"));
assert!(summary.contains("auto_probe_selected_gpu_input=0"));
assert!(summary.contains("auto_probe_gpu_batches=3"));
assert!(summary.contains("auto_probe_cpu_ms=1.200"));
assert!(summary.contains("auto_probe_gpu_ms=1.300"));
assert!(summary.contains("gpu_dispatch_ms=6.500"));
assert!(summary.contains("final_byte_ms=1.250"));
assert!(summary.contains("elapsed_ms=42.500"));
assert!(summary.contains("rss_mb=20.0"));
}
#[test]
fn cli_coverage_summary_reports_aggregate_route_counts() {
let summary = format_coverage_summary_with_memory(
&DicomRouteCoverageReport {
source_path: PathBuf::from("source.ndpi"),
transfer_syntax_uid: "1.2.840.10008.1.2.4.50",
requested_frames_per_level: 8,
available_frames: 20,
complete_frame_coverage: false,
levels: vec![
DicomRouteProfileReport {
source_path: PathBuf::from("source.ndpi"),
transfer_syntax_uid: "1.2.840.10008.1.2.4.50",
level: 0,
requested_frames: 8,
available_frames: 16,
metrics: DicomExportMetrics {
total_frames: 8,
jpeg_passthrough_frames: 8,
..DicomExportMetrics::default()
},
elapsed_micros: 1_000,
},
DicomRouteProfileReport {
source_path: PathBuf::from("source.ndpi"),
transfer_syntax_uid: "1.2.840.10008.1.2.4.50",
level: 1,
requested_frames: 8,
available_frames: 4,
metrics: DicomExportMetrics {
total_frames: 4,
cpu_fallback_frames: 4,
jpeg_decode_fallback_frames: 4,
jpeg_cpu_encode_frames: 4,
..DicomExportMetrics::default()
},
elapsed_micros: 2_000,
},
],
metrics: DicomExportMetrics {
total_frames: 12,
jpeg_passthrough_frames: 8,
cpu_fallback_frames: 4,
jpeg_decode_fallback_frames: 4,
jpeg_cpu_encode_frames: 4,
input_decode_micros: 3_000,
encode_micros: 4_000,
gpu_dispatch_micros: 7_000,
..DicomExportMetrics::default()
},
elapsed_micros: 5_000,
},
Some(30 * 1024 * 1024),
);
assert!(summary.contains("covered source.ndpi"));
assert!(summary.contains("levels=2"));
assert!(summary.contains("requested_frames_per_level=8"));
assert!(summary.contains("available_frames=20"));
assert!(summary.contains("sampled_frames_pct=60.0000"));
assert!(summary.contains("complete_frame_coverage=false"));
assert!(summary.contains("frames total=12"));
assert!(summary.contains("route_passthrough=8"));
assert!(summary.contains("route_passthrough_pct=66.7"));
assert!(summary.contains("route_cpu_fallback=4"));
assert!(summary.contains("route_cpu_fallback_pct=33.3"));
assert!(summary.contains("jpeg_passthrough=8"));
assert!(summary.contains("jpeg_decode_fallback=4"));
assert!(summary.contains("jpeg_cpu_encode=4"));
assert!(summary.contains("gpu_dispatch_ms=7.000"));
assert!(summary.contains("elapsed_ms=5.000"));
assert!(summary.contains("rss_mb=30.0"));
}
#[test]
fn cli_coverage_summary_formats_full_frame_coverage_request_as_all() {
let summary = format_coverage_summary_with_memory(
&DicomRouteCoverageReport {
source_path: PathBuf::from("source.svs"),
transfer_syntax_uid: "1.2.840.10008.1.2.4.202",
requested_frames_per_level: u64::MAX,
available_frames: 1,
complete_frame_coverage: true,
levels: Vec::new(),
metrics: DicomExportMetrics {
total_frames: 1,
gpu_transcode_frames: 1,
..DicomExportMetrics::default()
},
elapsed_micros: 1_000,
},
None,
);
assert!(summary.contains("requested_frames_per_level=all"));
assert!(summary.contains("complete_frame_coverage=true"));
}
#[test]
fn cli_corpus_coverage_summary_reports_sources_failures_and_aggregate_routes() {
let summary = format_corpus_coverage_summary_with_memory(
&DicomRouteCorpusCoverageReport {
source_root: PathBuf::from("corpus"),
transfer_syntax_uid: "1.2.840.10008.1.2.4.202",
requested_frames_per_level: 4,
max_levels: Some(1),
sources_considered: 3,
available_frames: 100_000,
complete_frame_coverage: false,
reports: vec![DicomRouteCoverageReport {
source_path: PathBuf::from("corpus/source.svs"),
transfer_syntax_uid: "1.2.840.10008.1.2.4.202",
requested_frames_per_level: 4,
available_frames: 100_000,
complete_frame_coverage: false,
levels: vec![DicomRouteProfileReport {
source_path: PathBuf::from("corpus/source.svs"),
transfer_syntax_uid: "1.2.840.10008.1.2.4.202",
level: 0,
requested_frames: 4,
available_frames: 100_000,
metrics: DicomExportMetrics {
total_frames: 4,
gpu_transcode_frames: 4,
resident_gpu_transcode_frames: 4,
gpu_input_decode_frames: 4,
gpu_encode_frames: 4,
..DicomExportMetrics::default()
},
elapsed_micros: 10_000,
}],
metrics: DicomExportMetrics {
total_frames: 4,
gpu_transcode_frames: 4,
resident_gpu_transcode_frames: 4,
gpu_input_decode_frames: 4,
gpu_encode_frames: 4,
..DicomExportMetrics::default()
},
elapsed_micros: 10_000,
}],
failures: vec![DicomRouteCorpusCoverageFailure {
source_path: PathBuf::from("corpus/bad.svs"),
message: "unsupported".into(),
}],
metrics: DicomExportMetrics {
total_frames: 4,
gpu_transcode_frames: 4,
resident_gpu_transcode_frames: 4,
gpu_input_decode_frames: 4,
gpu_encode_frames: 4,
gpu_dispatch_micros: 9_000,
..DicomExportMetrics::default()
},
elapsed_micros: 12_000,
},
Some(40 * 1024 * 1024),
);
assert!(summary.contains("covered_corpus corpus"));
assert!(summary.contains("sources_considered=3"));
assert!(summary.contains("sources_profiled=1"));
assert!(summary.contains("failures=1"));
assert!(summary.contains("available_frames=100000"));
assert!(summary.contains("sampled_frames_pct=0.0040"));
assert!(summary.contains("complete_frame_coverage=false"));
assert!(summary.contains("route_gpu_transcode=4"));
assert!(summary.contains("route_gpu_transcode_pct=100.0"));
assert!(summary.contains("route_resident_gpu_transcode=4"));
assert!(summary.contains("gpu_dispatch_ms=9.000"));
assert!(summary.contains("rss_mb=40.0"));
}
#[test]
fn cli_sustain_iteration_summary_reports_throughput_memory_and_thermal_state() {
let summary = format_sustain_iteration_summary(
2,
5,
&DicomRouteCoverageReport {
source_path: PathBuf::from("source.svs"),
transfer_syntax_uid: "1.2.840.10008.1.2.4.202",
requested_frames_per_level: 4,
available_frames: 12,
complete_frame_coverage: true,
levels: Vec::new(),
metrics: DicomExportMetrics {
total_frames: 12,
gpu_transcode_frames: 12,
resident_gpu_transcode_frames: 12,
gpu_input_decode_frames: 12,
gpu_encode_frames: 12,
gpu_dispatch_micros: 12_500,
..DicomExportMetrics::default()
},
elapsed_micros: 2_000_000,
},
Some(40 * 1024 * 1024),
Some("No thermal warning level has been recorded"),
Some("System-wide memory free percentage: 92%"),
);
assert!(summary.contains("sustain_iteration=2/5"));
assert!(summary.contains("frames=12"));
assert!(summary.contains("available_frames=12"));
assert!(summary.contains("sampled_frames_pct=100.0000"));
assert!(summary.contains("complete_frame_coverage=true"));
assert!(summary.contains("frames_per_sec=6.00"));
assert!(summary.contains("route_gpu_transcode=12"));
assert!(summary.contains("route_resident_gpu_transcode=12"));
assert!(summary.contains("gpu_dispatch_ms=12.500"));
assert!(summary.contains("rss_mb=40.0"));
assert!(summary.contains("thermal=\"No thermal warning level has been recorded\""));
assert!(summary.contains("memory_pressure=\"System-wide memory free percentage: 92%\""));
}
#[test]
fn cli_sustain_convert_summary_reports_real_export_throughput() {
let summary = format_sustain_export_iteration_summary(
1,
3,
&DicomExportReport {
output_dir: PathBuf::from("out/iteration-0001"),
instances: Vec::new(),
metrics: DicomExportMetrics {
total_frames: 20,
j2k_passthrough_frames: 4,
gpu_transcode_frames: 12,
resident_gpu_transcode_frames: 10,
partial_gpu_transcode_frames: 2,
cpu_fallback_frames: 4,
gpu_input_decode_frames: 12,
gpu_encode_frames: 12,
write_micros: 3_500,
input_decode_micros: 8_000,
compose_micros: 2_000,
encode_micros: 4_000,
validation_micros: 1_000,
gpu_dispatch_micros: 15_000,
..DicomExportMetrics::default()
},
},
2_000_000,
Some(50 * 1024 * 1024),
Some("No thermal warning level has been recorded"),
Some("System-wide memory free percentage: 91%"),
);
assert!(summary.contains("sustain_iteration=1/3"));
assert!(summary.contains("mode=convert"));
assert!(summary.contains("output=out/iteration-0001"));
assert!(summary.contains("frames=20"));
assert!(summary.contains("frames_per_sec=10.00"));
assert!(summary.contains("route_passthrough=4"));
assert!(summary.contains("route_gpu_transcode=12"));
assert!(summary.contains("route_resident_gpu_transcode=10"));
assert!(summary.contains("route_partial_gpu_transcode=2"));
assert!(summary.contains("route_cpu_fallback=4"));
assert!(summary.contains("gpu_dispatch_ms=15.000"));
assert!(summary.contains("final_byte_ms=3.500"));
assert!(summary.contains("elapsed_ms=2000.000"));
assert!(summary.contains("rss_mb=50.0"));
assert!(summary.contains("thermal=\"No thermal warning level has been recorded\""));
assert!(summary.contains("memory_pressure=\"System-wide memory free percentage: 91%\""));
}
}