Skip to main content

document_svg/document/
openlabel.rs

1//! Bounded ASAM OpenLABEL 1.0 JSON annotation previews.
2//!
3//! OpenLABEL describes multi-sensor annotations for objects, frames, actions,
4//! events and relations. This adapter renders collection counts and structural
5//! metadata only; sensor payloads, attribute values, coordinates, image/point
6//! cloud resources and external ontologies are never displayed or fetched.
7
8use serde_json::Value;
9use std::path::Path;
10
11use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
12use crate::document::html::{HtmlBlock, render_blocks_to_pages};
13use crate::error::{Error, Result};
14use crate::table::{TableAlign, TableData};
15
16const MAX_OPENLABEL_BYTES: u64 = 64 * 1024 * 1024;
17const MAX_OPENLABEL_DEPTH: usize = 100;
18const MAX_OPENLABEL_VALUES: usize = 300_000;
19const MAX_OPENLABEL_ROWS: usize = 200_000;
20const MAX_OPENLABEL_ENTRIES: usize = 100_000;
21const MAX_OPENLABEL_STRING_BYTES: usize = 2 * 1024 * 1024;
22const MAX_OPENLABEL_DISPLAY_BYTES: usize = 256;
23
24const COLLECTIONS: &[(&str, &str)] = &[
25    ("objects", "object labels"),
26    ("actions", "action labels"),
27    ("events", "event labels"),
28    ("contexts", "context labels"),
29    ("relations", "relations"),
30    ("frames", "annotated frames"),
31    ("frame_intervals", "frame intervals"),
32    ("tags", "scenario tags"),
33    ("ontologies", "ontology references"),
34    ("resources", "sensor resources"),
35    ("coordinate_systems", "coordinate systems"),
36    ("streams", "sensor streams"),
37];
38
39pub(crate) fn looks_like_prefix(prefix: &[u8]) -> bool {
40    let text = String::from_utf8_lossy(prefix);
41    let trimmed = text.trim_start_matches('\u{feff}').trim_start();
42    if !trimmed.starts_with('{') {
43        return false;
44    }
45    if let Ok(Value::Object(object)) = serde_json::from_str::<Value>(trimmed) {
46        return object.get("openlabel").and_then(Value::as_object).is_some();
47    }
48    text.contains("\"openlabel\"")
49}
50
51struct OpenLabelPageSink<'a> {
52    inner: &'a mut dyn PageConsumer,
53    warnings: &'a [String],
54}
55
56impl PageConsumer for OpenLabelPageSink<'_> {
57    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
58        page.source_format = "openlabel".into();
59        if page.title.is_empty() {
60            page.title = "ASAM OpenLABEL JSON".into();
61        }
62        page.description =
63            "OpenLABEL annotation structure is rendered inertly; sensor payloads, coordinates, attribute values and external resources are not displayed or resolved".into();
64        for warning in self.warnings {
65            page.warn(warning.clone());
66        }
67        self.inner.consume(page)
68    }
69}
70
71pub(crate) fn convert(
72    path: &Path,
73    options: &ConvertOptions,
74    sink: &mut dyn PageConsumer,
75) -> Result<Vec<String>> {
76    let bytes = read_limited_file(
77        path,
78        options.max_input_bytes.min(MAX_OPENLABEL_BYTES),
79        "OpenLABEL JSON input",
80    )?;
81    let text = String::from_utf8(bytes).map_err(|error| {
82        Error::InvalidInput(format!("OpenLABEL JSON must be UTF-8 JSON: {error}"))
83    })?;
84    let (table, metadata, warnings) = parse(&text)?;
85    let blocks = vec![
86        HtmlBlock::Heading {
87            level: 1,
88            text: "ASAM OpenLABEL JSON".into(),
89        },
90        HtmlBlock::Paragraph { text: metadata },
91        HtmlBlock::Table(table),
92    ];
93    let mut page_sink = OpenLabelPageSink {
94        inner: sink,
95        warnings: &warnings,
96    };
97    render_blocks_to_pages(&blocks, &mut page_sink, options)?;
98    Ok(warnings)
99}
100
101fn parse(text: &str) -> Result<(TableData, String, Vec<String>)> {
102    if text.len() as u64 > MAX_OPENLABEL_BYTES {
103        return Err(Error::LimitExceeded(format!(
104            "OpenLABEL JSON exceeds {MAX_OPENLABEL_BYTES} bytes"
105        )));
106    }
107    preflight_depth(text)?;
108    let value: Value = serde_json::from_str(text)
109        .map_err(|error| Error::InvalidInput(format!("invalid OpenLABEL JSON: {error}")))?;
110    let mut values = 0usize;
111    count_values(&value, 0, &mut values)?;
112    let root = value
113        .as_object()
114        .ok_or_else(|| Error::InvalidInput("OpenLABEL JSON root must be an object".into()))?;
115    let openlabel = root
116        .get("openlabel")
117        .and_then(Value::as_object)
118        .ok_or_else(|| Error::InvalidInput("OpenLABEL JSON requires an openlabel object".into()))?;
119    let mut rows = Vec::new();
120    let mut warnings = Vec::new();
121    let mut collection_counts = Vec::new();
122    let mut total_entries = 0usize;
123    let mut object_data = 0usize;
124    let mut frame_object_data = 0usize;
125    for (key, description) in COLLECTIONS {
126        let count = match openlabel.get(*key) {
127            None => 0,
128            Some(value) => {
129                let map = value.as_object().ok_or_else(|| {
130                    Error::InvalidInput(format!("OpenLABEL {key} must be an object map"))
131                })?;
132                if map.len() > MAX_OPENLABEL_ENTRIES {
133                    return Err(Error::LimitExceeded(format!(
134                        "OpenLABEL {key} entries exceed {MAX_OPENLABEL_ENTRIES}"
135                    )));
136                }
137                if *key == "objects" {
138                    object_data = map.values().map(count_object_data).sum();
139                }
140                if *key == "frames" {
141                    frame_object_data = map.values().map(count_frame_object_data).sum();
142                }
143                map.len()
144            }
145        };
146        total_entries = total_entries.saturating_add(count);
147        collection_counts.push((*key, count));
148        if rows.len() >= MAX_OPENLABEL_ROWS {
149            return Err(Error::LimitExceeded(format!(
150                "OpenLABEL rows exceed {MAX_OPENLABEL_ROWS}"
151            )));
152        }
153        rows.push(vec![
154            (*key).into(),
155            count.to_string(),
156            (*description).into(),
157        ]);
158    }
159    if total_entries == 0 && openlabel.get("metadata").is_none() {
160        return Err(Error::InvalidInput(
161            "OpenLABEL openlabel object contains no recognized collections or metadata".into(),
162        ));
163    }
164    let metadata = openlabel.get("metadata").and_then(Value::as_object);
165    if openlabel.contains_key("metadata") && metadata.is_none() {
166        return Err(Error::InvalidInput(
167            "OpenLABEL metadata must be an object".into(),
168        ));
169    }
170    let version = metadata
171        .and_then(|map| map.get("schema_version").or_else(|| map.get("version")))
172        .and_then(Value::as_str)
173        .map_or_else(|| "—".into(), truncate);
174    let metadata_fields = metadata.map_or(0, serde_json::Map::len);
175    let metadata = format!(
176        "Schema version: {version}\nMetadata fields: {metadata_fields}\nCollections: {}\nObject data entries: {object_data}\nFrame object-data entries: {frame_object_data}",
177        collection_counts
178            .iter()
179            .filter(|(_, count)| *count > 0)
180            .count()
181    );
182    warnings.push("OpenLABEL object/attribute values, bounding-box coordinates, sensor frames, image/point-cloud resources, ontology URLs and external references are omitted; no sensor data or network resource is opened".into());
183    warnings.push("OpenLABEL collection maps and nested annotation counts are bounded; labels are summarized without scenario-tag evaluation or tracking execution".into());
184    Ok((
185        TableData {
186            headers: vec!["Collection".into(), "N".into(), "Content".into()],
187            rows,
188            alignments: vec![TableAlign::Left; 3],
189            raw_source: String::new(),
190        },
191        metadata,
192        warnings,
193    ))
194}
195
196fn count_object_data(value: &Value) -> usize {
197    value
198        .as_object()
199        .and_then(|object| object.get("object_data"))
200        .map_or(0, collection_len)
201}
202
203fn count_frame_object_data(value: &Value) -> usize {
204    value
205        .as_object()
206        .and_then(|frame| frame.get("objects"))
207        .map_or(0, collection_len)
208}
209
210fn collection_len(value: &Value) -> usize {
211    match value {
212        Value::Array(values) => values.len(),
213        Value::Object(map) => map.len(),
214        _ => 0,
215    }
216}
217
218fn truncate(value: &str) -> String {
219    if value.len() <= MAX_OPENLABEL_DISPLAY_BYTES {
220        return value.to_owned();
221    }
222    let mut end = MAX_OPENLABEL_DISPLAY_BYTES;
223    while !value.is_char_boundary(end) {
224        end -= 1;
225    }
226    format!("{}…", &value[..end])
227}
228
229fn count_values(value: &Value, depth: usize, count: &mut usize) -> Result<()> {
230    if depth > MAX_OPENLABEL_DEPTH {
231        return Err(Error::LimitExceeded(format!(
232            "OpenLABEL JSON nesting exceeds {MAX_OPENLABEL_DEPTH} levels"
233        )));
234    }
235    *count = count.saturating_add(1);
236    if *count > MAX_OPENLABEL_VALUES {
237        return Err(Error::LimitExceeded(format!(
238            "OpenLABEL JSON contains more than {MAX_OPENLABEL_VALUES} values"
239        )));
240    }
241    match value {
242        Value::Array(values) => {
243            for item in values {
244                count_values(item, depth + 1, count)?;
245            }
246        }
247        Value::Object(map) => {
248            for item in map.values() {
249                count_values(item, depth + 1, count)?;
250            }
251        }
252        Value::String(value) if value.len() > MAX_OPENLABEL_STRING_BYTES => {
253            return Err(Error::LimitExceeded(format!(
254                "OpenLABEL JSON string exceeds {MAX_OPENLABEL_STRING_BYTES} bytes"
255            )));
256        }
257        _ => {}
258    }
259    Ok(())
260}
261
262fn preflight_depth(text: &str) -> Result<()> {
263    let mut depth = 0usize;
264    let mut quoted = false;
265    let mut escaped = false;
266    for byte in text.bytes() {
267        if quoted {
268            if escaped {
269                escaped = false;
270            } else if byte == b'\\' {
271                escaped = true;
272            } else if byte == b'"' {
273                quoted = false;
274            }
275            continue;
276        }
277        match byte {
278            b'"' => quoted = true,
279            b'{' | b'[' => {
280                depth += 1;
281                if depth > MAX_OPENLABEL_DEPTH {
282                    return Err(Error::LimitExceeded(format!(
283                        "OpenLABEL JSON nesting exceeds {MAX_OPENLABEL_DEPTH} levels"
284                    )));
285                }
286            }
287            b'}' | b']' => depth = depth.saturating_sub(1),
288            _ => {}
289        }
290    }
291    Ok(())
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn recognizes_openlabel_root() {
300        assert!(looks_like_prefix(br#"{"openlabel":{"objects":{}}}"#));
301        assert!(!looks_like_prefix(br#"{"objects":{}}"#));
302    }
303
304    #[test]
305    fn summarizes_collections_without_annotation_values() {
306        let (table, metadata, warnings) = parse(
307            r#"{"openlabel":{"metadata":{"schema_version":"1.0.0","comment":"private"},"objects":{"car":{"object_data":[{"type":"bbox","val":"secret"}]}},"frames":{"1":{"objects":{"car":{"object_data":[{"type":"point","val":"private"}]}}}},"relations":{"r1":{"rdf_subject":"car"}},"streams":{"camera":{"uri":"https://private.invalid/cam"}}}}"#,
308        ).unwrap();
309        assert_eq!(table.rows.len(), 12);
310        assert!(metadata.contains("Schema version: 1.0.0"));
311        assert!(metadata.contains("Object data entries: 1"));
312        assert!(
313            !table
314                .rows
315                .iter()
316                .flatten()
317                .any(|value| value.contains("secret") || value.contains("private"))
318        );
319        assert!(
320            warnings
321                .iter()
322                .any(|warning| warning.contains("attribute values"))
323        );
324    }
325}