use std::borrow::Cow;
use std::collections::HashMap;
use std::ffi::OsStr;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::types::{Annotation, Category, Dataset, Image};
use super::{ConvertError, line_err, parse_err};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct YoloStats {
pub images: usize,
pub annotations: usize,
pub skipped_crowd: usize,
pub skipped_no_bbox: usize,
}
pub fn coco_to_yolo(dataset: &Dataset, output_dir: &Path) -> Result<YoloStats, ConvertError> {
super::check_unique_stems(dataset)?;
fs::create_dir_all(output_dir)?;
let mut sorted_cats: Vec<&Category> = dataset.categories.iter().collect();
sorted_cats.sort_by_key(|c| c.id);
let cat_id_to_idx: HashMap<u64, usize> = sorted_cats
.iter()
.enumerate()
.map(|(i, c)| (c.id, i))
.collect();
let anns_by_image = super::anns_by_image(dataset);
let mut stats = YoloStats {
images: dataset.images.len(),
..Default::default()
};
for img in &dataset.images {
if img.width == 0 || img.height == 0 {
return Err(ConvertError::MissingImageDimensions(format!(
"{} (id {})",
img.file_name, img.id
)));
}
let stem = super::file_stem(&img.file_name);
let txt_path = output_dir.join(format!("{stem}.txt"));
let mut file = fs::File::create(&txt_path)?;
let w = img.width as f64;
let h = img.height as f64;
if let Some(anns) = anns_by_image.get(&img.id) {
for ann in anns {
if ann.iscrowd {
stats.skipped_crowd += 1;
continue;
}
let bbox = match ann.bbox {
Some(b) => b,
None => {
stats.skipped_no_bbox += 1;
continue;
}
};
let class_idx =
*cat_id_to_idx
.get(&ann.category_id)
.ok_or(ConvertError::UnknownCategory {
ann_id: ann.id,
category_id: ann.category_id,
})?;
let [x, y, bw, bh] = bbox;
let cx = (x + bw / 2.0) / w;
let cy = (y + bh / 2.0) / h;
let nw = bw / w;
let nh = bh / h;
writeln!(file, "{class_idx} {cx:.6} {cy:.6} {nw:.6} {nh:.6}")?;
stats.annotations += 1;
}
}
}
let yaml_path = output_dir.join("data.yaml");
let mut yaml_file = fs::File::create(&yaml_path)?;
writeln!(yaml_file, "nc: {}", sorted_cats.len())?;
let names_csv: Vec<Cow<'_, str>> = sorted_cats
.iter()
.map(|c| yaml_scalar(c.name.as_str()))
.collect();
writeln!(yaml_file, "names: [{}]", names_csv.join(", "))?;
Ok(stats)
}
pub fn yolo_to_coco(
yolo_dir: &Path,
image_dims: &HashMap<String, (u32, u32)>,
) -> Result<Dataset, ConvertError> {
let yaml_path = yolo_dir.join("data.yaml");
if !yaml_path.exists() {
return Err(ConvertError::MissingDataYaml);
}
let yaml_content = fs::read_to_string(&yaml_path)?;
let names = parse_data_yaml(&yaml_path, &yaml_content)?;
let categories: Vec<Category> = names
.iter()
.enumerate()
.map(|(i, name)| Category {
id: (i + 1) as u64,
name: name.clone(),
..Default::default()
})
.collect();
let mut txt_files: Vec<PathBuf> = fs::read_dir(yolo_dir)?
.filter_map(|entry| {
let path = entry.ok()?.path();
if path.extension() == Some(OsStr::new("txt")) {
Some(path)
} else {
None
}
})
.collect();
txt_files.sort();
let mut images: Vec<Image> = Vec::new();
let mut annotations: Vec<Annotation> = Vec::new();
let mut img_id = 1u64;
let mut ann_id = 1u64;
for txt_path in &txt_files {
let stem = super::utf8_stem(txt_path)?.to_string();
let (width, height) = super::lookup_image_dims(image_dims, &stem)
.ok_or_else(|| ConvertError::MissingImageDimensions(stem.clone()))?;
images.push(Image {
id: img_id,
file_name: stem.clone(),
width,
height,
..Default::default()
});
let content = fs::read_to_string(txt_path)?;
let w = f64::from(width);
let h = f64::from(height);
for (line_idx, line) in content.lines().enumerate() {
let line_no = line_idx + 1;
let line = line.trim();
if line.is_empty() {
continue;
}
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() != 5 {
return Err(line_err(
txt_path,
line_no,
format!("expected 5 fields, got {} in: {line}", parts.len()),
));
}
let field = |idx: usize, name: &str| -> Result<f64, ConvertError> {
parts[idx].parse::<f64>().map_err(|_| {
line_err(txt_path, line_no, format!("invalid {name}: {}", parts[idx]))
})
};
let class_idx: usize = parts[0].parse().map_err(|_| {
line_err(
txt_path,
line_no,
format!("invalid class_idx: {}", parts[0]),
)
})?;
let cx = field(1, "cx")?;
let cy = field(2, "cy")?;
let bw = field(3, "width")?;
let bh = field(4, "height")?;
if class_idx >= categories.len() {
return Err(line_err(
txt_path,
line_no,
format!(
"class_idx {class_idx} out of range (nc={})",
categories.len()
),
));
}
let category_id = (class_idx + 1) as u64;
let px = (cx - bw / 2.0) * w;
let py = (cy - bh / 2.0) * h;
let pw = bw * w;
let ph = bh * h;
annotations.push(Annotation {
id: ann_id,
image_id: img_id,
category_id,
bbox: Some([px, py, pw, ph]),
area: Some(pw * ph),
..Default::default()
});
ann_id += 1;
}
img_id += 1;
}
Ok(Dataset {
info: None,
images,
annotations,
categories,
licenses: vec![],
})
}
fn yaml_scalar(name: &str) -> Cow<'_, str> {
let needs_quoting = name.is_empty()
|| name != name.trim()
|| name.starts_with('-')
|| name.contains([
',', ':', '#', '[', ']', '{', '}', '\'', '"', '\n', '&', '*', '?', '|', '>', '!', '%',
'@', '`',
]);
if needs_quoting {
Cow::Owned(format!("'{}'", name.replace('\'', "''")))
} else {
Cow::Borrowed(name)
}
}
fn yaml_unquote(s: &str) -> String {
let s = s.trim();
if s.len() >= 2 && s.starts_with('\'') && s.ends_with('\'') {
s[1..s.len() - 1].replace("''", "'")
} else if s.len() >= 2 && s.starts_with('"') && s.ends_with('"') {
s[1..s.len() - 1]
.replace("\\\"", "\"")
.replace("\\\\", "\\")
} else {
s.to_string()
}
}
fn split_yaml_flow(inner: &str) -> Vec<String> {
let mut items: Vec<String> = Vec::new();
let mut current = String::new();
let mut quote: Option<char> = None;
for c in inner.chars() {
match quote {
Some(q) => {
current.push(c);
if c == q {
quote = None;
}
}
None => match c {
'\'' | '"' => {
current.push(c);
quote = Some(c);
}
',' => items.push(std::mem::take(&mut current)),
_ => current.push(c),
},
}
}
items.push(current);
items
.iter()
.map(|item| yaml_unquote(item))
.filter(|item| !item.is_empty())
.collect()
}
fn parse_data_yaml(yaml_path: &Path, content: &str) -> Result<Vec<String>, ConvertError> {
let lines: Vec<&str> = content.lines().collect();
for (i, raw) in lines.iter().enumerate() {
let trimmed = raw.trim();
let Some(rest) = trimmed.strip_prefix("names:") else {
continue;
};
let rest = rest.trim();
if rest.is_empty() || rest.starts_with('#') {
let indent = raw.len() - raw.trim_start().len();
return parse_block_names(yaml_path, &lines, i + 1, indent);
}
return parse_flow_names(yaml_path, i + 1, rest);
}
Err(parse_err(yaml_path, "no `names` field found in data.yaml"))
}
fn parse_flow_names(
yaml_path: &Path,
line_no: usize,
rest: &str,
) -> Result<Vec<String>, ConvertError> {
let inner = rest
.strip_prefix('[')
.and_then(|r| r.strip_suffix(']'))
.ok_or_else(|| {
line_err(
yaml_path,
line_no,
format!("expected a flow list after `names:`, got `{rest}`"),
)
})?;
Ok(split_yaml_flow(inner))
}
fn parse_block_names(
yaml_path: &Path,
lines: &[&str],
start: usize,
names_indent: usize,
) -> Result<Vec<String>, ConvertError> {
let mut list_items: Vec<String> = Vec::new();
let mut map_items: Vec<(usize, String, usize)> = Vec::new();
for (offset, raw) in lines[start..].iter().enumerate() {
let line_no = start + offset + 1;
let trimmed = raw.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let indent = raw.len() - raw.trim_start().len();
if indent <= names_indent {
break; }
if let Some(item) = trimmed.strip_prefix('-') {
list_items.push(yaml_unquote(item));
} else if let Some((key, value)) = trimmed.split_once(':') {
let idx: usize = key.trim().parse().map_err(|_| {
line_err(
yaml_path,
line_no,
format!("expected a class index before `:`, got `{key}`"),
)
})?;
map_items.push((idx, yaml_unquote(value), line_no));
} else {
return Err(line_err(
yaml_path,
line_no,
format!("cannot parse names entry `{trimmed}`"),
));
}
}
match (list_items.is_empty(), map_items.is_empty()) {
(false, true) => Ok(list_items),
(true, false) => {
map_items.sort_by_key(|&(idx, ..)| idx);
let mut names = Vec::with_capacity(map_items.len());
for (expected, (idx, name, line_no)) in map_items.into_iter().enumerate() {
if idx != expected {
return Err(line_err(
yaml_path,
line_no,
format!(
"names indices must be 0..n without gaps or duplicates; expected {expected}, found {idx}"
),
));
}
names.push(name);
}
Ok(names)
}
(true, true) => Err(parse_err(yaml_path, "`names:` has no entries")),
(false, false) => Err(parse_err(
yaml_path,
"`names:` mixes `- name` list entries and `index: name` mapping entries",
)),
}
}