use std::collections::{BTreeMap, BTreeSet};
use std::f64::consts::PI;
use std::fs::File;
use std::io::{BufReader, BufWriter};
use std::path::Path;
use serde::{Deserialize, Serialize};
use super::model::{Annotation, Category, Dataset, DatasetInfo, Image};
use super::{AnnotationId, BBoxXYXY, CategoryId, ImageId, Pixel};
use crate::error::PanlabelError;
#[derive(Debug, Deserialize)]
struct LsTask {
#[serde(default)]
data: LsTaskData,
#[serde(default)]
annotations: Option<Vec<LsResultSet>>,
#[serde(default)]
completions: Option<Vec<LsResultSet>>,
#[serde(default)]
predictions: Option<Vec<LsResultSet>>,
}
#[derive(Debug, Default, Deserialize)]
struct LsTaskData {
#[serde(default)]
image: Option<String>,
#[serde(default)]
width: Option<u32>,
#[serde(default)]
height: Option<u32>,
}
#[derive(Debug, Default, Deserialize)]
struct LsResultSet {
#[serde(default)]
result: Vec<LsResult>,
}
#[derive(Debug, Deserialize)]
struct LsResult {
#[serde(rename = "type")]
result_type: String,
#[serde(default)]
value: Option<serde_json::Value>,
#[serde(default)]
original_width: Option<u32>,
#[serde(default)]
original_height: Option<u32>,
#[serde(default)]
rotation: Option<f64>,
#[serde(default)]
from_name: Option<String>,
#[serde(default)]
to_name: Option<String>,
#[serde(default)]
score: Option<f64>,
}
#[derive(Debug, Deserialize)]
struct LsRectangleValue {
x: f64,
y: f64,
width: f64,
height: f64,
#[serde(default)]
rectanglelabels: Vec<String>,
}
#[derive(Debug, Serialize)]
struct LsTaskOut {
id: u64,
data: LsTaskDataOut,
#[serde(skip_serializing_if = "Vec::is_empty")]
annotations: Vec<LsResultSetOut>,
#[serde(skip_serializing_if = "Vec::is_empty")]
predictions: Vec<LsResultSetOut>,
}
#[derive(Debug, Serialize)]
struct LsTaskDataOut {
image: String,
width: u32,
height: u32,
}
#[derive(Debug, Serialize)]
struct LsResultSetOut {
result: Vec<LsResultOut>,
}
#[derive(Debug, Serialize)]
struct LsResultOut {
#[serde(rename = "type")]
result_type: &'static str,
value: LsRectangleValueOut,
original_width: u32,
original_height: u32,
from_name: String,
to_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
rotation: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
score: Option<f64>,
}
#[derive(Debug, Serialize)]
struct LsRectangleValueOut {
x: f64,
y: f64,
width: f64,
height: f64,
rectanglelabels: Vec<String>,
}
#[derive(Debug)]
struct ParsedTask {
file_name: String,
image_ref: String,
width: u32,
height: u32,
from_name: Option<String>,
to_name: Option<String>,
rows: Vec<ParsedAnnotation>,
}
#[derive(Debug)]
struct ParsedAnnotation {
label: String,
bbox: BBoxXYXY<Pixel>,
confidence: Option<f64>,
attributes: BTreeMap<String, String>,
}
pub fn read_label_studio_json(path: &Path) -> Result<Dataset, PanlabelError> {
let file = File::open(path).map_err(PanlabelError::Io)?;
let reader = BufReader::new(file);
let tasks: Vec<LsTask> =
serde_json::from_reader(reader).map_err(|source| PanlabelError::LabelStudioJsonParse {
path: path.to_path_buf(),
source,
})?;
ls_to_ir(tasks, path)
}
pub fn write_label_studio_json(path: &Path, dataset: &Dataset) -> Result<(), PanlabelError> {
let tasks = ir_to_ls(dataset, path)?;
let file = File::create(path).map_err(PanlabelError::Io)?;
let writer = BufWriter::new(file);
serde_json::to_writer_pretty(writer, &tasks).map_err(|source| {
PanlabelError::LabelStudioJsonWrite {
path: path.to_path_buf(),
source,
}
})
}
pub fn from_label_studio_str(json: &str) -> Result<Dataset, PanlabelError> {
let path = Path::new("<string>");
let tasks: Vec<LsTask> =
serde_json::from_str(json).map_err(|source| PanlabelError::LabelStudioJsonParse {
path: path.to_path_buf(),
source,
})?;
ls_to_ir(tasks, path)
}
pub fn from_label_studio_slice(bytes: &[u8]) -> Result<Dataset, PanlabelError> {
let path = Path::new("<bytes>");
let tasks: Vec<LsTask> =
serde_json::from_slice(bytes).map_err(|source| PanlabelError::LabelStudioJsonParse {
path: path.to_path_buf(),
source,
})?;
ls_to_ir(tasks, path)
}
pub fn to_label_studio_string(dataset: &Dataset) -> Result<String, PanlabelError> {
let path = Path::new("<string>");
let tasks = ir_to_ls(dataset, path)?;
serde_json::to_string_pretty(&tasks).map_err(|source| PanlabelError::LabelStudioJsonWrite {
path: path.to_path_buf(),
source,
})
}
fn ls_to_ir(tasks: Vec<LsTask>, path: &Path) -> Result<Dataset, PanlabelError> {
if tasks.is_empty() {
return Ok(Dataset::default());
}
let mut parsed_tasks = Vec::with_capacity(tasks.len());
let mut seen_file_names = BTreeSet::new();
for (task_idx, task) in tasks.into_iter().enumerate() {
let image_ref = task
.data
.image
.ok_or_else(|| invalid(path, format!("task[{task_idx}] missing data.image")))?;
let file_name = derive_image_file_name(&image_ref).ok_or_else(|| {
invalid(
path,
format!(
"task[{task_idx}] data.image '{}' does not contain a valid filename",
image_ref
),
)
})?;
if !seen_file_names.insert(file_name.clone()) {
return Err(invalid(
path,
format!(
"duplicate image basename '{}' derived from data.image; panlabel requires unique basenames",
file_name
),
));
}
let annotation_results =
select_annotation_results(task.annotations, task.completions, path, task_idx)?;
let prediction_results = select_prediction_results(task.predictions, path, task_idx)?;
let mut from_names = BTreeSet::new();
let mut to_names = BTreeSet::new();
let mut dims: Option<(u32, u32)> = None;
let mut rows = Vec::new();
for (result_idx, result) in annotation_results.iter().enumerate() {
let parsed = parse_result(
result,
path,
task_idx,
result_idx,
"annotations",
&mut dims,
&mut from_names,
&mut to_names,
)?;
rows.push(parsed);
}
for (result_idx, result) in prediction_results.iter().enumerate() {
let parsed = parse_result(
result,
path,
task_idx,
result_idx,
"predictions",
&mut dims,
&mut from_names,
&mut to_names,
)?;
rows.push(parsed);
}
let (width, height) = if let Some((w, h)) = dims {
(w, h)
} else if let (Some(w), Some(h)) = (task.data.width, task.data.height) {
(w, h)
} else {
return Err(invalid(
path,
format!(
"task[{task_idx}] has no results with original_width/original_height and no data.width/data.height fallback"
),
));
};
if from_names.len() > 1 {
return Err(invalid(
path,
format!("task[{task_idx}] has inconsistent from_name values across results"),
));
}
if to_names.len() > 1 {
return Err(invalid(
path,
format!("task[{task_idx}] has inconsistent to_name values across results"),
));
}
let from_name = from_names.into_iter().next();
let to_name = to_names.into_iter().next();
parsed_tasks.push(ParsedTask {
file_name,
image_ref,
width,
height,
from_name,
to_name,
rows,
});
}
parsed_tasks.sort_by(|left, right| left.file_name.cmp(&right.file_name));
let mut category_names = BTreeSet::new();
for task in &parsed_tasks {
for row in &task.rows {
category_names.insert(row.label.clone());
}
}
let categories: Vec<Category> = category_names
.into_iter()
.enumerate()
.map(|(idx, name)| Category::new((idx + 1) as u64, name))
.collect();
let category_id_by_name: BTreeMap<String, CategoryId> = categories
.iter()
.map(|category| (category.name.clone(), category.id))
.collect();
let mut images = Vec::with_capacity(parsed_tasks.len());
let mut image_id_by_name = BTreeMap::new();
for (idx, task) in parsed_tasks.iter().enumerate() {
let mut image = Image::new(
(idx + 1) as u64,
task.file_name.clone(),
task.width,
task.height,
);
image
.attributes
.insert("ls_image_ref".to_string(), task.image_ref.clone());
if let Some(from_name) = &task.from_name {
image
.attributes
.insert("ls_from_name".to_string(), from_name.clone());
}
if let Some(to_name) = &task.to_name {
image
.attributes
.insert("ls_to_name".to_string(), to_name.clone());
}
image_id_by_name.insert(task.file_name.clone(), image.id);
images.push(image);
}
let mut annotations = Vec::new();
let mut next_annotation_id: u64 = 1;
for task in parsed_tasks {
let image_id = image_id_by_name
.get(&task.file_name)
.copied()
.ok_or_else(|| {
invalid(
path,
format!(
"internal error: missing image mapping for '{}'",
task.file_name
),
)
})?;
for parsed in task.rows {
let category_id = category_id_by_name
.get(&parsed.label)
.copied()
.ok_or_else(|| {
invalid(
path,
format!(
"internal error: missing category mapping for '{}'",
parsed.label
),
)
})?;
let mut annotation = Annotation::new(
AnnotationId::new(next_annotation_id),
image_id,
category_id,
parsed.bbox,
);
annotation.confidence = parsed.confidence;
annotation.attributes = parsed.attributes;
annotations.push(annotation);
next_annotation_id += 1;
}
}
Ok(Dataset {
info: DatasetInfo::default(),
licenses: vec![],
images,
categories,
annotations,
})
}
fn select_annotation_results(
annotations: Option<Vec<LsResultSet>>,
completions: Option<Vec<LsResultSet>>,
path: &Path,
task_idx: usize,
) -> Result<Vec<LsResult>, PanlabelError> {
if annotations.is_some() && completions.is_some() {
return Err(invalid(
path,
format!("task[{task_idx}] has both annotations and completions; expected only one key"),
));
}
let selected = annotations.or(completions);
let Some(mut sets) = selected else {
return Ok(vec![]);
};
if sets.len() > 1 {
return Err(invalid(
path,
format!(
"task[{task_idx}] has {} annotation sets; panlabel currently requires <= 1",
sets.len()
),
));
}
Ok(sets.pop().map(|set| set.result).unwrap_or_default())
}
fn select_prediction_results(
predictions: Option<Vec<LsResultSet>>,
path: &Path,
task_idx: usize,
) -> Result<Vec<LsResult>, PanlabelError> {
let Some(mut sets) = predictions else {
return Ok(vec![]);
};
if sets.len() > 1 {
return Err(invalid(
path,
format!(
"task[{task_idx}] has {} prediction sets; panlabel currently requires <= 1",
sets.len()
),
));
}
Ok(sets.pop().map(|set| set.result).unwrap_or_default())
}
#[allow(clippy::too_many_arguments)]
fn parse_result(
result: &LsResult,
path: &Path,
task_idx: usize,
result_idx: usize,
result_source: &str,
dims: &mut Option<(u32, u32)>,
from_names: &mut BTreeSet<String>,
to_names: &mut BTreeSet<String>,
) -> Result<ParsedAnnotation, PanlabelError> {
if result.result_type != "rectanglelabels" {
return Err(invalid(
path,
format!(
"task[{task_idx}] {result_source}[{result_idx}] unsupported result type '{}'; only rectanglelabels is supported",
result.result_type
),
));
}
let raw_value = result.value.as_ref().ok_or_else(|| {
invalid(
path,
format!("task[{task_idx}] {result_source}[{result_idx}] missing value object"),
)
})?;
let value: LsRectangleValue = serde_json::from_value(raw_value.clone()).map_err(|source| {
invalid(
path,
format!(
"task[{task_idx}] {result_source}[{result_idx}] has invalid rectanglelabels value: {source}"
),
)
})?;
if value.rectanglelabels.len() != 1 {
return Err(invalid(
path,
format!(
"task[{task_idx}] {result_source}[{result_idx}] rectanglelabels length is {}; expected exactly 1",
value.rectanglelabels.len()
),
));
}
let original_width = result.original_width.ok_or_else(|| {
invalid(
path,
format!("task[{task_idx}] {result_source}[{result_idx}] missing original_width"),
)
})?;
let original_height = result.original_height.ok_or_else(|| {
invalid(
path,
format!("task[{task_idx}] {result_source}[{result_idx}] missing original_height"),
)
})?;
if let Some((existing_w, existing_h)) = dims {
if *existing_w != original_width || *existing_h != original_height {
return Err(invalid(
path,
format!(
"task[{task_idx}] has inconsistent original dimensions: ({existing_w}, {existing_h}) vs ({original_width}, {original_height})"
),
));
}
} else {
*dims = Some((original_width, original_height));
}
if let Some(from_name) = result.from_name.as_ref().filter(|name| !name.is_empty()) {
from_names.insert(from_name.clone());
}
if let Some(to_name) = result.to_name.as_ref().filter(|name| !name.is_empty()) {
to_names.insert(to_name.clone());
}
let rotation = result.rotation.unwrap_or(0.0);
let bbox = percent_bbox_to_pixel(
value.x,
value.y,
value.width,
value.height,
original_width,
original_height,
rotation,
);
let mut attributes = BTreeMap::new();
if rotation != 0.0 {
attributes.insert("ls_rotation_deg".to_string(), rotation.to_string());
}
Ok(ParsedAnnotation {
label: value.rectanglelabels[0].clone(),
bbox,
confidence: result.score,
attributes,
})
}
fn ir_to_ls(dataset: &Dataset, path: &Path) -> Result<Vec<LsTaskOut>, PanlabelError> {
let image_by_id: BTreeMap<ImageId, &Image> = dataset
.images
.iter()
.map(|image| (image.id, image))
.collect();
let category_name_by_id: BTreeMap<CategoryId, String> = dataset
.categories
.iter()
.map(|category| (category.id, category.name.clone()))
.collect();
let mut annotations_by_image: BTreeMap<ImageId, Vec<&Annotation>> = BTreeMap::new();
for annotation in &dataset.annotations {
if !image_by_id.contains_key(&annotation.image_id) {
return Err(invalid(
path,
format!(
"annotation {} references missing image {}",
annotation.id.as_u64(),
annotation.image_id.as_u64()
),
));
}
if !category_name_by_id.contains_key(&annotation.category_id) {
return Err(invalid(
path,
format!(
"annotation {} references missing category {}",
annotation.id.as_u64(),
annotation.category_id.as_u64()
),
));
}
annotations_by_image
.entry(annotation.image_id)
.or_default()
.push(annotation);
}
for annotations in annotations_by_image.values_mut() {
annotations.sort_by_key(|annotation| annotation.id);
}
let mut image_ref_by_id: BTreeMap<ImageId, String> = BTreeMap::new();
let mut seen_basenames = BTreeSet::new();
for image in &dataset.images {
let image_ref = image
.attributes
.get("ls_image_ref")
.cloned()
.unwrap_or_else(|| image.file_name.clone());
let basename = derive_image_file_name(&image_ref).ok_or_else(|| {
invalid(
path,
format!(
"image '{}' maps to invalid Label Studio image reference '{}'",
image.file_name, image_ref
),
)
})?;
if !seen_basenames.insert(basename.clone()) {
return Err(invalid(
path,
format!(
"multiple images map to basename '{}' for Label Studio output; unique basenames are required",
basename
),
));
}
image_ref_by_id.insert(image.id, image_ref);
}
let mut images_sorted: Vec<&Image> = dataset.images.iter().collect();
images_sorted.sort_by(|left, right| left.file_name.cmp(&right.file_name));
let mut tasks = Vec::with_capacity(images_sorted.len());
for (idx, image) in images_sorted.into_iter().enumerate() {
let image_ref = image_ref_by_id.get(&image.id).cloned().ok_or_else(|| {
invalid(
path,
format!(
"internal error: missing image reference mapping for image {}",
image.id.as_u64()
),
)
})?;
let from_name = image
.attributes
.get("ls_from_name")
.cloned()
.unwrap_or_else(|| "label".to_string());
let to_name = image
.attributes
.get("ls_to_name")
.cloned()
.unwrap_or_else(|| "image".to_string());
let image_annotations = annotations_by_image.remove(&image.id).unwrap_or_default();
let mut annotation_results = Vec::new();
let mut prediction_results = Vec::new();
for annotation in image_annotations {
let category_name = category_name_by_id
.get(&annotation.category_id)
.ok_or_else(|| {
invalid(
path,
format!(
"internal error: missing category {} while writing",
annotation.category_id.as_u64()
),
)
})?
.clone();
let rotation = annotation
.attributes
.get("ls_rotation_deg")
.and_then(|value| value.parse::<f64>().ok());
let (x, y, width, height) =
pixel_bbox_to_percent(&annotation.bbox, image.width, image.height).ok_or_else(
|| {
invalid(
path,
format!(
"image '{}' has zero width/height; cannot convert bbox {} to percentages",
image.file_name,
annotation.id.as_u64()
),
)
},
)?;
let result = LsResultOut {
result_type: "rectanglelabels",
value: LsRectangleValueOut {
x,
y,
width,
height,
rectanglelabels: vec![category_name],
},
original_width: image.width,
original_height: image.height,
from_name: from_name.clone(),
to_name: to_name.clone(),
rotation,
score: annotation.confidence,
};
if annotation.confidence.is_some() {
prediction_results.push(result);
} else {
annotation_results.push(result);
}
}
let task = LsTaskOut {
id: (idx + 1) as u64,
data: LsTaskDataOut {
image: image_ref,
width: image.width,
height: image.height,
},
annotations: if annotation_results.is_empty() {
vec![]
} else {
vec![LsResultSetOut {
result: annotation_results,
}]
},
predictions: if prediction_results.is_empty() {
vec![]
} else {
vec![LsResultSetOut {
result: prediction_results,
}]
},
};
tasks.push(task);
}
Ok(tasks)
}
fn invalid(path: &Path, message: impl Into<String>) -> PanlabelError {
PanlabelError::LabelStudioJsonInvalid {
path: path.to_path_buf(),
message: message.into(),
}
}
fn derive_image_file_name(image_ref: &str) -> Option<String> {
let no_query = image_ref.split('?').next().unwrap_or(image_ref);
let no_fragment = no_query.split('#').next().unwrap_or(no_query);
let normalized = no_fragment.replace('\\', "/");
let candidate = normalized.rsplit('/').next()?;
if candidate.is_empty() {
return None;
}
Some(candidate.to_string())
}
fn percent_bbox_to_pixel(
x: f64,
y: f64,
width: f64,
height: f64,
image_width: u32,
image_height: u32,
rotation_deg: f64,
) -> BBoxXYXY<Pixel> {
let w = image_width as f64;
let h = image_height as f64;
let xmin = (x / 100.0) * w;
let ymin = (y / 100.0) * h;
let xmax = ((x + width) / 100.0) * w;
let ymax = ((y + height) / 100.0) * h;
if rotation_deg == 0.0 {
return BBoxXYXY::from_xyxy(xmin, ymin, xmax, ymax);
}
rotated_envelope_bbox(xmin, ymin, xmax, ymax, rotation_deg)
}
fn rotated_envelope_bbox(
xmin: f64,
ymin: f64,
xmax: f64,
ymax: f64,
rotation_deg: f64,
) -> BBoxXYXY<Pixel> {
let theta = rotation_deg * (PI / 180.0);
let cos_t = theta.cos();
let sin_t = theta.sin();
let cx = (xmin + xmax) / 2.0;
let cy = (ymin + ymax) / 2.0;
let corners = [(xmin, ymin), (xmax, ymin), (xmax, ymax), (xmin, ymax)];
let mut min_x = f64::INFINITY;
let mut min_y = f64::INFINITY;
let mut max_x = f64::NEG_INFINITY;
let mut max_y = f64::NEG_INFINITY;
for (x, y) in corners {
let dx = x - cx;
let dy = y - cy;
let rx = cx + (dx * cos_t) - (dy * sin_t);
let ry = cy + (dx * sin_t) + (dy * cos_t);
min_x = min_x.min(rx);
min_y = min_y.min(ry);
max_x = max_x.max(rx);
max_y = max_y.max(ry);
}
BBoxXYXY::from_xyxy(min_x, min_y, max_x, max_y)
}
fn pixel_bbox_to_percent(
bbox: &BBoxXYXY<Pixel>,
image_width: u32,
image_height: u32,
) -> Option<(f64, f64, f64, f64)> {
if image_width == 0 || image_height == 0 {
return None;
}
let w = image_width as f64;
let h = image_height as f64;
let x = (bbox.xmin() / w) * 100.0;
let y = (bbox.ymin() / h) * 100.0;
let width = ((bbox.xmax() - bbox.xmin()) / w) * 100.0;
let height = ((bbox.ymax() - bbox.ymin()) / h) * 100.0;
Some((x, y, width, height))
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_json() -> &'static str {
r#"[
{
"data": {"image": "https://example.com/assets/img_b.jpg"},
"annotations": [
{
"result": [
{
"type": "rectanglelabels",
"from_name": "bbox",
"to_name": "image",
"value": {
"x": 10.0,
"y": 10.0,
"width": 40.0,
"height": 50.0,
"rectanglelabels": ["dog"]
},
"original_width": 200,
"original_height": 100
}
]
}
],
"predictions": [
{
"result": [
{
"type": "rectanglelabels",
"from_name": "bbox",
"to_name": "image",
"value": {
"x": 50.0,
"y": 20.0,
"width": 10.0,
"height": 20.0,
"rectanglelabels": ["cat"]
},
"original_width": 200,
"original_height": 100,
"score": 0.9
}
]
}
]
},
{
"data": {"image": "img_a.jpg"},
"annotations": [
{
"result": [
{
"type": "rectanglelabels",
"from_name": "bbox",
"to_name": "image",
"value": {
"x": 0.0,
"y": 0.0,
"width": 20.0,
"height": 50.0,
"rectanglelabels": ["cat"]
},
"original_width": 100,
"original_height": 100
}
]
}
]
}
]"#
}
#[test]
fn parse_assigns_deterministic_ids() {
let dataset = from_label_studio_str(sample_json()).expect("parse dataset");
assert_eq!(dataset.images.len(), 2);
assert_eq!(dataset.categories.len(), 2);
assert_eq!(dataset.annotations.len(), 3);
assert_eq!(dataset.images[0].file_name, "img_a.jpg");
assert_eq!(dataset.images[0].id.as_u64(), 1);
assert_eq!(dataset.images[1].file_name, "img_b.jpg");
assert_eq!(dataset.images[1].id.as_u64(), 2);
assert_eq!(dataset.categories[0].name, "cat");
assert_eq!(dataset.categories[0].id.as_u64(), 1);
assert_eq!(dataset.categories[1].name, "dog");
assert_eq!(dataset.categories[1].id.as_u64(), 2);
assert!(dataset
.annotations
.iter()
.any(|ann| ann.confidence == Some(0.9)));
assert_eq!(
dataset.images[1].attributes.get("ls_image_ref"),
Some(&"https://example.com/assets/img_b.jpg".to_string())
);
}
#[test]
fn parse_rotation_sets_attribute() {
let json = r#"[
{
"data": {"image": "img_rot.jpg"},
"annotations": [
{
"result": [
{
"type": "rectanglelabels",
"value": {
"x": 10.0,
"y": 20.0,
"width": 30.0,
"height": 40.0,
"rectanglelabels": ["box"]
},
"rotation": 35.0,
"original_width": 100,
"original_height": 200
}
]
}
]
}
]"#;
let dataset = from_label_studio_str(json).expect("parse rotated");
let ann = &dataset.annotations[0];
assert_eq!(
ann.attributes.get("ls_rotation_deg"),
Some(&"35".to_string())
);
}
#[test]
fn parse_rejects_multiple_annotation_sets() {
let json = r#"[
{
"data": {"image": "img.jpg"},
"annotations": [{"result": []}, {"result": []}]
}
]"#;
let err = from_label_studio_str(json).expect_err("expected invalid error");
match err {
PanlabelError::LabelStudioJsonInvalid { message, .. } => {
assert!(message.contains("annotation sets"));
}
other => panic!("expected LabelStudioJsonInvalid, got {other:?}"),
}
}
#[test]
fn parse_rejects_unsupported_result_type() {
let json = r#"[
{
"data": {"image": "img.jpg"},
"annotations": [
{
"result": [
{
"type": "choices",
"value": {"choices": ["yes"]},
"original_width": 10,
"original_height": 10
}
]
}
]
}
]"#;
let err = from_label_studio_str(json).expect_err("expected invalid error");
match err {
PanlabelError::LabelStudioJsonInvalid { message, .. } => {
assert!(message.contains("unsupported result type"));
}
other => panic!("expected LabelStudioJsonInvalid, got {other:?}"),
}
}
#[test]
fn parse_rejects_duplicate_basenames() {
let json = r#"[
{
"data": {"image": "https://a.example/x/img.jpg", "width": 10, "height": 10},
"annotations": [{"result": []}],
"predictions": []
},
{
"data": {"image": "https://b.example/y/img.jpg", "width": 10, "height": 10},
"annotations": [{"result": []}],
"predictions": []
}
]"#;
let err = from_label_studio_str(json).expect_err("expected invalid error");
match err {
PanlabelError::LabelStudioJsonInvalid { message, .. } => {
assert!(message.contains("duplicate image basename"));
}
other => panic!("expected LabelStudioJsonInvalid, got {other:?}"),
}
}
#[test]
fn write_then_read_roundtrip_semantic() {
let dataset = from_label_studio_str(sample_json()).expect("parse original");
let json = to_label_studio_string(&dataset).expect("write string");
let restored = from_label_studio_str(&json).expect("parse restored");
assert_eq!(dataset.images.len(), restored.images.len());
assert_eq!(dataset.categories.len(), restored.categories.len());
assert_eq!(dataset.annotations.len(), restored.annotations.len());
for (left, right) in dataset.annotations.iter().zip(restored.annotations.iter()) {
assert!((left.bbox.xmin() - right.bbox.xmin()).abs() < 1e-6);
assert!((left.bbox.ymin() - right.bbox.ymin()).abs() < 1e-6);
assert!((left.bbox.xmax() - right.bbox.xmax()).abs() < 1e-6);
assert!((left.bbox.ymax() - right.bbox.ymax()).abs() < 1e-6);
}
}
#[test]
fn parse_rejects_inconsistent_from_name() {
let json = r#"[
{
"data": {"image": "img.jpg"},
"annotations": [
{
"result": [
{
"type": "rectanglelabels",
"from_name": "bbox_a",
"to_name": "image",
"value": {
"x": 10.0,
"y": 10.0,
"width": 10.0,
"height": 10.0,
"rectanglelabels": ["cat"]
},
"original_width": 100,
"original_height": 100
},
{
"type": "rectanglelabels",
"from_name": "bbox_b",
"to_name": "image",
"value": {
"x": 20.0,
"y": 20.0,
"width": 10.0,
"height": 10.0,
"rectanglelabels": ["cat"]
},
"original_width": 100,
"original_height": 100
}
]
}
]
}
]"#;
let err = from_label_studio_str(json).expect_err("expected inconsistent from_name error");
match err {
PanlabelError::LabelStudioJsonInvalid { message, .. } => {
assert!(message.contains("inconsistent from_name"));
}
other => panic!("expected LabelStudioJsonInvalid, got {other:?}"),
}
}
#[test]
fn writer_rejects_duplicate_output_basenames() {
let dataset = Dataset {
images: vec![
Image::new(1u64, "train/shared.jpg", 100, 100),
Image::new(2u64, "val/shared.jpg", 100, 100),
],
categories: vec![Category::new(1u64, "cat")],
annotations: vec![],
..Default::default()
};
let err = to_label_studio_string(&dataset).expect_err("expected duplicate basename error");
match err {
PanlabelError::LabelStudioJsonInvalid { message, .. } => {
assert!(message.contains("unique basenames are required"));
}
other => panic!("expected LabelStudioJsonInvalid, got {other:?}"),
}
}
}