use std::collections::HashSet;
use std::fs;
use std::io::{BufReader, BufWriter};
use quick_xml::events::{BytesEnd, BytesStart, Event};
use quick_xml::reader::Reader;
use quick_xml::writer::Writer;
use crate::types::{Annotation, Category, Dataset, Image, Segmentation};
use super::ConvertError;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CvatStats {
pub images: usize,
pub boxes: usize,
pub polygons: usize,
pub skipped_no_geometry: usize,
pub skipped_degenerate: usize,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CvatImportStats {
pub images: usize,
pub boxes: usize,
pub polygons: usize,
pub skipped_degenerate: usize,
pub skipped_unsupported: usize,
}
pub fn coco_to_cvat(
dataset: &Dataset,
output_path: &std::path::Path,
) -> Result<CvatStats, ConvertError> {
let cat_name = crate::types::cat_id_to_name(dataset);
let anns_by_image = super::anns_by_image(dataset);
let file = fs::File::create(output_path)?;
let buf = BufWriter::new(file);
let mut writer = Writer::new_with_indent(buf, b' ', 2);
writer.write_event(Event::Start(BytesStart::new("annotations")))?;
write_text_element(&mut writer, "version", "1.1")?;
writer.write_event(Event::Start(BytesStart::new("meta")))?;
writer.write_event(Event::Start(BytesStart::new("task")))?;
writer.write_event(Event::Start(BytesStart::new("labels")))?;
let mut sorted_cats: Vec<&Category> = dataset.categories.iter().collect();
sorted_cats.sort_by_key(|c| c.id);
for cat in &sorted_cats {
writer.write_event(Event::Start(BytesStart::new("label")))?;
write_text_element(&mut writer, "name", &cat.name)?;
writer.write_event(Event::End(BytesEnd::new("label")))?;
}
writer.write_event(Event::End(BytesEnd::new("labels")))?;
writer.write_event(Event::End(BytesEnd::new("task")))?;
writer.write_event(Event::End(BytesEnd::new("meta")))?;
let mut stats = CvatStats {
images: dataset.images.len(),
..Default::default()
};
for img in &dataset.images {
let mut img_elem = BytesStart::new("image");
img_elem.push_attribute(("id", img.id.to_string().as_str()));
img_elem.push_attribute(("name", img.file_name.as_str()));
img_elem.push_attribute(("width", img.width.to_string().as_str()));
img_elem.push_attribute(("height", img.height.to_string().as_str()));
writer.write_event(Event::Start(img_elem))?;
if let Some(anns) = anns_by_image.get(&img.id) {
for ann in anns {
let label =
*cat_name
.get(&ann.category_id)
.ok_or(ConvertError::UnknownCategory {
ann_id: ann.id,
category_id: ann.category_id,
})?;
let mut wrote_shape = false;
if let Some(Segmentation::Polygon(ref polys)) = ann.segmentation {
for poly in polys {
if poly.len() < 6 {
stats.skipped_degenerate += 1;
continue;
}
let points_str = poly
.chunks_exact(2)
.map(|p| format!("{:.2},{:.2}", p[0], p[1]))
.collect::<Vec<_>>()
.join(";");
let mut elem = BytesStart::new("polygon");
elem.push_attribute(("label", label));
elem.push_attribute(("points", points_str.as_str()));
elem.push_attribute(("occluded", "0"));
writer.write_event(Event::Empty(elem))?;
stats.polygons += 1;
wrote_shape = true;
}
}
if !wrote_shape {
if let Some([x, y, w, h]) = ann.bbox {
let mut elem = BytesStart::new("box");
elem.push_attribute(("label", label));
elem.push_attribute(("xtl", format!("{:.2}", x).as_str()));
elem.push_attribute(("ytl", format!("{:.2}", y).as_str()));
elem.push_attribute(("xbr", format!("{:.2}", x + w).as_str()));
elem.push_attribute(("ybr", format!("{:.2}", y + h).as_str()));
elem.push_attribute(("occluded", "0"));
writer.write_event(Event::Empty(elem))?;
stats.boxes += 1;
} else {
stats.skipped_no_geometry += 1;
}
}
}
}
writer.write_event(Event::End(BytesEnd::new("image")))?;
}
writer.write_event(Event::End(BytesEnd::new("annotations")))?;
Ok(stats)
}
pub fn cvat_to_coco(
cvat_path: &std::path::Path,
) -> Result<(Dataset, CvatImportStats), ConvertError> {
let file = fs::File::open(cvat_path)?;
let parsed = parse_cvat_xml(BufReader::new(file)).map_err(|e| e.with_path(cvat_path))?;
let (boxes, polygons) = parsed.images.iter().flat_map(|img| &img.shapes).fold(
(0usize, 0usize),
|(boxes, polygons), s| match s.kind {
ShapeKind::Box { .. } => (boxes + 1, polygons),
ShapeKind::Polygon { .. } => (boxes, polygons + 1),
},
);
let stats = CvatImportStats {
images: parsed.images.len(),
boxes,
polygons,
skipped_degenerate: parsed.skipped_degenerate,
skipped_unsupported: parsed.skipped_unsupported,
};
let names = derive_category_names(parsed.meta_labels, &parsed.images);
Ok((build_dataset(parsed.images, names), stats))
}
struct ParsedCvat {
meta_labels: Vec<String>,
images: Vec<ParsedCvatImage>,
skipped_degenerate: usize,
skipped_unsupported: usize,
}
fn parse_cvat_xml<R: std::io::BufRead>(reader: R) -> Result<ParsedCvat, ConvertError> {
let mut xml = Reader::from_reader(reader);
xml.config_mut().trim_text(true);
let mut meta_labels: Vec<String> = Vec::new();
let mut images: Vec<ParsedCvatImage> = Vec::new();
let mut skipped_degenerate = 0usize;
let mut skipped_unsupported = 0usize;
let mut in_meta = false;
let mut in_task = false;
let mut in_labels = false;
let mut in_label = false;
let mut current_tag: Vec<u8> = Vec::new();
let mut label_name = String::new();
let mut current_image: Option<ParsedCvatImage> = None;
let mut buf = Vec::new();
let mut skip_buf = Vec::new();
loop {
match xml.read_event_into(&mut buf) {
Ok(Event::Start(ref e)) => {
let name = e.name();
let tag = name.as_ref();
match tag {
b"meta" => in_meta = true,
b"task" if in_meta => in_task = true,
b"labels" if in_task => in_labels = true,
b"label" if in_labels => {
in_label = true;
label_name.clear();
}
b"image" => current_image = Some(parse_image_attrs(e)?),
b"box" | b"polygon" => {
let pos = xml.buffer_position();
let img = current_image
.as_mut()
.ok_or_else(|| shape_outside_image(tag, pos))?;
match parse_shape_attrs(tag, e).map_err(|err| at_byte(err, pos))? {
Some(shape) => img.shapes.push(shape),
None => skipped_degenerate += 1,
}
let end = e.to_end().into_owned();
skip_buf.clear();
xml.read_to_end_into(end.name(), &mut skip_buf)?;
}
_ if current_image.is_some() => {
skipped_unsupported += 1;
let end = e.to_end().into_owned();
skip_buf.clear();
xml.read_to_end_into(end.name(), &mut skip_buf)?;
}
_ => {}
}
current_tag = tag.to_vec();
}
Ok(Event::Empty(ref e)) => {
let name = e.name();
let tag = name.as_ref();
match tag {
b"image" => images.push(parse_image_attrs(e)?),
b"box" | b"polygon" => {
let pos = xml.buffer_position();
let img = current_image
.as_mut()
.ok_or_else(|| shape_outside_image(tag, pos))?;
match parse_shape_attrs(tag, e).map_err(|err| at_byte(err, pos))? {
Some(shape) => img.shapes.push(shape),
None => skipped_degenerate += 1,
}
}
_ if current_image.is_some() => skipped_unsupported += 1,
_ => {}
}
}
Ok(Event::End(ref e)) => {
let name = e.name();
match name.as_ref() {
b"meta" => in_meta = false,
b"task" => in_task = false,
b"labels" => in_labels = false,
b"label" if in_label => {
if !label_name.is_empty() {
meta_labels.push(std::mem::take(&mut label_name));
}
in_label = false;
}
b"image" => {
if let Some(img) = current_image.take() {
images.push(img);
}
}
_ => {}
}
current_tag.clear();
}
Ok(Event::Text(ref e)) => {
if in_label && current_tag == b"name" {
let text = e
.decode()
.map_err(|err| ConvertError::XmlError(format!("invalid text: {err}")))?;
label_name = text.trim().to_string();
}
}
Ok(Event::Eof) => break,
Err(e) => {
return Err(ConvertError::XmlError(format!(
"near byte {}: {e}",
xml.error_position()
)));
}
_ => {}
}
buf.clear();
}
Ok(ParsedCvat {
meta_labels,
images,
skipped_degenerate,
skipped_unsupported,
})
}
fn derive_category_names(meta_labels: Vec<String>, images: &[ParsedCvatImage]) -> Vec<String> {
let had_meta = !meta_labels.is_empty();
let mut seen: HashSet<String> = meta_labels.iter().cloned().collect();
let mut names = meta_labels;
for shape in images.iter().flat_map(|img| &img.shapes) {
if seen.insert(shape.label.clone()) {
names.push(shape.label.clone());
}
}
if !had_meta {
names.sort();
}
names
}
fn build_dataset(parsed_images: Vec<ParsedCvatImage>, category_names: Vec<String>) -> Dataset {
let categories: Vec<Category> = category_names
.into_iter()
.enumerate()
.map(|(i, name)| Category {
id: (i + 1) as u64,
name,
..Default::default()
})
.collect();
let name_to_id = crate::types::cat_name_to_id(&categories);
let mut images: Vec<Image> = Vec::new();
let mut annotations: Vec<Annotation> = Vec::new();
for (i, parsed) in parsed_images.iter().enumerate() {
let img_id = (i + 1) as u64;
images.push(Image {
id: img_id,
file_name: parsed.name.clone(),
width: parsed.width,
height: parsed.height,
..Default::default()
});
for shape in &parsed.shapes {
let Some(&category_id) = name_to_id.get(shape.label.as_str()) else {
continue;
};
let ann_id = (annotations.len() + 1) as u64;
annotations.push(shape_to_annotation(
ann_id,
img_id,
category_id,
&shape.kind,
));
}
}
Dataset {
info: None,
images,
annotations,
categories,
licenses: vec![],
}
}
fn shape_to_annotation(id: u64, image_id: u64, category_id: u64, kind: &ShapeKind) -> Annotation {
let (bbox, area, segmentation) = match kind {
ShapeKind::Box { xtl, ytl, xbr, ybr } => {
let (w, h) = (xbr - xtl, ybr - ytl);
([*xtl, *ytl, w, h], w * h, None)
}
ShapeKind::Polygon { points } => (
crate::geometry::polygon_bbox(points),
crate::geometry::polygon_area(points),
Some(Segmentation::Polygon(vec![
points.iter().flat_map(|&(x, y)| [x, y]).collect(),
])),
),
};
Annotation {
id,
image_id,
category_id,
bbox: Some(bbox),
area: Some(area),
segmentation,
..Default::default()
}
}
struct ParsedCvatImage {
name: String,
width: u32,
height: u32,
shapes: Vec<ParsedCvatShape>,
}
struct ParsedCvatShape {
label: String,
kind: ShapeKind,
}
enum ShapeKind {
Box {
xtl: f64,
ytl: f64,
xbr: f64,
ybr: f64,
},
Polygon {
points: Vec<(f64, f64)>,
},
}
use super::{at_byte, write_text_element};
fn shape_outside_image(tag: &[u8], pos: u64) -> ConvertError {
ConvertError::ParseError(format!(
"near byte {pos}: found <{}> outside an <image> element — is this a CVAT for video export? Only CVAT for Images 1.1 is supported",
String::from_utf8_lossy(tag)
))
}
fn parse_shape_attrs(tag: &[u8], e: &BytesStart) -> Result<Option<ParsedCvatShape>, ConvertError> {
if tag == b"box" {
parse_box_attrs(e).map(Some)
} else {
parse_polygon_attrs(e)
}
}
fn parse_image_attrs(e: &BytesStart) -> Result<ParsedCvatImage, ConvertError> {
let mut name: Option<String> = None;
let mut width: Option<u32> = None;
let mut height: Option<u32> = None;
for attr in e.attributes().flatten() {
let val = String::from_utf8_lossy(&attr.value);
match attr.key.as_ref() {
b"name" => name = Some(val.to_string()),
b"width" => {
width = Some(val.parse().map_err(|_| {
ConvertError::ParseError(format!("invalid image width: {val}"))
})?);
}
b"height" => {
height = Some(val.parse().map_err(|_| {
ConvertError::ParseError(format!("invalid image height: {val}"))
})?);
}
_ => {}
}
}
let missing =
|attr: &str| ConvertError::ParseError(format!("CVAT <image> missing `{attr}` attribute"));
Ok(ParsedCvatImage {
name: name
.filter(|n| !n.is_empty())
.ok_or_else(|| missing("name"))?,
width: width.ok_or_else(|| missing("width"))?,
height: height.ok_or_else(|| missing("height"))?,
shapes: Vec::new(),
})
}
fn parse_box_attrs(e: &BytesStart) -> Result<ParsedCvatShape, ConvertError> {
const COORDS: [&str; 4] = ["xtl", "ytl", "xbr", "ybr"];
let mut label: Option<String> = None;
let mut coords: [Option<f64>; 4] = [None; 4];
for attr in e.attributes().flatten() {
let val = String::from_utf8_lossy(&attr.value);
let key = attr.key.as_ref();
if key == b"label" {
label = Some(val.to_string());
} else if let Some(i) = COORDS.iter().position(|c| c.as_bytes() == key) {
coords[i] =
Some(val.parse().map_err(|_| {
ConvertError::ParseError(format!("invalid {}: {val}", COORDS[i]))
})?);
}
}
let label = label
.filter(|l| !l.is_empty())
.ok_or_else(|| ConvertError::ParseError("CVAT <box> missing `label` attribute".into()))?;
let coord = |i: usize| {
coords[i].ok_or_else(|| {
ConvertError::ParseError(format!("CVAT <box> missing `{}` attribute", COORDS[i]))
})
};
Ok(ParsedCvatShape {
label,
kind: ShapeKind::Box {
xtl: coord(0)?,
ytl: coord(1)?,
xbr: coord(2)?,
ybr: coord(3)?,
},
})
}
fn parse_polygon_attrs(e: &BytesStart) -> Result<Option<ParsedCvatShape>, ConvertError> {
let mut label: Option<String> = None;
let mut points_str: Option<String> = None;
for attr in e.attributes().flatten() {
match attr.key.as_ref() {
b"label" => label = Some(String::from_utf8_lossy(&attr.value).to_string()),
b"points" => points_str = Some(String::from_utf8_lossy(&attr.value).to_string()),
_ => {}
}
}
let label = label.filter(|l| !l.is_empty()).ok_or_else(|| {
ConvertError::ParseError("CVAT <polygon> missing `label` attribute".into())
})?;
let points_str = points_str.ok_or_else(|| {
ConvertError::ParseError("CVAT <polygon> missing `points` attribute".into())
})?;
let points = parse_cvat_points(&points_str)?;
if points.len() < 3 {
return Ok(None);
}
Ok(Some(ParsedCvatShape {
label,
kind: ShapeKind::Polygon { points },
}))
}
fn parse_cvat_points(s: &str) -> Result<Vec<(f64, f64)>, ConvertError> {
let s = s.trim();
if s.is_empty() {
return Ok(Vec::new());
}
s.split(';')
.map(|pair| {
let parts: Vec<&str> = pair.split(',').collect();
if parts.len() != 2 {
return Err(ConvertError::ParseError(format!(
"invalid point pair: {pair}"
)));
}
let x: f64 = parts[0]
.trim()
.parse()
.map_err(|_| ConvertError::ParseError(format!("invalid x: {}", parts[0])))?;
let y: f64 = parts[1]
.trim()
.parse()
.map_err(|_| ConvertError::ParseError(format!("invalid y: {}", parts[1])))?;
Ok((x, y))
})
.collect()
}