Skip to content

Commit 14ac2c8

Browse files
authored
fix(yolo): emit accurate dataset YAML (#68)
1 parent 88c8c4d commit 14ac2c8

1 file changed

Lines changed: 125 additions & 7 deletions

File tree

src-tauri/src/converter/yolo.rs

Lines changed: 125 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,25 @@ pub struct YoloConverter {
66
darknet: bool,
77
}
88

9+
struct SplitPresence {
10+
train: bool,
11+
val: bool,
12+
test: bool,
13+
}
14+
15+
impl SplitPresence {
16+
fn from_data(data: &NDJSONData) -> Self {
17+
Self {
18+
train: data.images.iter().any(|img| img.split == "train"),
19+
val: data
20+
.images
21+
.iter()
22+
.any(|img| img.split == "valid" || img.split == "val"),
23+
test: data.images.iter().any(|img| img.split == "test"),
24+
}
25+
}
26+
}
27+
928
fn quote_yaml_scalar(value: &str) -> String {
1029
serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string())
1130
}
@@ -40,15 +59,26 @@ impl YoloConverter {
4059
fn create_data_yaml(&self, data: &NDJSONData) -> String {
4160
let class_names = get_class_list(data);
4261
let task = &data.metadata.task;
62+
// Split presence reflects data.images after download filtering when images are requested.
63+
let splits = SplitPresence::from_data(data);
4364

4465
let mut yaml = String::new();
4566
yaml.push_str("# Generated by YOLO NDJSON Converter\n");
4667
yaml.push_str(&format!("# Version: {}\n", env!("CARGO_PKG_VERSION")));
4768
yaml.push_str("# https://github.com/amanharshx/YOLO-Ndjson-Zip\n\n");
4869
yaml.push_str("path: .\n");
49-
yaml.push_str("train: train/images\n");
50-
yaml.push_str("val: valid/images\n");
51-
yaml.push_str("test: test/images\n");
70+
if splits.train {
71+
yaml.push_str("train: train/images\n");
72+
}
73+
if splits.val {
74+
yaml.push_str("val: valid/images\n");
75+
}
76+
if splits.test {
77+
yaml.push_str("test: test/images\n");
78+
}
79+
if !splits.train || !splits.val {
80+
yaml.push_str("# WARNING: Ultralytics training requires both train and val splits.\n");
81+
}
5282
yaml.push_str(&format!("nc: {}\n", class_names.len()));
5383
yaml.push_str("names:\n");
5484

@@ -155,10 +185,12 @@ impl Converter for YoloConverter {
155185
);
156186
} else {
157187
// Standard YOLO mode
158-
files.insert(
159-
"data.yaml".to_string(),
160-
self.create_data_yaml(data).into_bytes(),
161-
);
188+
if task != "classify" {
189+
files.insert(
190+
"data.yaml".to_string(),
191+
self.create_data_yaml(data).into_bytes(),
192+
);
193+
}
162194
let class_list = get_class_list(data);
163195
files.insert(
164196
"classes.txt".to_string(),
@@ -283,6 +315,92 @@ mod tests {
283315
}
284316
}
285317

318+
fn image_with_split(split: &str) -> ImageEntry {
319+
ImageEntry {
320+
r#type: "image".to_string(),
321+
file: format!("{}.jpg", split),
322+
output_file: None,
323+
url: String::new(),
324+
width: 640,
325+
height: 480,
326+
split: split.to_string(),
327+
annotations: Some(json!({
328+
"bboxes": [[0, 0.5, 0.5, 0.2, 0.2]]
329+
})),
330+
}
331+
}
332+
333+
#[test]
334+
fn data_yaml_emits_only_present_splits() {
335+
let cases = [
336+
(&["train"][..], [true, false, false], true),
337+
(&["train", "val"][..], [true, true, false], false),
338+
(&["train", "valid", "test"][..], [true, true, true], false),
339+
(&["train", "test"][..], [true, false, true], true),
340+
(&["val", "test"][..], [false, true, true], true),
341+
];
342+
343+
for (source_splits, expected, warns) in cases {
344+
let images = source_splits
345+
.iter()
346+
.map(|split| image_with_split(split))
347+
.collect();
348+
let data = make_data("detect", HashMap::new(), None, images);
349+
let files = YoloConverter::new().convert(&data, &HashMap::new());
350+
let yaml = std::str::from_utf8(files.get("data.yaml").unwrap()).unwrap();
351+
352+
let has_line = |line| yaml.lines().any(|candidate| candidate == line);
353+
assert_eq!(
354+
[
355+
has_line("train: train/images"),
356+
has_line("val: valid/images"),
357+
has_line("test: test/images"),
358+
],
359+
expected,
360+
"unexpected splits in:\n{yaml}"
361+
);
362+
assert_eq!(yaml.contains("# WARNING:"), warns);
363+
}
364+
}
365+
366+
#[test]
367+
fn data_yaml_omits_split_whose_images_all_failed() {
368+
let mut data = make_data(
369+
"detect",
370+
HashMap::new(),
371+
None,
372+
vec![image_with_split("train"), image_with_split("val")],
373+
);
374+
data.images.retain(|img| img.split == "train");
375+
376+
let files = YoloConverter::new().convert(&data, &HashMap::new());
377+
let yaml = std::str::from_utf8(files.get("data.yaml").unwrap()).unwrap();
378+
379+
assert!(yaml.contains("train: train/images"));
380+
assert!(!yaml.contains("val: valid/images"));
381+
assert!(!files.contains_key("valid/labels/val.txt"));
382+
}
383+
384+
#[test]
385+
fn classification_conversion_omits_yaml_metadata() {
386+
let data = make_data(
387+
"classify",
388+
HashMap::from([("0".to_string(), "animal".to_string())]),
389+
None,
390+
vec![ImageEntry {
391+
annotations: Some(json!({ "classification": [0] })),
392+
..image_with_split("train")
393+
}],
394+
);
395+
let downloaded_images =
396+
HashMap::from([(image_download_key("train", "train.jpg"), vec![1, 2, 3])]);
397+
398+
let files = YoloConverter::new().convert(&data, &downloaded_images);
399+
400+
assert!(files.contains_key("train/animal/train.jpg"));
401+
assert!(files.keys().all(|path| !path.ends_with(".yaml")));
402+
}
403+
286404
#[test]
287405
fn create_data_yaml_quotes_class_names() {
288406
let mut class_names = HashMap::new();

0 commit comments

Comments
 (0)