Skip to content

Commit a0f6db4

Browse files
authored
fix: validate pose keypoint shapes (#60)
* fix: validate pose keypoint shapes * docs: document pose keypoint shape requirements
1 parent d543782 commit a0f6db4

5 files changed

Lines changed: 603 additions & 165 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,10 @@ The app expects newline-delimited JSON with this structure:
177177
{"type":"image","file":"img2.jpg","url":"https://...","width":640,"height":480,"split":"valid","annotations":{"bboxes":[[1,0.3,0.4,0.1,0.2]]}}
178178
```
179179

180+
Pose rows use `[class_id, cx, cy, width, height, keypoints...]`. Dataset records may include
181+
`"kpt_shape":[number_of_keypoints, 2|3]`. Add `kpt_shape` when a 2D keypoint payload length is divisible
182+
by both 2 and 3, such as hand-21 or dog-18, because its dimensions cannot be inferred safely.
183+
180184
---
181185

182186
## Roadmap

src-tauri/src/converter/coco.rs

Lines changed: 109 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ impl CocoConverter {
7979
data: &NDJSONData,
8080
_split: &str,
8181
num_kpts: usize,
82+
kpt_dims: usize,
8283
) -> String {
8384
let class_names = get_class_list(data);
8485
let now = Utc::now();
@@ -177,30 +178,29 @@ impl CocoConverter {
177178
}
178179
}
179180
"pose" => {
180-
for pose in img.get_pose_annotations() {
181+
for pose in img.get_pose_annotations(kpt_dims) {
181182
let x_min = (pose.bbox_x - pose.bbox_w / 2.0) * img.width as f64;
182183
let y_min = (pose.bbox_y - pose.bbox_h / 2.0) * img.height as f64;
183184
let w = pose.bbox_w * img.width as f64;
184185
let h = pose.bbox_h * img.height as f64;
185186

186-
let mut kps: Vec<f64> = Vec::new();
187+
let mut kps = Vec::with_capacity(pose.num_keypoints() * 3);
187188
let mut visible_count = 0;
188-
for (kp_x, kp_y, kp_v) in &pose.keypoints {
189-
let abs_x = kp_x * img.width as f64;
190-
let abs_y = kp_y * img.height as f64;
191-
if *kp_v > 0.0 {
189+
for keypoint in pose.keypoints.chunks_exact(pose.dims) {
190+
let abs_x = keypoint[0] * img.width as f64;
191+
let abs_y = keypoint[1] * img.height as f64;
192+
let visibility = if pose.dims == 3 {
193+
keypoint[2]
194+
} else {
195+
// COCO requires visibility; 2D NDJSON points are labeled and visible.
196+
2.0
197+
};
198+
if visibility > 0.0 {
192199
visible_count += 1;
193200
}
194201
kps.push(abs_x);
195202
kps.push(abs_y);
196-
kps.push(*kp_v);
197-
}
198-
199-
// Pad missing keypoints with 0,0,0 (not labeled)
200-
for _ in pose.keypoints.len()..num_kpts {
201-
kps.push(0.0);
202-
kps.push(0.0);
203-
kps.push(0.0);
203+
kps.push(visibility);
204204
}
205205

206206
coco.annotations.push(CocoAnnotation {
@@ -291,24 +291,18 @@ impl Converter for CocoConverter {
291291
let mut files: HashMap<String, Vec<u8>> = HashMap::new();
292292
let task = &data.metadata.task;
293293

294-
// For pose: compute max keypoint count globally (max of metadata and actual data)
295-
let num_kpts = if task == "pose" {
296-
let meta_kpts = data
297-
.metadata
294+
// convert_ndjson validates pose rows and populates kpt_shape before conversion.
295+
let (num_kpts, kpt_dims) = if task == "pose" {
296+
data.metadata
298297
.kpt_shape
299298
.as_ref()
300-
.and_then(|s| s.first().copied())
301-
.unwrap_or(0) as usize;
302-
let data_kpts = data
303-
.images
304-
.iter()
305-
.flat_map(|img| img.get_pose_annotations())
306-
.map(|p| p.keypoints.len())
307-
.max()
308-
.unwrap_or(0);
309-
meta_kpts.max(data_kpts)
299+
.and_then(|shape| match shape.as_slice() {
300+
[num_keypoints, dims] => Some((*num_keypoints as usize, *dims as usize)),
301+
_ => None,
302+
})
303+
.unwrap_or((0, 3))
310304
} else {
311-
0
305+
(0, 0)
312306
};
313307

314308
let splits = [
@@ -333,7 +327,7 @@ impl Converter for CocoConverter {
333327
}
334328

335329
// Create JSON at {split}/_annotations.coco.json
336-
let coco_json = self.create_coco_json(images, data, split, num_kpts);
330+
let coco_json = self.create_coco_json(images, data, split, num_kpts, kpt_dims);
337331
files.insert(
338332
format!("{}/_annotations.coco.json", split),
339333
coco_json.into_bytes(),
@@ -449,4 +443,89 @@ mod tests {
449443
Some("img1__abcd1234.jpg")
450444
);
451445
}
446+
447+
#[test]
448+
fn pose_conversion_synthesizes_coco_visibility_for_two_dimensional_keypoints() {
449+
let data = NDJSONData {
450+
metadata: DatasetMetadata {
451+
r#type: "dataset".to_string(),
452+
task: "pose".to_string(),
453+
name: "pose".to_string(),
454+
description: String::new(),
455+
bytes: 0,
456+
url: String::new(),
457+
class_names: HashMap::from([("0".to_string(), "object".to_string())]),
458+
kpt_shape: Some(vec![2, 2]),
459+
version: "1".to_string(),
460+
},
461+
images: vec![ImageEntry {
462+
r#type: "image".to_string(),
463+
file: "pose.jpg".to_string(),
464+
output_file: None,
465+
url: String::new(),
466+
width: 100,
467+
height: 200,
468+
split: "train".to_string(),
469+
annotations: Some(json!({
470+
"pose": [[0, 0.5, 0.5, 0.4, 0.6, 0.1, 0.2, 0.3, 0.4]]
471+
})),
472+
}],
473+
};
474+
475+
let files = CocoConverter::new().convert(&data, &HashMap::new());
476+
let coco: serde_json::Value =
477+
serde_json::from_slice(files.get("train/_annotations.coco.json").unwrap()).unwrap();
478+
let annotation = &coco["annotations"][0];
479+
480+
assert_eq!(
481+
annotation["keypoints"],
482+
json!([10.0, 40.0, 2.0, 30.0, 80.0, 2.0])
483+
);
484+
assert_eq!(annotation["num_keypoints"], 2);
485+
assert_eq!(annotation["keypoints"].as_array().unwrap().len(), 6);
486+
}
487+
488+
#[test]
489+
fn pose_conversion_preserves_three_dimensional_visibility() {
490+
let data = NDJSONData {
491+
metadata: DatasetMetadata {
492+
r#type: "dataset".to_string(),
493+
task: "pose".to_string(),
494+
name: "pose".to_string(),
495+
description: String::new(),
496+
bytes: 0,
497+
url: String::new(),
498+
class_names: HashMap::from([("0".to_string(), "object".to_string())]),
499+
kpt_shape: Some(vec![2, 3]),
500+
version: "1".to_string(),
501+
},
502+
images: vec![ImageEntry {
503+
r#type: "image".to_string(),
504+
file: "pose.jpg".to_string(),
505+
output_file: None,
506+
url: String::new(),
507+
width: 100,
508+
height: 200,
509+
split: "train".to_string(),
510+
annotations: Some(json!({
511+
"pose": [[0, 0.5, 0.5, 0.4, 0.6, 0.1, 0.2, 1, 0.3, 0.4, 2]]
512+
})),
513+
}],
514+
};
515+
516+
let files = CocoConverter::new().convert(&data, &HashMap::new());
517+
let coco: serde_json::Value =
518+
serde_json::from_slice(files.get("train/_annotations.coco.json").unwrap()).unwrap();
519+
let annotation = &coco["annotations"][0];
520+
521+
assert_eq!(
522+
annotation["keypoints"],
523+
json!([10.0, 40.0, 1.0, 30.0, 80.0, 2.0])
524+
);
525+
assert_eq!(annotation["num_keypoints"], 2);
526+
assert_eq!(
527+
coco["categories"][0]["keypoints"].as_array().unwrap().len(),
528+
2
529+
);
530+
}
452531
}

0 commit comments

Comments
 (0)