panlabel 0.7.0

The universal annotation converter
Documentation
use std::fs;

use panlabel::ir::io_superannotate_json::{
    from_superannotate_str, read_superannotate_json, to_superannotate_string,
    write_superannotate_json, ATTR_GEOMETRY_TYPE, ATTR_INSTANCE_ID,
};
use panlabel::ir::{Annotation, BBoxXYXY, Category, Dataset, Image, Pixel};

fn assert_bbox(bbox: BBoxXYXY<Pixel>, expected: (f64, f64, f64, f64)) {
    assert_eq!(bbox.xmin(), expected.0);
    assert_eq!(bbox.ymin(), expected.1);
    assert_eq!(bbox.xmax(), expected.2);
    assert_eq!(bbox.ymax(), expected.3);
}

fn category_names(dataset: &Dataset) -> Vec<&str> {
    dataset
        .categories
        .iter()
        .map(|category| category.name.as_str())
        .collect()
}

#[test]
fn parses_fixture_with_bbox_polygon_and_oriented_envelopes() {
    let dataset =
        read_superannotate_json("tests/fixtures/sample_valid.superannotate.json".as_ref())
            .expect("parse fixture");

    assert_eq!(dataset.images.len(), 1);
    assert_eq!(dataset.images[0].file_name, "sa_image.jpg");
    assert_eq!(dataset.images[0].width, 640);
    assert_eq!(dataset.images[0].height, 480);
    assert_eq!(category_names(&dataset), vec!["car", "cat", "dog"]);
    assert_eq!(dataset.annotations.len(), 3);

    assert_bbox(dataset.annotations[0].bbox, (10.0, 20.0, 100.0, 80.0));
    assert_eq!(dataset.annotations[0].confidence, Some(0.91));
    assert_eq!(
        dataset.annotations[0].attributes.get(ATTR_GEOMETRY_TYPE),
        Some(&"bbox".to_string())
    );
    assert_eq!(
        dataset.annotations[0].attributes.get(ATTR_INSTANCE_ID),
        Some(&"101".to_string())
    );

    assert_bbox(dataset.annotations[1].bbox, (50.0, 60.0, 200.0, 210.0));
    assert_eq!(
        dataset.annotations[1].attributes.get(ATTR_GEOMETRY_TYPE),
        Some(&"polygon".to_string())
    );

    assert_bbox(dataset.annotations[2].bbox, (280.0, 100.0, 360.0, 180.0));
    assert_eq!(
        dataset.annotations[2].attributes.get(ATTR_GEOMETRY_TYPE),
        Some(&"rotated_bbox".to_string())
    );
}

#[test]
fn write_read_single_file_semantic_roundtrip() {
    let dataset = Dataset {
        images: vec![Image::new(1u64, "test.jpg", 640, 480)],
        categories: vec![Category::new(1u64, "cat"), Category::new(2u64, "dog")],
        annotations: vec![
            Annotation::new(
                1u64,
                1u64,
                1u64,
                BBoxXYXY::<Pixel>::from_xyxy(10.0, 20.0, 100.0, 80.0),
            )
            .with_confidence(0.7),
            Annotation::new(
                2u64,
                1u64,
                2u64,
                BBoxXYXY::<Pixel>::from_xyxy(50.0, 60.0, 200.0, 210.0),
            ),
        ],
        ..Default::default()
    };

    let json = to_superannotate_string(&dataset).expect("serialize");
    let restored = from_superannotate_str(&json).expect("parse");

    assert_eq!(restored.images.len(), 1);
    assert_eq!(restored.categories.len(), 2);
    assert_eq!(restored.annotations.len(), 2);
    assert_bbox(restored.annotations[0].bbox, (10.0, 20.0, 100.0, 80.0));
    assert_eq!(restored.annotations[0].confidence, Some(0.7));
    assert_bbox(restored.annotations[1].bbox, (50.0, 60.0, 200.0, 210.0));
}

#[test]
fn direct_bbox_aliases_normalize_geometry_type_to_bbox() {
    let json = r#"{
        "metadata": {"name": "alias.jpg", "width": 100, "height": 100},
        "instances": [
            {
                "type": "bounding_box",
                "className": "cat",
                "points": {"x1": 1, "y1": 2, "x2": 30, "y2": 40}
            },
            {
                "type": "rectangle",
                "className": "dog",
                "points": {"x1": 5, "y1": 6, "x2": 50, "y2": 60}
            }
        ]
    }"#;

    let dataset = from_superannotate_str(json).expect("parse");
    assert_eq!(
        dataset.annotations[0].attributes.get(ATTR_GEOMETRY_TYPE),
        Some(&"bbox".to_string())
    );
    assert_eq!(
        dataset.annotations[1].attributes.get(ATTR_GEOMETRY_TYPE),
        Some(&"bbox".to_string())
    );
}

