pub mod conversion;
pub mod diff;
pub mod error;
#[cfg(feature = "hf-remote")]
pub mod hf;
pub mod ir;
pub mod sample;
pub mod stats;
pub mod validation;
use std::fs::File;
use std::io::{BufReader, IsTerminal, Write};
use std::path::{Path, PathBuf};
use clap::{Parser, Subcommand, ValueEnum};
pub use error::PanlabelError;
#[derive(Parser)]
#[command(name = "panlabel")]
#[command(version, author, about)]
#[command(propagate_version = true)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
Validate(ValidateArgs),
Convert(ConvertArgs),
Stats(StatsArgs),
Diff(DiffArgs),
Sample(SampleArgs),
ListFormats(ListFormatsArgs),
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
enum ConvertFormat {
#[value(name = "ir-json")]
IrJson,
#[value(name = "coco", alias = "coco-json")]
Coco,
#[value(name = "cvat", alias = "cvat-xml")]
Cvat,
#[value(name = "label-studio", alias = "label-studio-json", alias = "ls")]
LabelStudio,
#[value(name = "tfod", alias = "tfod-csv")]
Tfod,
#[value(
name = "yolo",
alias = "ultralytics",
alias = "yolov8",
alias = "yolov5"
)]
Yolo,
#[value(name = "voc", alias = "pascal-voc", alias = "voc-xml")]
Voc,
#[value(name = "hf", alias = "hf-imagefolder", alias = "huggingface")]
HfImagefolder,
#[value(name = "labelme", alias = "labelme-json")]
LabelMe,
#[value(name = "create-ml", alias = "createml", alias = "create-ml-json")]
CreateMl,
#[value(name = "kitti", alias = "kitti-txt")]
Kitti,
#[value(name = "via", alias = "via-json", alias = "vgg-via")]
Via,
#[value(name = "retinanet", alias = "retinanet-csv", alias = "keras-retinanet")]
Retinanet,
}
impl ConvertFormat {
fn to_conversion_format(self) -> conversion::Format {
match self {
ConvertFormat::IrJson => conversion::Format::IrJson,
ConvertFormat::Coco => conversion::Format::Coco,
ConvertFormat::Cvat => conversion::Format::Cvat,
ConvertFormat::LabelStudio => conversion::Format::LabelStudio,
ConvertFormat::Tfod => conversion::Format::Tfod,
ConvertFormat::Yolo => conversion::Format::Yolo,
ConvertFormat::Voc => conversion::Format::Voc,
ConvertFormat::HfImagefolder => conversion::Format::HfImagefolder,
ConvertFormat::LabelMe => conversion::Format::LabelMe,
ConvertFormat::CreateMl => conversion::Format::CreateMl,
ConvertFormat::Kitti => conversion::Format::Kitti,
ConvertFormat::Via => conversion::Format::Via,
ConvertFormat::Retinanet => conversion::Format::Retinanet,
}
}
}
#[derive(Copy, Clone, Debug, ValueEnum)]
enum ConvertFromFormat {
#[value(name = "auto")]
Auto,
#[value(name = "ir-json")]
IrJson,
#[value(name = "coco", alias = "coco-json")]
Coco,
#[value(name = "cvat", alias = "cvat-xml")]
Cvat,
#[value(name = "label-studio", alias = "label-studio-json", alias = "ls")]
LabelStudio,
#[value(name = "tfod", alias = "tfod-csv")]
Tfod,
#[value(
name = "yolo",
alias = "ultralytics",
alias = "yolov8",
alias = "yolov5"
)]
Yolo,
#[value(name = "voc", alias = "pascal-voc", alias = "voc-xml")]
Voc,
#[value(name = "hf", alias = "hf-imagefolder", alias = "huggingface")]
HfImagefolder,
#[value(name = "labelme", alias = "labelme-json")]
LabelMe,
#[value(name = "create-ml", alias = "createml", alias = "create-ml-json")]
CreateMl,
#[value(name = "kitti", alias = "kitti-txt")]
Kitti,
#[value(name = "via", alias = "via-json", alias = "vgg-via")]
Via,
#[value(name = "retinanet", alias = "retinanet-csv", alias = "keras-retinanet")]
Retinanet,
}
impl ConvertFromFormat {
fn as_concrete(self) -> Option<ConvertFormat> {
match self {
ConvertFromFormat::Auto => None,
ConvertFromFormat::IrJson => Some(ConvertFormat::IrJson),
ConvertFromFormat::Coco => Some(ConvertFormat::Coco),
ConvertFromFormat::Cvat => Some(ConvertFormat::Cvat),
ConvertFromFormat::LabelStudio => Some(ConvertFormat::LabelStudio),
ConvertFromFormat::Tfod => Some(ConvertFormat::Tfod),
ConvertFromFormat::Yolo => Some(ConvertFormat::Yolo),
ConvertFromFormat::Voc => Some(ConvertFormat::Voc),
ConvertFromFormat::HfImagefolder => Some(ConvertFormat::HfImagefolder),
ConvertFromFormat::LabelMe => Some(ConvertFormat::LabelMe),
ConvertFromFormat::CreateMl => Some(ConvertFormat::CreateMl),
ConvertFromFormat::Kitti => Some(ConvertFormat::Kitti),
ConvertFromFormat::Via => Some(ConvertFormat::Via),
ConvertFromFormat::Retinanet => Some(ConvertFormat::Retinanet),
}
}
}
#[derive(Copy, Clone, Debug, Default, ValueEnum)]
enum ReportFormat {
#[default]
#[value(name = "text")]
Text,
#[value(name = "json")]
Json,
}
#[derive(Copy, Clone, Debug, Default, ValueEnum)]
enum StatsOutputFormat {
#[default]
#[value(name = "text")]
Text,
#[value(name = "json")]
Json,
#[value(name = "html")]
Html,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum JsonStyle {
Pretty,
Compact,
}
#[derive(Copy, Clone, Debug)]
struct OutputContext {
stdout_is_terminal: bool,
}
impl OutputContext {
fn detect() -> Self {
Self {
stdout_is_terminal: std::io::stdout().is_terminal(),
}
}
fn json_style(self) -> JsonStyle {
if self.stdout_is_terminal {
JsonStyle::Pretty
} else {
JsonStyle::Compact
}
}
fn stats_text_style(self) -> stats::TextReportStyle {
if self.stdout_is_terminal {
stats::TextReportStyle::Rich
} else {
stats::TextReportStyle::Plain
}
}
}
#[derive(Copy, Clone, Debug, Default, ValueEnum)]
enum DiffMatchBy {
#[default]
#[value(name = "id")]
Id,
#[value(name = "iou")]
Iou,
}
#[derive(Copy, Clone, Debug, Default, ValueEnum)]
enum SampleStrategyArg {
#[default]
#[value(name = "random")]
Random,
#[value(name = "stratified")]
Stratified,
}
#[derive(Copy, Clone, Debug, Default, ValueEnum)]
enum CategoryModeArg {
#[default]
#[value(name = "images")]
Images,
#[value(name = "annotations")]
Annotations,
}
#[derive(Copy, Clone, Debug, Default, ValueEnum)]
enum HfBboxFormatArg {
#[default]
#[value(name = "xywh")]
Xywh,
#[value(name = "xyxy")]
Xyxy,
}
impl HfBboxFormatArg {
fn to_hf_bbox_format(self) -> ir::io_hf_imagefolder::HfBboxFormat {
match self {
HfBboxFormatArg::Xywh => ir::io_hf_imagefolder::HfBboxFormat::Xywh,
HfBboxFormatArg::Xyxy => ir::io_hf_imagefolder::HfBboxFormat::Xyxy,
}
}
}
#[derive(clap::Args)]
struct ValidateArgs {
input: PathBuf,
#[arg(long, value_enum, default_value_t = ConvertFormat::IrJson)]
format: ConvertFormat,
#[arg(long)]
strict: bool,
#[arg(
long = "output-format",
visible_alias = "output",
value_enum,
default_value_t = ReportFormat::Text
)]
output_format: ReportFormat,
}
#[derive(clap::Args)]
struct StatsArgs {
input: PathBuf,
#[arg(long, value_enum)]
format: Option<ConvertFormat>,
#[arg(long, default_value_t = 10)]
top: usize,
#[arg(long, default_value_t = 0.5)]
tolerance: f64,
#[arg(
long = "output-format",
visible_alias = "output",
value_enum,
default_value_t = StatsOutputFormat::Text
)]
output_format: StatsOutputFormat,
}
#[derive(clap::Args)]
struct DiffArgs {
input_a: PathBuf,
input_b: PathBuf,
#[arg(long = "format-a", value_enum, default_value = "auto")]
format_a: ConvertFromFormat,
#[arg(long = "format-b", value_enum, default_value = "auto")]
format_b: ConvertFromFormat,
#[arg(long, value_enum, default_value = "id")]
match_by: DiffMatchBy,
#[arg(long, default_value_t = 0.5)]
iou_threshold: f64,
#[arg(long)]
detail: bool,
#[arg(
long = "output-format",
visible_alias = "output",
value_enum,
default_value_t = ReportFormat::Text
)]
output_format: ReportFormat,
}
#[derive(clap::Args)]
struct SampleArgs {
#[arg(short = 'i', long = "input")]
input: PathBuf,
#[arg(short = 'o', long = "output")]
output: PathBuf,
#[arg(long = "from", value_enum, default_value = "auto")]
from: ConvertFromFormat,
#[arg(long = "to", value_enum)]
to: Option<ConvertFormat>,
#[arg(short = 'n', long = "n")]
n: Option<usize>,
#[arg(long = "fraction")]
fraction: Option<f64>,
#[arg(long = "seed")]
seed: Option<u64>,
#[arg(long, value_enum, default_value = "random")]
strategy: SampleStrategyArg,
#[arg(long = "categories")]
categories: Option<String>,
#[arg(long = "category-mode", value_enum, default_value = "images")]
category_mode: CategoryModeArg,
#[arg(long = "allow-lossy")]
allow_lossy: bool,
#[arg(long = "dry-run")]
dry_run: bool,
#[arg(
long = "output-format",
visible_alias = "report",
value_enum,
default_value_t = ReportFormat::Text
)]
output_format: ReportFormat,
}
#[derive(clap::Args)]
struct ConvertArgs {
#[arg(short = 'f', long = "from", value_enum)]
from: ConvertFromFormat,
#[arg(short = 't', long = "to", value_enum)]
to: ConvertFormat,
#[arg(short = 'i', long = "input")]
input: Option<PathBuf>,
#[arg(short = 'o', long = "output")]
output: PathBuf,
#[arg(long)]
strict: bool,
#[arg(long = "no-validate")]
no_validate: bool,
#[arg(long = "allow-lossy")]
allow_lossy: bool,
#[arg(long = "dry-run")]
dry_run: bool,
#[arg(
long = "output-format",
visible_alias = "report",
value_enum,
default_value_t = ReportFormat::Text
)]
output_format: ReportFormat,
#[arg(long = "hf-bbox-format", value_enum, default_value = "xywh")]
hf_bbox_format: HfBboxFormatArg,
#[arg(long = "hf-objects-column")]
hf_objects_column: Option<String>,
#[arg(long = "hf-category-map")]
hf_category_map: Option<PathBuf>,
#[arg(long = "hf-repo")]
hf_repo: Option<String>,
#[arg(long = "split")]
split: Option<String>,
#[arg(long = "revision")]
revision: Option<String>,
#[arg(long = "config")]
config: Option<String>,
#[arg(long = "token", env = "HF_TOKEN")]
token: Option<String>,
}
#[derive(clap::Args)]
struct ListFormatsArgs {
#[arg(
long = "output-format",
visible_alias = "output",
value_enum,
default_value_t = ReportFormat::Text
)]
output_format: ReportFormat,
}
struct FormatCatalogEntry {
format: ConvertFormat,
aliases: &'static [&'static str],
description: &'static str,
file_based: bool,
directory_based: bool,
}
#[derive(serde::Serialize)]
struct ListFormatEntry {
name: &'static str,
aliases: &'static [&'static str],
read: bool,
write: bool,
lossiness: &'static str,
description: &'static str,
file_based: bool,
directory_based: bool,
}
const FORMAT_CATALOG: &[FormatCatalogEntry] = &[
FormatCatalogEntry {
format: ConvertFormat::IrJson,
aliases: &[],
description: "Panlabel's intermediate representation (JSON)",
file_based: true,
directory_based: false,
},
FormatCatalogEntry {
format: ConvertFormat::Coco,
aliases: &["coco-json"],
description: "COCO object detection format (JSON)",
file_based: true,
directory_based: false,
},
FormatCatalogEntry {
format: ConvertFormat::Cvat,
aliases: &["cvat-xml"],
description: "CVAT for images XML annotation export",
file_based: true,
directory_based: false,
},
FormatCatalogEntry {
format: ConvertFormat::LabelStudio,
aliases: &["label-studio-json", "ls"],
description: "Label Studio task export (JSON)",
file_based: true,
directory_based: false,
},
FormatCatalogEntry {
format: ConvertFormat::Tfod,
aliases: &["tfod-csv"],
description: "TensorFlow Object Detection format (CSV)",
file_based: true,
directory_based: false,
},
FormatCatalogEntry {
format: ConvertFormat::Yolo,
aliases: &["ultralytics", "yolov8", "yolov5"],
description: "Ultralytics YOLO .txt (directory-based)",
file_based: false,
directory_based: true,
},
FormatCatalogEntry {
format: ConvertFormat::Voc,
aliases: &["pascal-voc", "voc-xml"],
description: "Pascal VOC XML (directory-based)",
file_based: false,
directory_based: true,
},
FormatCatalogEntry {
format: ConvertFormat::HfImagefolder,
aliases: &["hf-imagefolder", "huggingface"],
description: "Hugging Face ImageFolder metadata (metadata.jsonl/parquet)",
file_based: false,
directory_based: true,
},
FormatCatalogEntry {
format: ConvertFormat::LabelMe,
aliases: &["labelme-json"],
description: "LabelMe per-image JSON annotation format",
file_based: true,
directory_based: true,
},
FormatCatalogEntry {
format: ConvertFormat::CreateMl,
aliases: &["createml", "create-ml-json"],
description: "Apple CreateML annotation format (JSON)",
file_based: true,
directory_based: false,
},
FormatCatalogEntry {
format: ConvertFormat::Kitti,
aliases: &["kitti-txt"],
description: "KITTI object detection label files (directory-based)",
file_based: false,
directory_based: true,
},
FormatCatalogEntry {
format: ConvertFormat::Via,
aliases: &["via-json", "vgg-via"],
description: "VGG Image Annotator (VIA) JSON format",
file_based: true,
directory_based: false,
},
FormatCatalogEntry {
format: ConvertFormat::Retinanet,
aliases: &["retinanet-csv", "keras-retinanet"],
description: "keras-retinanet CSV format",
file_based: true,
directory_based: false,
},
];
pub fn run() -> Result<(), PanlabelError> {
let cli = Cli::parse();
let output = OutputContext::detect();
match cli.command {
Some(Commands::Validate(args)) => run_validate(args, output),
Some(Commands::Convert(args)) => run_convert(args, output),
Some(Commands::Stats(args)) => run_stats(args, output),
Some(Commands::Diff(args)) => run_diff(args, output),
Some(Commands::Sample(args)) => run_sample(args, output),
Some(Commands::ListFormats(args)) => run_list_formats(args, output),
None => {
println!("panlabel {}", env!("CARGO_PKG_VERSION"));
println!();
println!("The universal annotation converter.");
println!();
println!("Run 'panlabel --help' for usage information.");
Ok(())
}
}
}
fn write_json_stdout<T: serde::Serialize>(
value: &T,
output: OutputContext,
) -> Result<(), PanlabelError> {
let stdout = std::io::stdout();
let mut handle = stdout.lock();
match output.json_style() {
JsonStyle::Pretty => serde_json::to_writer_pretty(&mut handle, value),
JsonStyle::Compact => serde_json::to_writer(&mut handle, value),
}
.map_err(|source| PanlabelError::ReportJsonWrite { source })?;
writeln!(handle).map_err(PanlabelError::Io)?;
handle.flush().map_err(PanlabelError::Io)?;
Ok(())
}
fn run_stats(args: StatsArgs, output: OutputContext) -> Result<(), PanlabelError> {
let format = resolve_stats_format(args.format, &args.input)?;
let dataset = read_dataset(format, &args.input)?;
let opts = stats::StatsOptions {
top_labels: args.top,
top_pairs: args.top,
oob_tolerance_px: args.tolerance,
bar_width: 20,
};
let report = stats::stats_dataset(&dataset, &opts);
match args.output_format {
StatsOutputFormat::Text => print!("{}", report.display(output.stats_text_style())),
StatsOutputFormat::Json => write_json_stdout(&report, output)?,
StatsOutputFormat::Html => {
let html = stats::html::render_html(&report)?;
print!("{html}");
}
}
Ok(())
}
fn run_diff(args: DiffArgs, output: OutputContext) -> Result<(), PanlabelError> {
if matches!(args.match_by, DiffMatchBy::Iou)
&& !(0.0 < args.iou_threshold && args.iou_threshold <= 1.0)
{
return Err(PanlabelError::DiffFailed {
message: "--iou-threshold must be in the interval (0.0, 1.0] when --match-by iou"
.to_string(),
});
}
let format_a = resolve_from_format(args.format_a, &args.input_a)?;
let format_b = resolve_from_format(args.format_b, &args.input_b)?;
let dataset_a = read_dataset(format_a, &args.input_a)?;
let dataset_b = read_dataset(format_b, &args.input_b)?;
ensure_unique_image_file_names(&dataset_a, "A")?;
ensure_unique_image_file_names(&dataset_b, "B")?;
let match_by = match args.match_by {
DiffMatchBy::Id => diff::MatchBy::Id,
DiffMatchBy::Iou => diff::MatchBy::Iou,
};
let opts = diff::DiffOptions {
match_by,
iou_threshold: args.iou_threshold,
detail: args.detail,
max_items: 20,
bbox_eps: 1e-6,
};
let report = diff::diff_datasets(&dataset_a, &dataset_b, &opts);
match args.output_format {
ReportFormat::Text => {
println!(
"Dataset Diff: {} vs {}",
args.input_a.display(),
args.input_b.display()
);
println!();
print!("{}", report);
}
ReportFormat::Json => write_json_stdout(&report, output)?,
}
Ok(())
}
fn emit_conversion_report(
report: &conversion::ConversionReport,
format: ReportFormat,
output: OutputContext,
) -> Result<(), PanlabelError> {
match format {
ReportFormat::Text => {
let stdout = std::io::stdout();
let mut handle = stdout.lock();
write!(handle, "{}", report).map_err(PanlabelError::Io)?;
handle.flush().map_err(PanlabelError::Io)?;
}
ReportFormat::Json => write_json_stdout(report, output)?,
}
Ok(())
}
fn run_sample(args: SampleArgs, output: OutputContext) -> Result<(), PanlabelError> {
let from_format = resolve_from_format(args.from, &args.input)?;
let to_format = match args.to {
Some(target) => target,
None => args.from.as_concrete().unwrap_or(ConvertFormat::IrJson),
};
let dataset = read_dataset(from_format, &args.input)?;
let strategy = match args.strategy {
SampleStrategyArg::Random => sample::SampleStrategy::Random,
SampleStrategyArg::Stratified => sample::SampleStrategy::Stratified,
};
let category_mode = match args.category_mode {
CategoryModeArg::Images => sample::CategoryMode::Images,
CategoryModeArg::Annotations => sample::CategoryMode::Annotations,
};
let sample_opts = sample::SampleOptions {
n: args.n,
fraction: args.fraction,
seed: args.seed,
strategy,
categories: parse_categories_arg(args.categories),
category_mode,
};
let sampled_dataset = sample::sample_dataset(&dataset, &sample_opts)?;
let conv_report = conversion::build_conversion_report(
&sampled_dataset,
from_format.to_conversion_format(),
to_format.to_conversion_format(),
);
if conv_report.is_lossy() && !args.allow_lossy {
emit_conversion_report(&conv_report, args.output_format, output)?;
return Err(PanlabelError::LossyConversionBlocked {
from: format_name(from_format).to_string(),
to: format_name(to_format).to_string(),
report: Box::new(conv_report),
});
}
if !args.dry_run {
write_dataset(to_format, &args.output, &sampled_dataset)?;
}
match args.output_format {
ReportFormat::Text => {
println!(
"{} {} images -> {} images: {} ({}) -> {} ({})",
if args.dry_run {
"Dry run: would sample"
} else {
"Sampled"
},
dataset.images.len(),
sampled_dataset.images.len(),
args.input.display(),
format_name(from_format),
args.output.display(),
format_name(to_format)
);
emit_conversion_report(&conv_report, ReportFormat::Text, output)?;
}
ReportFormat::Json => {
emit_conversion_report(&conv_report, ReportFormat::Json, output)?;
}
}
Ok(())
}
fn run_validate(args: ValidateArgs, output: OutputContext) -> Result<(), PanlabelError> {
let dataset = read_dataset(args.format, &args.input)?;
let opts = validation::ValidateOptions {
strict: args.strict,
};
let report = validation::validate_dataset(&dataset, &opts);
match args.output_format {
ReportFormat::Json => write_json_stdout(&report.as_json(), output)?,
ReportFormat::Text => print!("{}", report),
}
let has_errors = report.error_count() > 0;
let has_warnings = report.warning_count() > 0;
if has_errors || (args.strict && has_warnings) {
Err(PanlabelError::ValidationFailed {
error_count: report.error_count(),
warning_count: report.warning_count(),
report,
})
} else {
Ok(())
}
}
fn run_convert(args: ConvertArgs, output: OutputContext) -> Result<(), PanlabelError> {
let from_format = match args.from.as_concrete() {
Some(format) => format,
None => {
let input = args.input.as_ref().ok_or_else(|| {
PanlabelError::UnsupportedFormat("--from auto requires --input <path>".to_string())
})?;
detect_format(input)?
}
};
validate_hf_flag_usage(&args, from_format)?;
#[allow(unused_mut)]
let mut hf_read_options = ir::io_hf_imagefolder::HfReadOptions {
bbox_format: args.hf_bbox_format.to_hf_bbox_format(),
objects_column: args.hf_objects_column.clone(),
split: args.split.clone(),
category_map: load_hf_category_map(args.hf_category_map.as_deref())?,
provenance: Default::default(),
};
let hf_write_options = ir::io_hf_imagefolder::HfWriteOptions {
bbox_format: args.hf_bbox_format.to_hf_bbox_format(),
};
#[cfg(feature = "hf-remote")]
let mut remote_hf_provenance: Option<std::collections::BTreeMap<String, String>> = None;
#[cfg(not(feature = "hf-remote"))]
let remote_hf_provenance: Option<std::collections::BTreeMap<String, String>> = None;
let (effective_input, source_display, effective_from_format) =
if from_format == ConvertFormat::HfImagefolder && args.hf_repo.is_some() {
#[cfg(feature = "hf-remote")]
{
let repo_input = args.hf_repo.as_deref().expect("checked is_some");
let repo_ref = hf::resolve::parse_hf_input(
repo_input,
args.revision.as_deref(),
args.config.as_deref(),
args.split.as_deref(),
)?;
let preflight = hf::preflight::run_preflight(&repo_ref, args.token.as_deref());
if preflight.is_none() {
eprintln!("Note: HF viewer API unavailable; proceeding with direct download.");
}
if let Some(preflight_data) = preflight.as_ref() {
if hf_read_options.objects_column.is_none() {
hf_read_options.objects_column =
preflight_data.detected_objects_column.clone();
}
if hf_read_options.category_map.is_empty() {
if let Some(labels) = preflight_data.category_labels.as_ref() {
for (idx, label) in labels.iter().enumerate() {
hf_read_options
.category_map
.insert(idx as i64, label.clone());
}
}
}
if let Some(license) = preflight_data.license.as_ref() {
hf_read_options
.provenance
.insert("hf_license".to_string(), license.clone());
}
if let Some(description) = preflight_data.description.as_ref() {
hf_read_options
.provenance
.insert("hf_description".to_string(), description.clone());
}
if hf_read_options.split.is_none() {
hf_read_options.split = preflight_data.selected_split.clone();
}
}
let acquired =
hf::acquire::acquire(&repo_ref, preflight.as_ref(), args.token.as_deref())?;
let revision = repo_ref
.revision
.clone()
.unwrap_or_else(|| "main".to_string());
hf_read_options
.provenance
.insert("hf_repo_id".to_string(), repo_ref.repo_id.clone());
hf_read_options
.provenance
.insert("hf_revision".to_string(), revision);
hf_read_options.provenance.insert(
"hf_bbox_format".to_string(),
args.hf_bbox_format.to_hf_bbox_format().as_str().to_string(),
);
if let Some(split_name) = acquired
.split_name
.clone()
.or_else(|| repo_ref.split.clone())
{
hf_read_options
.provenance
.insert("hf_split".to_string(), split_name);
}
remote_hf_provenance = Some(hf_read_options.provenance.clone());
if acquired.payload_format == hf::acquire::HfAcquirePayloadFormat::HfImagefolder
&& hf_read_options.split.is_some()
&& (acquired.payload_path.join("metadata.jsonl").is_file()
|| acquired.payload_path.join("metadata.parquet").is_file())
{
hf_read_options.split = None;
}
(
acquired.payload_path,
args.hf_repo.clone().expect("checked is_some"),
remote_payload_to_convert_format(acquired.payload_format),
)
}
#[cfg(not(feature = "hf-remote"))]
{
return Err(PanlabelError::UnsupportedFormat(
"remote HF import requires the 'hf-remote' feature".to_string(),
));
}
} else {
let input = args.input.clone().ok_or_else(|| {
PanlabelError::UnsupportedFormat("missing required --input <path>".to_string())
})?;
let display = input.display().to_string();
(input, display, from_format)
};
let yolo_read_options = ir::io_yolo::YoloReadOptions {
split: args.split.clone(),
};
let mut dataset = if effective_from_format == ConvertFormat::HfImagefolder
|| effective_from_format == ConvertFormat::Yolo
{
read_dataset_with_options(
effective_from_format,
&effective_input,
&hf_read_options,
&yolo_read_options,
)?
} else {
read_dataset(effective_from_format, &effective_input)?
};
if let Some(provenance) = remote_hf_provenance {
dataset.info.attributes.extend(provenance);
}
if !args.no_validate {
let opts = validation::ValidateOptions {
strict: args.strict,
};
let validation_report = validation::validate_dataset(&dataset, &opts);
let has_errors = validation_report.error_count() > 0;
let has_warnings = validation_report.warning_count() > 0;
if has_errors || has_warnings {
eprintln!("{}", validation_report);
}
if has_errors || (args.strict && has_warnings) {
return Err(PanlabelError::ValidationFailed {
error_count: validation_report.error_count(),
warning_count: validation_report.warning_count(),
report: validation_report,
});
}
}
let conv_report = conversion::build_conversion_report(
&dataset,
effective_from_format.to_conversion_format(),
args.to.to_conversion_format(),
);
if conv_report.is_lossy() && !args.allow_lossy {
emit_conversion_report(&conv_report, args.output_format, output)?;
return Err(PanlabelError::LossyConversionBlocked {
from: format_name(effective_from_format).to_string(),
to: format_name(args.to).to_string(),
report: Box::new(conv_report),
});
}
if !args.dry_run {
write_dataset_with_options(args.to, &args.output, &dataset, &hf_write_options)?;
}
match args.output_format {
ReportFormat::Text => {
println!(
"{} {} ({}) -> {} ({})",
if args.dry_run {
"Dry run: would convert"
} else {
"Converted"
},
source_display,
format_name(effective_from_format),
args.output.display(),
format_name(args.to)
);
emit_conversion_report(&conv_report, ReportFormat::Text, output)?;
}
ReportFormat::Json => {
emit_conversion_report(&conv_report, ReportFormat::Json, output)?;
}
}
Ok(())
}
#[cfg(feature = "hf-remote")]
fn remote_payload_to_convert_format(payload: hf::acquire::HfAcquirePayloadFormat) -> ConvertFormat {
match payload {
hf::acquire::HfAcquirePayloadFormat::HfImagefolder => ConvertFormat::HfImagefolder,
hf::acquire::HfAcquirePayloadFormat::Yolo => ConvertFormat::Yolo,
hf::acquire::HfAcquirePayloadFormat::Voc => ConvertFormat::Voc,
hf::acquire::HfAcquirePayloadFormat::Coco => ConvertFormat::Coco,
}
}
fn resolve_from_format(
from: ConvertFromFormat,
path: &Path,
) -> Result<ConvertFormat, PanlabelError> {
match from.as_concrete() {
Some(format) => Ok(format),
None => detect_format(path),
}
}
fn resolve_stats_format(
format: Option<ConvertFormat>,
path: &Path,
) -> Result<ConvertFormat, PanlabelError> {
if let Some(format) = format {
return Ok(format);
}
match detect_format(path) {
Ok(format) => Ok(format),
Err(error) => {
if matches!(&error, PanlabelError::FormatDetectionJsonParse { .. }) {
return Err(error);
}
let is_json_file = path.is_file()
&& path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.eq_ignore_ascii_case("json"))
.unwrap_or(false);
if is_json_file {
Ok(ConvertFormat::IrJson)
} else {
Err(error)
}
}
}
}
fn parse_categories_arg(raw: Option<String>) -> Vec<String> {
raw.unwrap_or_default()
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.collect()
}
fn load_hf_category_map(
path: Option<&Path>,
) -> Result<std::collections::BTreeMap<i64, String>, PanlabelError> {
let Some(path) = path else {
return Ok(Default::default());
};
let file = File::open(path).map_err(PanlabelError::Io)?;
let reader = BufReader::new(file);
let value: serde_json::Value =
serde_json::from_reader(reader).map_err(|source| PanlabelError::HfLayoutInvalid {
path: path.to_path_buf(),
message: format!("invalid JSON in category map: {source}"),
})?;
let mut map = std::collections::BTreeMap::new();
match value {
serde_json::Value::Object(obj) => {
for (raw_key, raw_value) in obj {
let key = raw_key
.parse::<i64>()
.map_err(|_| PanlabelError::HfLayoutInvalid {
path: path.to_path_buf(),
message: format!("category-map key '{}' is not a valid integer", raw_key),
})?;
let label = raw_value
.as_str()
.ok_or_else(|| PanlabelError::HfLayoutInvalid {
path: path.to_path_buf(),
message: format!(
"category-map value for key '{}' must be a string",
raw_key
),
})?;
map.insert(key, label.to_string());
}
}
serde_json::Value::Array(items) => {
for (idx, item) in items.into_iter().enumerate() {
let label = item
.as_str()
.ok_or_else(|| PanlabelError::HfLayoutInvalid {
path: path.to_path_buf(),
message: format!("category-map array entry {} must be a string", idx),
})?;
map.insert(idx as i64, label.to_string());
}
}
_ => {
return Err(PanlabelError::HfLayoutInvalid {
path: path.to_path_buf(),
message:
"category map must be either a JSON object {\"0\":\"person\"} or string array"
.to_string(),
});
}
}
Ok(map)
}
fn validate_hf_flag_usage(
args: &ConvertArgs,
from_format: ConvertFormat,
) -> Result<(), PanlabelError> {
let hf_involved =
from_format == ConvertFormat::HfImagefolder || args.to == ConvertFormat::HfImagefolder;
let split_allowed = hf_involved || from_format == ConvertFormat::Yolo;
if args.split.is_some() && !split_allowed {
return Err(PanlabelError::UnsupportedFormat(
"--split can only be used with --from hf or --from yolo".to_string(),
));
}
let hf_specific_flags_used = args.hf_repo.is_some()
|| args.hf_objects_column.is_some()
|| args.hf_category_map.is_some()
|| args.revision.is_some()
|| args.config.is_some()
|| !matches!(args.hf_bbox_format, HfBboxFormatArg::Xywh);
if hf_specific_flags_used && !hf_involved {
return Err(PanlabelError::UnsupportedFormat(
"HF-specific flags (--hf-*) can only be used with --from hf or --to hf".to_string(),
));
}
if args.hf_repo.is_some() && from_format != ConvertFormat::HfImagefolder {
return Err(PanlabelError::UnsupportedFormat(
"--hf-repo can only be used with --from hf".to_string(),
));
}
if args.hf_repo.is_none() && (args.revision.is_some() || args.config.is_some()) {
return Err(PanlabelError::UnsupportedFormat(
"--revision/--config require --hf-repo".to_string(),
));
}
if from_format == ConvertFormat::HfImagefolder && args.hf_repo.is_none() && args.input.is_none()
{
return Err(PanlabelError::UnsupportedFormat(
"--from hf requires either --input <path> or --hf-repo <namespace/dataset>".to_string(),
));
}
Ok(())
}
fn ensure_unique_image_file_names(dataset: &ir::Dataset, side: &str) -> Result<(), PanlabelError> {
let mut seen = std::collections::HashSet::new();
for image in &dataset.images {
if !seen.insert(image.file_name.clone()) {
return Err(PanlabelError::DiffFailed {
message: format!(
"duplicate image file_name '{}' found in dataset {}. Use unique image names for reliable diffing.",
image.file_name, side
),
});
}
}
Ok(())
}
fn read_dataset(format: ConvertFormat, path: &Path) -> Result<ir::Dataset, PanlabelError> {
read_dataset_with_options(
format,
path,
&ir::io_hf_imagefolder::HfReadOptions::default(),
&ir::io_yolo::YoloReadOptions::default(),
)
}
fn read_dataset_with_options(
format: ConvertFormat,
path: &Path,
hf_options: &ir::io_hf_imagefolder::HfReadOptions,
yolo_options: &ir::io_yolo::YoloReadOptions,
) -> Result<ir::Dataset, PanlabelError> {
match format {
ConvertFormat::IrJson => ir::io_json::read_ir_json(path),
ConvertFormat::Coco => ir::io_coco_json::read_coco_json(path),
ConvertFormat::Cvat => ir::io_cvat_xml::read_cvat_xml(path),
ConvertFormat::LabelStudio => ir::io_label_studio_json::read_label_studio_json(path),
ConvertFormat::Tfod => ir::io_tfod_csv::read_tfod_csv(path),
ConvertFormat::Yolo => ir::io_yolo::read_yolo_dir_with_options(path, yolo_options),
ConvertFormat::Voc => ir::io_voc_xml::read_voc_dir(path),
ConvertFormat::HfImagefolder => read_hf_dataset_with_options(path, hf_options),
ConvertFormat::LabelMe => ir::io_labelme_json::read_labelme_json(path),
ConvertFormat::CreateMl => ir::io_createml_json::read_createml_json(path),
ConvertFormat::Kitti => ir::io_kitti::read_kitti_dir(path),
ConvertFormat::Via => ir::io_via_json::read_via_json(path),
ConvertFormat::Retinanet => ir::io_retinanet_csv::read_retinanet_csv(path),
}
}
fn write_dataset(
format: ConvertFormat,
path: &Path,
dataset: &ir::Dataset,
) -> Result<(), PanlabelError> {
write_dataset_with_options(
format,
path,
dataset,
&ir::io_hf_imagefolder::HfWriteOptions::default(),
)
}
fn write_dataset_with_options(
format: ConvertFormat,
path: &Path,
dataset: &ir::Dataset,
hf_options: &ir::io_hf_imagefolder::HfWriteOptions,
) -> Result<(), PanlabelError> {
match format {
ConvertFormat::IrJson => ir::io_json::write_ir_json(path, dataset),
ConvertFormat::Coco => ir::io_coco_json::write_coco_json(path, dataset),
ConvertFormat::Cvat => ir::io_cvat_xml::write_cvat_xml(path, dataset),
ConvertFormat::LabelStudio => {
ir::io_label_studio_json::write_label_studio_json(path, dataset)
}
ConvertFormat::Tfod => ir::io_tfod_csv::write_tfod_csv(path, dataset),
ConvertFormat::Yolo => ir::io_yolo::write_yolo_dir(path, dataset),
ConvertFormat::Voc => ir::io_voc_xml::write_voc_dir(path, dataset),
ConvertFormat::HfImagefolder => {
ir::io_hf_imagefolder::write_hf_imagefolder_with_options(path, dataset, hf_options)
}
ConvertFormat::LabelMe => ir::io_labelme_json::write_labelme_json(path, dataset),
ConvertFormat::CreateMl => ir::io_createml_json::write_createml_json(path, dataset),
ConvertFormat::Kitti => ir::io_kitti::write_kitti_dir(path, dataset),
ConvertFormat::Via => ir::io_via_json::write_via_json(path, dataset),
ConvertFormat::Retinanet => ir::io_retinanet_csv::write_retinanet_csv(path, dataset),
}
}
fn read_hf_dataset_with_options(
path: &Path,
options: &ir::io_hf_imagefolder::HfReadOptions,
) -> Result<ir::Dataset, PanlabelError> {
#[cfg(feature = "hf-parquet")]
{
if should_read_hf_parquet(path, options.split.as_deref())? {
return ir::io_hf_parquet::read_hf_parquet_with_options(path, options);
}
}
ir::io_hf_imagefolder::read_hf_imagefolder_with_options(path, options)
}
#[cfg(feature = "hf-parquet")]
fn should_read_hf_parquet(path: &Path, split: Option<&str>) -> Result<bool, PanlabelError> {
let has_jsonl = hf_has_metadata(path, split, "metadata.jsonl")?;
let has_parquet_layout =
hf_has_metadata(path, split, "metadata.parquet")? || hf_has_any_parquet_file(path, split)?;
Ok(has_parquet_layout && !has_jsonl)
}
#[cfg(feature = "hf-parquet")]
fn hf_has_metadata(
path: &Path,
split: Option<&str>,
metadata_file_name: &str,
) -> Result<bool, PanlabelError> {
if !path.is_dir() {
return Ok(false);
}
if path.join(metadata_file_name).is_file() {
return Ok(true);
}
if let Some(split_name) = split {
let normalized = normalize_split_hint(split_name);
return Ok(path.join(&normalized).join(metadata_file_name).is_file());
}
for entry in std::fs::read_dir(path).map_err(PanlabelError::Io)? {
let entry = entry.map_err(PanlabelError::Io)?;
let entry_path = entry.path();
if entry_path.is_dir() && entry_path.join(metadata_file_name).is_file() {
return Ok(true);
}
}
Ok(false)
}
#[cfg(feature = "hf-parquet")]
fn hf_has_any_parquet_file(path: &Path, split: Option<&str>) -> Result<bool, PanlabelError> {
if !path.is_dir() {
return Ok(false);
}
let normalized_split = split.map(normalize_split_hint);
for entry in walkdir::WalkDir::new(path).follow_links(true) {
let entry = entry.map_err(|source| PanlabelError::HfLayoutInvalid {
path: path.to_path_buf(),
message: format!("failed while scanning parquet files: {source}"),
})?;
if !entry.file_type().is_file() {
continue;
}
let entry_path = entry.path();
let is_parquet = entry_path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.eq_ignore_ascii_case("parquet"))
.unwrap_or(false);
if !is_parquet {
continue;
}
if entry_path
.file_name()
.and_then(|name| name.to_str())
.map(|name| name.eq_ignore_ascii_case("metadata.parquet"))
.unwrap_or(false)
{
return Ok(true);
}
if let Some(split_name) = normalized_split.as_deref() {
if parquet_path_matches_split(entry_path, split_name) {
return Ok(true);
}
continue;
}
return Ok(true);
}
Ok(false)
}
#[cfg(feature = "hf-parquet")]
fn parquet_path_matches_split(path: &Path, split: &str) -> bool {
let split = normalize_split_hint(split);
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.map(|name| name.to_ascii_lowercase())
.unwrap_or_default();
if file_name.starts_with(&format!("{split}-")) {
return true;
}
path.components().any(|component| {
component
.as_os_str()
.to_str()
.map(|value| normalize_split_hint(value) == split)
.unwrap_or(false)
})
}
#[cfg(feature = "hf-parquet")]
fn normalize_split_hint(value: &str) -> String {
match value.to_ascii_lowercase().as_str() {
"val" | "valid" => "validation".to_string(),
"validation" => "validation".to_string(),
"train" => "train".to_string(),
"test" => "test".to_string(),
"dev" => "dev".to_string(),
_ => value.to_ascii_lowercase(),
}
}
fn format_name(format: ConvertFormat) -> &'static str {
match format {
ConvertFormat::IrJson => "ir-json",
ConvertFormat::Coco => "coco",
ConvertFormat::Cvat => "cvat",
ConvertFormat::LabelStudio => "label-studio",
ConvertFormat::Tfod => "tfod",
ConvertFormat::Yolo => "yolo",
ConvertFormat::Voc => "voc",
ConvertFormat::HfImagefolder => "hf",
ConvertFormat::LabelMe => "labelme",
ConvertFormat::CreateMl => "create-ml",
ConvertFormat::Kitti => "kitti",
ConvertFormat::Via => "via",
ConvertFormat::Retinanet => "retinanet",
}
}
fn lossiness_name(lossiness: conversion::IrLossiness) -> &'static str {
match lossiness {
conversion::IrLossiness::Lossless => "lossless",
conversion::IrLossiness::Conditional => "conditional",
conversion::IrLossiness::Lossy => "lossy",
}
}
fn list_format_entries() -> Vec<ListFormatEntry> {
FORMAT_CATALOG
.iter()
.map(|entry| ListFormatEntry {
name: format_name(entry.format),
aliases: entry.aliases,
read: true,
write: true,
lossiness: lossiness_name(
entry
.format
.to_conversion_format()
.lossiness_relative_to_ir(),
),
description: entry.description,
file_based: entry.file_based,
directory_based: entry.directory_based,
})
.collect()
}
fn run_list_formats(args: ListFormatsArgs, output: OutputContext) -> Result<(), PanlabelError> {
let entries = list_format_entries();
match args.output_format {
ReportFormat::Text => {
println!("Supported formats:");
println!();
println!(
" {:<12} {:<6} {:<6} {:<12} DESCRIPTION",
"FORMAT", "READ", "WRITE", "LOSSINESS"
);
println!(
" {:<12} {:<6} {:<6} {:<12} -----------",
"------", "----", "-----", "---------"
);
for entry in &entries {
println!(
" {:<12} {:<6} {:<6} {:<12} {}",
entry.name,
if entry.read { "yes" } else { "no" },
if entry.write { "yes" } else { "no" },
entry.lossiness,
entry.description
);
}
println!();
println!("Lossiness key:");
println!(" lossless - Format preserves all IR information");
println!(" conditional - Format may lose info depending on dataset content");
println!(" lossy - Format always loses some IR information");
println!();
println!("Tip: Use '--from auto' with 'convert' for automatic format detection.");
Ok(())
}
ReportFormat::Json => write_json_stdout(&entries, output),
}
}
fn detect_format(path: &Path) -> Result<ConvertFormat, PanlabelError> {
if path.is_dir() {
return detect_dir_format(path);
}
if !path.exists() {
return Err(PanlabelError::FormatDetectionFailed {
path: path.to_path_buf(),
reason: "file does not exist".to_string(),
});
}
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
match ext.to_lowercase().as_str() {
"csv" => return detect_csv_format(path),
"json" => return detect_json_format(path),
"xml" => return detect_xml_format(path),
_ => {}
}
}
Err(PanlabelError::FormatDetectionFailed {
path: path.to_path_buf(),
reason: "unrecognized file extension (expected .json, .csv, or .xml). Use --from to specify format explicitly.".to_string(),
})
}
struct FormatProbe {
name: &'static str,
format: ConvertFormat,
found: Vec<String>,
missing: Vec<String>,
}
impl FormatProbe {
fn new(name: &'static str, format: ConvertFormat) -> Self {
Self {
name,
format,
found: Vec::new(),
missing: Vec::new(),
}
}
fn is_detected(&self) -> bool {
!self.found.is_empty() && self.missing.is_empty()
}
fn is_partial(&self) -> bool {
!self.found.is_empty() && !self.missing.is_empty()
}
}
fn detect_dir_format(path: &Path) -> Result<ConvertFormat, PanlabelError> {
let probes = probe_dir_formats(path)?;
let detected: Vec<&FormatProbe> = probes.iter().filter(|p| p.is_detected()).collect();
let partial: Vec<&FormatProbe> = probes.iter().filter(|p| p.is_partial()).collect();
if detected.len() == 1 {
return Ok(detected[0].format);
}
if detected.len() > 1 {
let names: Vec<&str> = detected.iter().map(|p| p.name).collect();
let header = if detected.len() == 2 {
format!(
"directory matches both {} and {} layouts",
names[0], names[1]
)
} else {
format!("directory matches multiple layouts ({})", names.join(", "))
};
let mut reason = format!("{}:\n", header);
for p in &detected {
reason.push_str(&format!(" - {}: found {}\n", p.name, p.found.join(", ")));
}
reason.push_str("Use --from to specify format explicitly.");
return Err(PanlabelError::FormatDetectionFailed {
path: path.to_path_buf(),
reason,
});
}
if !partial.is_empty() {
let mut reason = String::new();
for p in &partial {
reason.push_str(&format!(
"found {}-style markers ({}), but missing: {}\n",
p.name,
p.found.join(", "),
p.missing.join(", "),
));
}
reason.push_str("Use --from to specify format explicitly, or fix the directory layout.");
return Err(PanlabelError::FormatDetectionFailed {
path: path.to_path_buf(),
reason,
});
}
Err(PanlabelError::FormatDetectionFailed {
path: path.to_path_buf(),
reason: "unrecognized directory layout. Expected one of:\n \
- YOLO: labels/ with .txt files and sibling images/\n \
- VOC: Annotations/ with .xml files\n \
- CVAT: annotations.xml at directory root\n \
- HF: metadata.jsonl, metadata.parquet, or parquet shard files\n \
- LabelMe: annotations/ with LabelMe .json files, or co-located .json files\n \
- KITTI: label_2/ with .txt files and sibling image_2/\n\
Use --from to specify format explicitly."
.to_string(),
})
}
fn probe_dir_formats(path: &Path) -> Result<Vec<FormatProbe>, PanlabelError> {
let mut probes = Vec::with_capacity(4);
let mut yolo = FormatProbe::new("YOLO", ConvertFormat::Yolo);
let (labels_dir_exists, has_txt) = if path.join("labels").is_dir() {
(true, dir_contains_txt_files(&path.join("labels"))?)
} else if is_labels_dir(path) {
(true, dir_contains_txt_files(path)?)
} else {
(false, false)
};
if labels_dir_exists && has_txt {
yolo.found.push("labels/ with .txt files".into());
let images_exists = if is_labels_dir(path) {
path.parent()
.map(|p| p.join("images").is_dir())
.unwrap_or(false)
} else {
path.join("images").is_dir()
};
if images_exists {
yolo.found.push("images/ directory".into());
} else {
yolo.missing.push("images/ directory".into());
}
}
if yolo.found.is_empty() {
if let Some(split_keys) = data_yaml_has_split_keys(path) {
yolo.found.push(format!(
"data.yaml with split keys: {}",
split_keys.join(", ")
));
}
}
probes.push(yolo);
let mut voc = FormatProbe::new("VOC", ConvertFormat::Voc);
let (ann_dir, has_top_level_xml) = if path.join("Annotations").is_dir() {
let ann = path.join("Annotations");
(true, dir_contains_top_level_xml_files(&ann)?)
} else if is_annotations_dir(path) {
(true, dir_contains_top_level_xml_files(path)?)
} else {
(false, false)
};
if ann_dir && has_top_level_xml {
voc.found
.push("Annotations/ with top-level .xml files".into());
}
probes.push(voc);
let mut cvat = FormatProbe::new("CVAT", ConvertFormat::Cvat);
if path.join("annotations.xml").is_file() {
cvat.found.push("annotations.xml at root".into());
}
probes.push(cvat);
let mut hf = FormatProbe::new("HF", ConvertFormat::HfImagefolder);
if dir_contains_hf_metadata(path)? {
hf.found.push("metadata.jsonl or metadata.parquet".into());
} else if dir_has_parquet_shards(path)? {
hf.found.push("parquet shard files".into());
}
probes.push(hf);
let mut kitti = FormatProbe::new("KITTI", ConvertFormat::Kitti);
let kitti_labels_dir = if path.join("label_2").is_dir() {
Some(path.join("label_2"))
} else if is_dir_named_ci(path, "label_2") {
Some(path.to_path_buf())
} else {
None
};
if let Some(ref labels_dir) = kitti_labels_dir {
if dir_contains_top_level_txt_files(labels_dir)? {
kitti.found.push("label_2/ with .txt files".into());
let images_exists = if is_dir_named_ci(path, "label_2") {
path.parent()
.map(|p| p.join("image_2").is_dir())
.unwrap_or(false)
} else {
path.join("image_2").is_dir()
};
if images_exists {
kitti.found.push("image_2/ directory".into());
} else {
kitti.missing.push("image_2/ directory".into());
}
}
}
probes.push(kitti);
let mut labelme = FormatProbe::new("LabelMe", ConvertFormat::LabelMe);
let labelme_ann_dir = path.join("annotations");
if labelme_ann_dir.is_dir() && dir_contains_labelme_json(&labelme_ann_dir)? {
labelme
.found
.push("annotations/ with LabelMe .json files".into());
} else if dir_contains_labelme_json(path)? {
labelme.found.push("co-located LabelMe .json files".into());
}
probes.push(labelme);
Ok(probes)
}
fn dir_contains_txt_files(path: &Path) -> Result<bool, PanlabelError> {
dir_contains_extension_files(path, "txt")
}
fn dir_contains_hf_metadata(path: &Path) -> Result<bool, PanlabelError> {
if path.join("metadata.jsonl").is_file() || path.join("metadata.parquet").is_file() {
return Ok(true);
}
for entry in std::fs::read_dir(path).map_err(|source| PanlabelError::FormatDetectionFailed {
path: path.to_path_buf(),
reason: format!("failed while inspecting directory: {source}"),
})? {
let entry = entry.map_err(|source| PanlabelError::FormatDetectionFailed {
path: path.to_path_buf(),
reason: format!("failed while inspecting directory: {source}"),
})?;
let entry_path = entry.path();
if entry_path.is_dir()
&& (entry_path.join("metadata.jsonl").is_file()
|| entry_path.join("metadata.parquet").is_file())
{
return Ok(true);
}
}
Ok(false)
}
fn dir_has_parquet_shards(path: &Path) -> Result<bool, PanlabelError> {
let entries = match std::fs::read_dir(path) {
Ok(entries) => entries,
Err(_) => return Ok(false),
};
for entry in entries {
let entry = entry.map_err(PanlabelError::Io)?;
let entry_path = entry.path();
if !entry_path.is_dir() {
continue;
}
let sub_entries = match std::fs::read_dir(&entry_path) {
Ok(entries) => entries,
Err(_) => continue,
};
for sub_entry in sub_entries {
let sub_entry = sub_entry.map_err(PanlabelError::Io)?;
let sub_path = sub_entry.path();
if sub_path.is_file()
&& sub_path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("parquet"))
.unwrap_or(false)
&& sub_path
.file_name()
.and_then(|n| n.to_str())
.map(|n| !n.eq_ignore_ascii_case("metadata.parquet"))
.unwrap_or(false)
{
return Ok(true);
}
}
}
Ok(false)
}
fn dir_contains_top_level_xml_files(path: &Path) -> Result<bool, PanlabelError> {
for entry in std::fs::read_dir(path).map_err(|source| PanlabelError::FormatDetectionFailed {
path: path.to_path_buf(),
reason: format!("failed while inspecting directory: {source}"),
})? {
let entry = entry.map_err(|source| PanlabelError::FormatDetectionFailed {
path: path.to_path_buf(),
reason: format!("failed while inspecting directory: {source}"),
})?;
let entry_path = entry.path();
if entry_path.is_file()
&& entry_path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.eq_ignore_ascii_case("xml"))
.unwrap_or(false)
{
return Ok(true);
}
}
Ok(false)
}
fn dir_contains_extension_files(path: &Path, extension: &str) -> Result<bool, PanlabelError> {
for entry in walkdir::WalkDir::new(path).follow_links(true) {
let entry = entry.map_err(|source| PanlabelError::FormatDetectionFailed {
path: path.to_path_buf(),
reason: format!("failed while inspecting directory: {source}"),
})?;
if entry.file_type().is_file()
&& entry
.path()
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.eq_ignore_ascii_case(extension))
.unwrap_or(false)
{
return Ok(true);
}
}
Ok(false)
}
fn is_labels_dir(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.map(|name| name.eq_ignore_ascii_case("labels"))
.unwrap_or(false)
}
fn is_annotations_dir(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.map(|name| name.eq_ignore_ascii_case("annotations"))
.unwrap_or(false)
}
fn is_dir_named_ci(path: &Path, dir_name: &str) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.map(|name| name.eq_ignore_ascii_case(dir_name))
.unwrap_or(false)
}
fn dir_contains_top_level_txt_files(path: &Path) -> Result<bool, PanlabelError> {
for entry in std::fs::read_dir(path).map_err(|source| PanlabelError::FormatDetectionFailed {
path: path.to_path_buf(),
reason: format!("failed while inspecting directory: {source}"),
})? {
let entry = entry.map_err(|source| PanlabelError::FormatDetectionFailed {
path: path.to_path_buf(),
reason: format!("failed while inspecting directory: {source}"),
})?;
let entry_path = entry.path();
if entry_path.is_file()
&& entry_path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.eq_ignore_ascii_case("txt"))
.unwrap_or(false)
{
return Ok(true);
}
}
Ok(false)
}
fn dir_contains_labelme_json(dir: &Path) -> Result<bool, PanlabelError> {
for entry in std::fs::read_dir(dir).map_err(|source| PanlabelError::FormatDetectionFailed {
path: dir.to_path_buf(),
reason: format!("failed while inspecting directory: {source}"),
})? {
let entry = entry.map_err(|source| PanlabelError::FormatDetectionFailed {
path: dir.to_path_buf(),
reason: format!("failed while inspecting directory: {source}"),
})?;
let entry_path = entry.path();
if entry_path.is_file()
&& entry_path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("json"))
.unwrap_or(false)
{
if let Ok(contents) = std::fs::read_to_string(&entry_path) {
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&contents) {
if is_likely_labelme_file(&value) {
return Ok(true);
}
}
}
}
}
Ok(false)
}
fn data_yaml_has_split_keys(path: &Path) -> Option<Vec<String>> {
let yaml_path = path.join("data.yaml");
let content = std::fs::read_to_string(&yaml_path).ok()?;
let mapping: serde_yaml::Value = serde_yaml::from_str(&content).ok()?;
let map = mapping.as_mapping()?;
let mut found = Vec::new();
for key in ["train", "val", "test"] {
if map.contains_key(serde_yaml::Value::String(key.to_string())) {
found.push(key.to_string());
}
}
if found.is_empty() {
None
} else {
Some(found)
}
}
fn detect_csv_format(path: &Path) -> Result<ConvertFormat, PanlabelError> {
let file = std::fs::File::open(path).map_err(PanlabelError::Io)?;
let reader = std::io::BufReader::new(file);
let mut csv_reader = csv::ReaderBuilder::new()
.has_headers(false)
.from_reader(reader);
if let Some(result) = csv_reader.records().next() {
let record = result.map_err(|_| PanlabelError::FormatDetectionFailed {
path: path.to_path_buf(),
reason: "failed to parse first CSV row while detecting format".to_string(),
})?;
match record.len() {
8 => return Ok(ConvertFormat::Tfod),
6 => return Ok(ConvertFormat::Retinanet),
n => {
return Err(PanlabelError::FormatDetectionFailed {
path: path.to_path_buf(),
reason: format!(
"CSV has {n} columns; expected 8 (TFOD) or 6 (RetinaNet). Use --from to specify format explicitly."
),
});
}
}
}
Err(PanlabelError::FormatDetectionFailed {
path: path.to_path_buf(),
reason:
"CSV file is empty; cannot determine format. Use --from to specify format explicitly."
.to_string(),
})
}
fn detect_json_format(path: &Path) -> Result<ConvertFormat, PanlabelError> {
use std::fs::File;
use std::io::BufReader;
let file = File::open(path)?;
let reader = BufReader::new(file);
let value: serde_json::Value = serde_json::from_reader(reader).map_err(|source| {
PanlabelError::FormatDetectionJsonParse {
path: path.to_path_buf(),
source,
}
})?;
if let Some(items) = value.as_array() {
if items.is_empty() {
return Err(PanlabelError::FormatDetectionFailed {
path: path.to_path_buf(),
reason: "empty JSON array is ambiguous (could be Label Studio or CreateML). \
Use --from to specify format explicitly."
.to_string(),
});
}
if is_likely_label_studio_task(&items[0]) {
return Ok(ConvertFormat::LabelStudio);
}
if is_likely_createml_item(&items[0]) {
return Ok(ConvertFormat::CreateMl);
}
return Err(PanlabelError::FormatDetectionFailed {
path: path.to_path_buf(),
reason: "array-root JSON not recognized (expected Label Studio task array or CreateML image array). Use --from to specify format explicitly.".to_string(),
});
}
if is_likely_labelme_file(&value) {
return Ok(ConvertFormat::LabelMe);
}
if is_likely_via_project(&value) {
return Ok(ConvertFormat::Via);
}
let annotations = value.get("annotations").and_then(|v| v.as_array());
let Some(annotations) = annotations else {
return Err(PanlabelError::FormatDetectionFailed {
path: path.to_path_buf(),
reason: "missing or invalid 'annotations' array. Cannot determine format.".to_string(),
});
};
if annotations.is_empty() {
return Err(PanlabelError::FormatDetectionFailed {
path: path.to_path_buf(),
reason: "empty 'annotations' array. Cannot determine format from empty dataset. Use --from to specify format explicitly.".to_string(),
});
}
let first_ann = &annotations[0];
let bbox = first_ann.get("bbox");
let Some(bbox) = bbox else {
return Err(PanlabelError::FormatDetectionFailed {
path: path.to_path_buf(),
reason: "first annotation has no 'bbox' field. Cannot determine format.".to_string(),
});
};
if let Some(arr) = bbox.as_array() {
if arr.len() == 4 && arr.iter().all(|v| v.is_number()) {
return Ok(ConvertFormat::Coco);
}
return Err(PanlabelError::FormatDetectionFailed {
path: path.to_path_buf(),
reason: format!(
"bbox is an array but not [x,y,w,h] format (found {} elements). Cannot determine format.",
arr.len()
),
});
}
if let Some(obj) = bbox.as_object() {
if obj.contains_key("min") && obj.contains_key("max") {
return Ok(ConvertFormat::IrJson);
}
if obj.contains_key("xmin")
&& obj.contains_key("ymin")
&& obj.contains_key("xmax")
&& obj.contains_key("ymax")
{
return Ok(ConvertFormat::IrJson);
}
return Err(PanlabelError::FormatDetectionFailed {
path: path.to_path_buf(),
reason: "bbox is an object but doesn't match IR JSON format (expected min/max or xmin/ymin/xmax/ymax). Cannot determine format.".to_string(),
});
}
Err(PanlabelError::FormatDetectionFailed {
path: path.to_path_buf(),
reason: "bbox has unexpected type (expected array or object). Cannot determine format."
.to_string(),
})
}
fn detect_xml_format(path: &Path) -> Result<ConvertFormat, PanlabelError> {
let xml = std::fs::read_to_string(path).map_err(PanlabelError::Io)?;
let doc = roxmltree::Document::parse(&xml).map_err(|source| {
PanlabelError::FormatDetectionFailed {
path: path.to_path_buf(),
reason: format!("failed to parse XML while detecting format: {source}"),
}
})?;
match doc.root_element().tag_name().name() {
"annotations" => Ok(ConvertFormat::Cvat),
"annotation" => Err(PanlabelError::FormatDetectionFailed {
path: path.to_path_buf(),
reason: "XML root is <annotation> (looks like a single VOC file). Panlabel expects VOC as a directory layout; use --from voc with a VOC dataset directory.".to_string(),
}),
other => Err(PanlabelError::FormatDetectionFailed {
path: path.to_path_buf(),
reason: format!("unrecognized XML root element <{other}>; cannot determine format. Use --from to specify format explicitly."),
}),
}
}
fn is_likely_label_studio_task(value: &serde_json::Value) -> bool {
let Some(task_obj) = value.as_object() else {
return false;
};
let Some(data_obj) = task_obj.get("data").and_then(|v| v.as_object()) else {
return false;
};
data_obj
.get("image")
.map(|value| value.is_string())
.unwrap_or(false)
}
fn is_likely_createml_item(value: &serde_json::Value) -> bool {
let Some(obj) = value.as_object() else {
return false;
};
let has_image = obj.get("image").map(|v| v.is_string()).unwrap_or(false);
let has_annotations = obj
.get("annotations")
.map(|v| v.is_array())
.unwrap_or(false);
has_image && has_annotations
}
fn is_likely_labelme_file(value: &serde_json::Value) -> bool {
let Some(obj) = value.as_object() else {
return false;
};
obj.get("shapes").map(|v| v.is_array()).unwrap_or(false)
}
fn is_likely_via_project(value: &serde_json::Value) -> bool {
let Some(obj) = value.as_object() else {
return false;
};
obj.values()
.any(|v| v.is_object() && v.get("filename").is_some() && v.get("regions").is_some())
}