use edgefirst_decoder::{schema::SchemaV2, DecoderBuilder, DetectBox};
use edgefirst_tensor::{Tensor, TensorDyn, TensorMapTrait, TensorMemory, TensorTrait};
const NC: usize = 2;
const NM: usize = 32;
const N: usize = 256;
const FEAT: usize = 4 + NC + NM;
const PH: usize = 160;
const PW: usize = 160;
const IMG: usize = 640;
fn schema_json(normalized: bool) -> String {
format!(
r#"{{
"schema_version": 2,
"nms": "class_agnostic",
"input": {{
"shape": [1, {IMG}, {IMG}, 3],
"dshape": [
{{"batch": 1}},
{{"height": {IMG}}},
{{"width": {IMG}}},
{{"num_features": 3}}
],
"cameraadaptor": "rgb"
}},
"outputs": [
{{
"name": "output0", "type": "detection",
"shape": [1, {FEAT}, {N}],
"dshape": [
{{"batch": 1}},
{{"num_features": {FEAT}}},
{{"num_boxes": {N}}}
],
"decoder": "ultralytics",
"encoding": "direct",
"normalized": {normalized}
}},
{{
"name": "protos", "type": "protos",
"shape": [1, {NM}, {PH}, {PW}],
"dshape": [
{{"batch": 1}},
{{"num_protos": {NM}}},
{{"height": {PH}}},
{{"width": {PW}}}
]
}}
]
}}"#,
)
}
fn build_inputs() -> Vec<TensorDyn> {
let n_targets = 5usize;
let target_start = 10usize;
let mut det_data = vec![0.0f32; FEAT * N];
let set = |d: &mut [f32], r: usize, c: usize, v: f32| d[r * N + c] = v;
for t in 0..n_targets {
let anchor = target_start + t;
let xc = 80.0 + 80.0 * t as f32; set(&mut det_data, 0, anchor, xc);
set(&mut det_data, 1, anchor, IMG as f32 * 0.5); set(&mut det_data, 2, anchor, 25.6); set(&mut det_data, 3, anchor, 192.0); set(&mut det_data, 4, anchor, 0.9); }
let det_tensor: TensorDyn = {
let t = Tensor::<f32>::new(&[1, FEAT, N], Some(TensorMemory::Mem), None).unwrap();
{
let mut m = t.map().unwrap();
m.as_mut_slice().copy_from_slice(&det_data);
}
TensorDyn::F32(t)
};
let proto_data = vec![0.0f32; NM * PH * PW];
let protos_tensor: TensorDyn = {
let t = Tensor::<f32>::new(&[1, NM, PH, PW], Some(TensorMemory::Mem), None).unwrap();
{
let mut m = t.map().unwrap();
m.as_mut_slice().copy_from_slice(&proto_data);
}
TensorDyn::F32(t)
};
vec![det_tensor, protos_tensor]
}
#[test]
fn pixel_space_input_with_normalized_false_decodes() {
let schema: SchemaV2 = serde_json::from_str(&schema_json(false)).unwrap();
let decoder = DecoderBuilder::default()
.with_score_threshold(0.5)
.with_iou_threshold(0.99)
.with_schema(schema)
.build()
.expect("schema-driven decoder must build");
assert_eq!(
decoder.normalized_boxes(),
Some(true),
"YoloSegDet: accessor must upgrade Some(false)+input_dims to Some(true) \
now that every entry point calls maybe_normalize_boxes_in_place uniformly",
);
assert_eq!(
decoder.input_dims(),
Some((IMG, IMG)),
"decoder must capture the schema's model input dimensions",
);
let owned = build_inputs();
let inputs: Vec<&TensorDyn> = owned.iter().collect();
let mut boxes: Vec<DetectBox> = Vec::with_capacity(50);
let mut masks: Vec<edgefirst_decoder::Segmentation> = Vec::with_capacity(50);
decoder
.decode(&inputs, &mut boxes, &mut masks)
.expect("pixel-space decode must succeed when normalized=false (EDGEAI-1303)");
assert!(
!boxes.is_empty(),
"expected detections to survive NMS; got 0",
);
for b in &boxes {
assert!(
(0.0..=1.0).contains(&b.bbox.xmin)
&& (0.0..=1.0).contains(&b.bbox.ymin)
&& (0.0..=1.0).contains(&b.bbox.xmax)
&& (0.0..=1.0).contains(&b.bbox.ymax),
"decoded bbox {:?} not in normalized [0, 1]; \
EDGEAI-1303 normalization did not run",
b.bbox,
);
}
}
#[test]
fn pixel_space_input_with_normalized_true_still_rejects() {
let schema: SchemaV2 = serde_json::from_str(&schema_json(true)).unwrap();
let decoder = DecoderBuilder::default()
.with_score_threshold(0.5)
.with_iou_threshold(0.99)
.with_schema(schema)
.build()
.expect("schema-driven decoder must build");
let owned = build_inputs();
let inputs: Vec<&TensorDyn> = owned.iter().collect();
let mut boxes: Vec<DetectBox> = Vec::with_capacity(50);
let mut masks: Vec<edgefirst_decoder::Segmentation> = Vec::with_capacity(50);
let err = decoder
.decode(&inputs, &mut boxes, &mut masks)
.expect_err("incorrectly-declared normalized=true must trip protobox guard");
let msg = err.to_string();
assert!(
msg.contains("un-normalized"),
"expected protobox un-normalized rejection; got: {msg}",
);
}
fn split_schema_json(normalized: bool) -> String {
format!(
r#"{{
"schema_version": 2,
"nms": "class_agnostic",
"input": {{
"shape": [1, {IMG}, {IMG}, 3],
"dshape": [
{{"batch": 1}},
{{"height": {IMG}}},
{{"width": {IMG}}},
{{"num_features": 3}}
],
"cameraadaptor": "rgb"
}},
"outputs": [
{{
"name": "boxes", "type": "boxes",
"shape": [1, 4, {N}],
"dshape": [
{{"batch": 1}},
{{"box_coords": 4}},
{{"num_boxes": {N}}}
],
"decoder": "ultralytics",
"encoding": "direct",
"normalized": {normalized}
}},
{{
"name": "scores", "type": "scores",
"shape": [1, {NC}, {N}],
"dshape": [
{{"batch": 1}},
{{"num_classes": {NC}}},
{{"num_boxes": {N}}}
],
"decoder": "ultralytics",
"score_format": "per_class"
}},
{{
"name": "mask_coeff", "type": "mask_coefs",
"shape": [1, {NM}, {N}],
"dshape": [
{{"batch": 1}},
{{"num_protos": {NM}}},
{{"num_boxes": {N}}}
]
}},
{{
"name": "protos", "type": "protos",
"shape": [1, {NM}, {PH}, {PW}],
"dshape": [
{{"batch": 1}},
{{"num_protos": {NM}}},
{{"height": {PH}}},
{{"width": {PW}}}
]
}}
]
}}"#,
)
}
fn build_split_inputs() -> Vec<TensorDyn> {
let n_targets = 5usize;
let target_start = 10usize;
let mut box_data = vec![0.0f32; 4 * N];
let mut score_data = vec![0.0f32; NC * N];
let mask_data = vec![0.0f32; NM * N];
let set = |d: &mut [f32], r: usize, c: usize, stride: usize, v: f32| d[r * stride + c] = v;
for t in 0..n_targets {
let anchor = target_start + t;
let xc = 80.0 + 80.0 * t as f32;
set(&mut box_data, 0, anchor, N, xc);
set(&mut box_data, 1, anchor, N, IMG as f32 * 0.5);
set(&mut box_data, 2, anchor, N, 25.6);
set(&mut box_data, 3, anchor, N, 192.0);
set(&mut score_data, 0, anchor, N, 0.9);
}
let to_dyn = |data: Vec<f32>, shape: &[usize]| -> TensorDyn {
let t = Tensor::<f32>::new(shape, Some(TensorMemory::Mem), None).unwrap();
{
let mut m = t.map().unwrap();
m.as_mut_slice().copy_from_slice(&data);
}
TensorDyn::F32(t)
};
vec![
to_dyn(box_data, &[1, 4, N]),
to_dyn(score_data, &[1, NC, N]),
to_dyn(mask_data, &[1, NM, N]),
to_dyn(vec![0.0f32; NM * PH * PW], &[1, NM, PH, PW]),
]
}
#[test]
fn split_schema_pixel_space_with_normalized_false_decodes() {
let schema: SchemaV2 = serde_json::from_str(&split_schema_json(false)).unwrap();
let decoder = DecoderBuilder::default()
.with_score_threshold(0.5)
.with_iou_threshold(0.99)
.with_schema(schema)
.build()
.expect("split schema must build");
assert_eq!(decoder.normalized_boxes(), Some(true));
assert_eq!(decoder.input_dims(), Some((IMG, IMG)));
let owned = build_split_inputs();
let inputs: Vec<&TensorDyn> = owned.iter().collect();
let mut boxes: Vec<DetectBox> = Vec::with_capacity(50);
let mut masks: Vec<edgefirst_decoder::Segmentation> = Vec::with_capacity(50);
decoder
.decode(&inputs, &mut boxes, &mut masks)
.expect("split-schema pixel-space decode must succeed (EDGEAI-1303)");
assert!(!boxes.is_empty(), "expected detections to survive NMS");
for b in &boxes {
assert!(
(0.0..=1.0).contains(&b.bbox.xmin)
&& (0.0..=1.0).contains(&b.bbox.ymin)
&& (0.0..=1.0).contains(&b.bbox.xmax)
&& (0.0..=1.0).contains(&b.bbox.ymax),
"split-schema bbox {:?} not in [0, 1]; EDGEAI-1303 normalization \
did not run on the SplitSegDet path",
b.bbox,
);
}
}
#[test]
fn normalized_false_without_input_dims_reports_false() {
use edgefirst_decoder::{configs, ConfigOutput};
let det = configs::Detection {
anchors: None,
decoder: configs::DecoderType::Ultralytics,
quantization: None,
shape: vec![1, FEAT, N],
dshape: Vec::new(),
normalized: Some(false),
};
let decoder = DecoderBuilder::default()
.add_output(ConfigOutput::Detection(det))
.build()
.expect("programmatic decoder must build");
assert_eq!(
decoder.input_dims(),
None,
"programmatic build must leave input_dims unset",
);
assert_eq!(
decoder.normalized_boxes(),
Some(false),
"non-per-scale: accessor returns raw schema flag; \
input_dims absent so the helper cannot run either",
);
}
#[test]
fn normalized_none_reports_none() {
use edgefirst_decoder::{configs, ConfigOutput};
let det = configs::Detection {
anchors: None,
decoder: configs::DecoderType::Ultralytics,
quantization: None,
shape: vec![1, FEAT, N],
dshape: Vec::new(),
normalized: None,
};
let decoder = DecoderBuilder::default()
.add_output(ConfigOutput::Detection(det))
.build()
.expect("programmatic decoder must build");
assert_eq!(
decoder.normalized_boxes(),
None,
"non-per-scale: absent normalized field must propagate as None",
);
}
#[test]
fn normalized_true_reports_true() {
use edgefirst_decoder::{configs, ConfigOutput};
let det = configs::Detection {
anchors: None,
decoder: configs::DecoderType::Ultralytics,
quantization: None,
shape: vec![1, FEAT, N],
dshape: Vec::new(),
normalized: Some(true),
};
let decoder = DecoderBuilder::default()
.add_output(ConfigOutput::Detection(det))
.build()
.expect("programmatic decoder must build");
assert_eq!(
decoder.normalized_boxes(),
Some(true),
"non-per-scale: normalized=true schema flag must propagate as Some(true)",
);
}
#[cfg(feature = "tracker")]
#[test]
fn yolo_segdet_tracker_path_normalizes() {
use edgefirst_tracker::ByteTrackBuilder;
let schema: SchemaV2 = serde_json::from_str(&schema_json(false)).unwrap();
let decoder = DecoderBuilder::default()
.with_score_threshold(0.5)
.with_iou_threshold(0.99)
.with_schema(schema)
.build()
.expect("schema-driven decoder must build");
assert_eq!(
decoder.normalized_boxes(),
Some(true),
"YoloSegDet tracker path: accessor must report Some(true) after EDGEAI-1303 \
was extended to cover decode_tracked in e18873d",
);
let owned = build_inputs();
let inputs: Vec<&TensorDyn> = owned.iter().collect();
let mut tracker = ByteTrackBuilder::new()
.track_update(0.1)
.track_high_conf(0.3)
.build();
let mut output_boxes: Vec<DetectBox> = Vec::with_capacity(50);
let mut output_masks: Vec<edgefirst_decoder::Segmentation> = Vec::with_capacity(50);
let mut output_tracks: Vec<edgefirst_decoder::TrackInfo> = Vec::with_capacity(50);
decoder
.decode_tracked(
&mut tracker,
0,
&inputs,
&mut output_boxes,
&mut output_masks,
&mut output_tracks,
)
.expect("YoloSegDet decode_tracked must succeed with pixel-space input");
assert!(
!output_boxes.is_empty(),
"expected detections to survive NMS on the tracker path",
);
for b in &output_boxes {
assert!(
(0.0..=1.0).contains(&b.bbox.xmin)
&& (0.0..=1.0).contains(&b.bbox.ymin)
&& (0.0..=1.0).contains(&b.bbox.xmax)
&& (0.0..=1.0).contains(&b.bbox.ymax),
"tracker-path bbox {:?} not in [0, 1]; maybe_normalize_boxes_in_place \
did not fire in decode_tracked — e18873d regression",
b.bbox,
);
}
}
#[test]
fn detection_only_normalized_false_with_input_dims_reports_false_raw() {
use edgefirst_decoder::{configs, ConfigOutput};
let schema_str = format!(
r#"{{
"schema_version": 2,
"nms": "class_agnostic",
"input": {{
"shape": [1, {IMG}, {IMG}, 3],
"dshape": [
{{"batch": 1}},
{{"height": {IMG}}},
{{"width": {IMG}}},
{{"num_features": 3}}
],
"cameraadaptor": "rgb"
}},
"outputs": [
{{
"name": "output0", "type": "detection",
"shape": [1, {feat}, {N}],
"dshape": [
{{"batch": 1}},
{{"num_features": {feat}}},
{{"num_boxes": {N}}}
],
"decoder": "ultralytics",
"encoding": "direct",
"normalized": false
}}
]
}}"#,
feat = 4 + NC,
);
let schema: SchemaV2 = serde_json::from_str(&schema_str).unwrap();
let decoder = DecoderBuilder::default()
.with_score_threshold(0.5)
.with_iou_threshold(0.99)
.with_schema(schema)
.build()
.expect("detection-only schema must build");
assert_eq!(
decoder.input_dims(),
Some((IMG, IMG)),
"v2 schema with input spec must populate input_dims",
);
assert_eq!(
decoder.normalized_boxes(),
Some(false),
"YoloDet: accessor must return raw schema flag — detection-only paths \
are not in the uniform-normalization upgrade list (fa8e919)",
);
let det_flat = configs::Detection {
anchors: None,
decoder: configs::DecoderType::Ultralytics,
quantization: None,
shape: vec![1, FEAT, N],
dshape: Vec::new(),
normalized: Some(false),
};
let decoder_flat = DecoderBuilder::default()
.add_output(ConfigOutput::Detection(det_flat))
.build()
.expect("flat YoloDet must build");
assert_eq!(
decoder_flat.input_dims(),
None,
"flat programmatic build leaves input_dims unset",
);
assert_eq!(
decoder_flat.normalized_boxes(),
Some(false),
"YoloDet flat: raw flag propagated regardless of input_dims",
);
}
#[cfg(feature = "tracker")]
#[test]
fn yolo_split_segdet_tracker_path_normalizes() {
use edgefirst_tracker::ByteTrackBuilder;
let schema: SchemaV2 = serde_json::from_str(&split_schema_json(false)).unwrap();
let decoder = DecoderBuilder::default()
.with_score_threshold(0.5)
.with_iou_threshold(0.99)
.with_schema(schema)
.build()
.expect("split schema must build");
assert_eq!(
decoder.normalized_boxes(),
Some(true),
"YoloSplitSegDet tracker path: accessor must report Some(true) after fa8e919 \
extended uniform normalization to the tracker macro path",
);
let owned = build_split_inputs();
let inputs: Vec<&TensorDyn> = owned.iter().collect();
let mut tracker = ByteTrackBuilder::new()
.track_update(0.1)
.track_high_conf(0.3)
.build();
let mut output_boxes: Vec<DetectBox> = Vec::with_capacity(50);
let mut output_masks: Vec<edgefirst_decoder::Segmentation> = Vec::with_capacity(50);
let mut output_tracks: Vec<edgefirst_decoder::TrackInfo> = Vec::with_capacity(50);
decoder
.decode_tracked(
&mut tracker,
0,
&inputs,
&mut output_boxes,
&mut output_masks,
&mut output_tracks,
)
.expect("YoloSplitSegDet decode_tracked must succeed with pixel-space input");
assert!(
!output_boxes.is_empty(),
"expected detections to survive NMS on the YoloSplitSegDet tracker path",
);
for b in &output_boxes {
assert!(
(0.0..=1.0).contains(&b.bbox.xmin)
&& (0.0..=1.0).contains(&b.bbox.ymin)
&& (0.0..=1.0).contains(&b.bbox.xmax)
&& (0.0..=1.0).contains(&b.bbox.ymax),
"tracker-path bbox {:?} not in [0, 1]; maybe_normalize_boxes_in_place \
did not fire in YoloSplitSegDet decode_tracked — fa8e919 regression",
b.bbox,
);
}
}
#[test]
fn yolo_segdet_2way_decode_normalizes() {
let det_feat = 4 + NC;
let schema_str = format!(
r#"{{
"schema_version": 2,
"nms": "class_agnostic",
"input": {{
"shape": [1, {IMG}, {IMG}, 3],
"dshape": [
{{"batch": 1}},
{{"height": {IMG}}},
{{"width": {IMG}}},
{{"num_features": 3}}
],
"cameraadaptor": "rgb"
}},
"outputs": [
{{
"name": "output0", "type": "detection",
"shape": [1, {det_feat}, {N}],
"dshape": [
{{"batch": 1}},
{{"num_features": {det_feat}}},
{{"num_boxes": {N}}}
],
"decoder": "ultralytics",
"encoding": "direct",
"normalized": false
}},
{{
"name": "mask_coeff", "type": "mask_coefs",
"shape": [1, {NM}, {N}],
"dshape": [
{{"batch": 1}},
{{"num_protos": {NM}}},
{{"num_boxes": {N}}}
]
}},
{{
"name": "protos", "type": "protos",
"shape": [1, {PH}, {PW}, {NM}],
"dshape": [
{{"batch": 1}},
{{"height": {PH}}},
{{"width": {PW}}},
{{"num_protos": {NM}}}
]
}}
]
}}"#,
);
let schema: SchemaV2 = serde_json::from_str(&schema_str).unwrap();
let decoder = DecoderBuilder::default()
.with_score_threshold(0.5)
.with_iou_threshold(0.99)
.with_schema(schema)
.build()
.expect("YoloSegDet2Way schema must build");
assert_eq!(
decoder.normalized_boxes(),
Some(true),
"YoloSegDet2Way: accessor must report Some(true) when normalized=false \
and input_dims is present (fa8e919 uniform normalization)",
);
assert_eq!(
decoder.input_dims(),
Some((IMG, IMG)),
"v2 schema must populate input_dims from the input spec",
);
let n_targets = 5usize;
let target_start = 10usize;
let mut det_data = vec![0.0f32; det_feat * N];
let set = |d: &mut [f32], r: usize, c: usize, v: f32| d[r * N + c] = v;
for t in 0..n_targets {
let anchor = target_start + t;
let xc = 80.0 + 80.0 * t as f32; set(&mut det_data, 0, anchor, xc);
set(&mut det_data, 1, anchor, IMG as f32 * 0.5); set(&mut det_data, 2, anchor, 25.6); set(&mut det_data, 3, anchor, 192.0); set(&mut det_data, 4, anchor, 0.9); }
let to_dyn = |data: Vec<f32>, shape: &[usize]| -> TensorDyn {
let t = Tensor::<f32>::new(shape, Some(TensorMemory::Mem), None).unwrap();
{
let mut m = t.map().unwrap();
m.as_mut_slice().copy_from_slice(&data);
}
TensorDyn::F32(t)
};
let owned = [
to_dyn(det_data, &[1, det_feat, N]),
to_dyn(vec![0.0f32; NM * N], &[1, NM, N]),
to_dyn(vec![0.0f32; NM * PH * PW], &[1, PH, PW, NM]),
];
let inputs: Vec<&TensorDyn> = owned.iter().collect();
let mut boxes: Vec<DetectBox> = Vec::with_capacity(50);
let mut masks: Vec<edgefirst_decoder::Segmentation> = Vec::with_capacity(50);
decoder
.decode(&inputs, &mut boxes, &mut masks)
.expect("YoloSegDet2Way pixel-space decode must succeed (fa8e919)");
assert!(
!boxes.is_empty(),
"expected detections to survive NMS on YoloSegDet2Way path",
);
for b in &boxes {
assert!(
(0.0..=1.0).contains(&b.bbox.xmin)
&& (0.0..=1.0).contains(&b.bbox.ymin)
&& (0.0..=1.0).contains(&b.bbox.xmax)
&& (0.0..=1.0).contains(&b.bbox.ymax),
"YoloSegDet2Way bbox {:?} not in [0, 1]; maybe_normalize_boxes_in_place \
did not fire — fa8e919 regression",
b.bbox,
);
}
}