#[test]
fn rejects_unsupported_geometry() {
    let json = r#"{
        "metadata": {"name": "bad.jpg", "width": 100, "height": 100},
        "instances": [
            {"type": "cuboid", "className": "box"}
        ]
    }"#;

    let err = from_superannotate_str(json).expect_err("unsupported geometry should fail");
    let message = err.to_string();
    assert!(message.contains("cuboid"), "{message}");
    assert!(
        message.contains("unsupported SuperAnnotate geometry"),
        "{message}"
    );
}

#[test]
fn writer_rejects_unsafe_image_file_name() {
    let dataset = Dataset {
        images: vec![Image::new(1u64, "../escape.jpg", 100, 100)],
        categories: vec![Category::new(1u64, "cat")],
        annotations: vec![],
        ..Default::default()
    };

    let temp = tempfile::tempdir().expect("create temp dir");
    let err = write_superannotate_json(temp.path(), &dataset).expect_err("unsafe path should fail");
    let message = err.to_string();
    assert!(message.contains("unsafe image file_name"), "{message}");
}

#[test]
fn to_string_rejects_annotation_with_missing_image() {
    let dataset = Dataset {
        images: vec![Image::new(1u64, "ok.jpg", 100, 100)],
        categories: vec![Category::new(1u64, "cat")],
        annotations: vec![Annotation::new(
            1u64,
            999u64,
            1u64,
            BBoxXYXY::<Pixel>::from_xyxy(0.0, 0.0, 10.0, 10.0),
        )],
        ..Default::default()
    };

    let err = to_superannotate_string(&dataset).expect_err("missing image should fail");
    let message = err.to_string();
    assert!(message.contains("references missing image"), "{message}");
}

#[test]
fn writer_rejects_annotation_with_missing_image() {
    let dataset = Dataset {
        images: vec![Image::new(1u64, "ok.jpg", 100, 100)],
        categories: vec![Category::new(1u64, "cat")],
        annotations: vec![Annotation::new(
            1u64,
            999u64,
            1u64,
            BBoxXYXY::<Pixel>::from_xyxy(0.0, 0.0, 10.0, 10.0),
        )],
        ..Default::default()
    };

    let temp = tempfile::tempdir().expect("create temp dir");
    let err =
        write_superannotate_json(temp.path(), &dataset).expect_err("missing image should fail");
    let message = err.to_string();
    assert!(message.contains("references missing image"), "{message}");
}

#[test]
fn reads_directory_with_class_metadata() {
    let temp = tempfile::tempdir().expect("create temp dir");
    fs::create_dir_all(temp.path().join("classes")).expect("create classes dir");
    fs::write(
        temp.path().join("classes/classes.json"),
        r#"[{"name": "cat"}, {"name": "unused"}]"#,
    )
    .expect("write classes");
    fs::write(
        temp.path().join("example.json"),
        r#"{
            "metadata": {"name": "example.jpg", "width": 320, "height": 240},
            "instances": [
                {
                    "type": "bbox",
                    "className": "cat",
                    "points": {"x1": 1, "y1": 2, "x2": 30, "y2": 40}
                }
            ]
        }"#,
    )
    .expect("write annotation");

    let dataset = read_superannotate_json(temp.path()).expect("read directory");

    assert_eq!(dataset.images.len(), 1);
    assert_eq!(dataset.images[0].file_name, "example.jpg");
    assert_eq!(category_names(&dataset), vec!["cat", "unused"]);
    assert_eq!(dataset.annotations.len(), 1);
    assert_bbox(dataset.annotations[0].bbox, (1.0, 2.0, 30.0, 40.0));
}

#[test]
fn writes_and_reads_canonical_directory() {
    let dataset = Dataset {
        images: vec![
            Image::new(1u64, "b.jpg", 800, 600),
            Image::new(2u64, "a.jpg", 640, 480),
        ],
        categories: vec![Category::new(1u64, "cat"), Category::new(2u64, "unused")],
        annotations: vec![Annotation::new(
            1u64,
            2u64,
            1u64,
            BBoxXYXY::<Pixel>::from_xyxy(5.0, 6.0, 50.0, 60.0),
        )],
        ..Default::default()
    };

    let temp = tempfile::tempdir().expect("create temp dir");
    write_superannotate_json(temp.path(), &dataset).expect("write directory");

    assert!(temp.path().join("annotations/a.json").is_file());
    assert!(temp.path().join("annotations/b.json").is_file());
    assert!(temp.path().join("classes/classes.json").is_file());
    assert!(temp.path().join("images/README.txt").is_file());

    let restored = read_superannotate_json(temp.path()).expect("read directory");
    assert_eq!(restored.images[0].file_name, "a.jpg");
    assert_eq!(restored.images[1].file_name, "b.jpg");
    assert_eq!(category_names(&restored), vec!["cat", "unused"]);
    assert_eq!(restored.annotations.len(), 1);
    assert_bbox(restored.annotations[0].bbox, (5.0, 6.0, 50.0, 60.0));
}