Skip to content

Commit 9537e71

Browse files
authored
feat: ✨ Add class filtering support to inference and CLI arguments (#72)
Signed-off-by: Onuralp SEZER <onuralp@ultralytics.com>
1 parent e3a2dc9 commit 9537e71

6 files changed

Lines changed: 157 additions & 6 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,7 @@ ONNX Runtime threading is set to auto (`num_threads: 0`) which lets ORT choose o
401401
- [x] FP16 half-precision inference
402402
- [x] Batch inference support
403403
- [x] Rectangular inference support and optimization
404+
- [x] Class filtering support
404405

405406
### In Progress
406407

src/cli/args.rs

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ use clap::{Args, Parser, Subcommand};
2222
--show Display results in a window
2323
--device <DEVICE> Device (cpu, cuda:0, mps, coreml, directml:0, openvino, tensorrt:0, xnnpack)
2424
--verbose Show verbose output [default: true]
25+
--classes <CLASSES> Filter by class IDs (e.g., "0", "0,1,2", "[0, 1]")
2526
2627
Examples:
2728
ultralytics-inference predict
@@ -31,7 +32,7 @@ Examples:
3132
ultralytics-inference predict --source 0 --conf 0.5 --show
3233
ultralytics-inference predict --source assets/ --save --half
3334
ultralytics-inference predict --source image.jpg --device cuda:0
34-
ultralytics-inference predict --source image.jpg --device mps"#)]
35+
ultralytics-inference predict --source image.jpg --classes 0"#)]
3536
pub struct Cli {
3637
#[command(subcommand)]
3738
/// Subcommand to execute.
@@ -104,6 +105,43 @@ pub struct PredictArgs {
104105
/// Show verbose output
105106
#[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
106107
pub verbose: bool,
108+
109+
/// Filter by class IDs (e.g. 0 or "0,1,2" or "[0, 1, 2]")
110+
///
111+
/// Supported formats:
112+
/// - Single integer: --classes 0
113+
/// - Comma-separated list: --classes "0,1,2"
114+
/// - List syntax: --classes "[0, 1, 2]"
115+
///
116+
/// Note: When passing a list directly without quotes, avoid spaces to prevent
117+
/// shell argument parsing issues (e.g. use --classes 0,1 not --classes 0, 1).
118+
#[arg(long, allow_hyphen_values = true)]
119+
pub classes: Option<String>,
120+
}
121+
122+
/// Parse class IDs from various formats: "1,2,3", "[1,2,3]", "(1,2,3)"
123+
///
124+
/// # Errors
125+
///
126+
/// Returns a `String` error message if any segment of the string cannot be parsed as a `usize`.
127+
pub fn parse_classes(s: &str) -> Result<Vec<usize>, String> {
128+
// Remove brackets, parentheses, and quotes
129+
let cleaned = s
130+
.trim()
131+
.trim_matches(|c| c == '[' || c == ']' || c == '(' || c == ')' || c == '"' || c == '\'');
132+
133+
if cleaned.is_empty() {
134+
return Ok(Vec::new());
135+
}
136+
137+
cleaned
138+
.split(',')
139+
.map(|part| {
140+
part.trim()
141+
.parse::<usize>()
142+
.map_err(|e| format!("Invalid class ID '{}': {}", part.trim(), e))
143+
})
144+
.collect()
107145
}
108146

109147
#[cfg(test)]

src/cli/predict.rs

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,21 @@ pub fn run_prediction(args: &PredictArgs) {
8484
config = config.with_device(d.clone());
8585
}
8686

87+
// Apply class filter if specified
88+
if let Some(classes_str) = &args.classes {
89+
match crate::cli::args::parse_classes(classes_str) {
90+
Ok(classes) => {
91+
if !classes.is_empty() {
92+
config = config.with_classes(classes);
93+
}
94+
}
95+
Err(e) => {
96+
error!("Error parsing classes: {e}");
97+
process::exit(1);
98+
}
99+
}
100+
}
101+
87102
let mut model = match YOLOModel::load_with_config(model_path, config) {
88103
Ok(m) => m,
89104
Err(e) => {
@@ -423,7 +438,7 @@ fn format_class_counts(
423438
/// Format detection summary like "4 persons, 1 bus".
424439
#[allow(clippy::option_if_let_else)]
425440
fn format_detection_summary(result: &Results) -> String {
426-
if let Some(ref boxes) = result.boxes {
441+
let summary = if let Some(ref boxes) = result.boxes {
427442
format_class_counts(&boxes.cls(), boxes.len(), &result.names)
428443
} else if let Some(ref obb) = result.obb {
429444
format_class_counts(&obb.cls(), obb.len(), &result.names)
@@ -439,6 +454,12 @@ fn format_detection_summary(result: &Results) -> String {
439454
parts.join(", ")
440455
} else {
441456
String::new()
457+
};
458+
459+
if summary.is_empty() {
460+
"(no detections)".to_string()
461+
} else {
462+
summary
442463
}
443464
}
444465

@@ -527,7 +548,7 @@ mod tests {
527548
result.boxes = Some(boxes);
528549

529550
let summary = format_detection_summary(&result);
530-
assert!(summary.is_empty());
551+
assert_eq!(summary, "(no detections)");
531552
}
532553

533554
/// Test `format_detection_summary` with OBB detections.
@@ -573,7 +594,7 @@ mod tests {
573594
result.obb = Some(obb);
574595

575596
let summary = format_detection_summary(&result);
576-
assert!(summary.is_empty());
597+
assert_eq!(summary, "(no detections)");
577598
}
578599

579600
/// Test `format_detection_summary` with classification probs.
@@ -616,7 +637,7 @@ mod tests {
616637
);
617638

618639
let summary = format_detection_summary(&result);
619-
assert!(summary.is_empty());
640+
assert_eq!(summary, "(no detections)");
620641
}
621642

622643
/// Test `format_detection_summary` with unknown class (uses "object" fallback).

src/inference.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ pub struct InferenceConfig {
6969
/// Whether to use minimal padding (rectangular inference).
7070
/// Defaults to `true` to match Ultralytics Python.
7171
pub rect: bool,
72+
/// Class IDs to filter predictions. If `None`, all classes are returned.
73+
/// Useful for focusing on specific objects in multi-class detection tasks.
74+
pub classes: Option<Vec<usize>>,
7275
}
7376

7477
impl Default for InferenceConfig {
@@ -85,6 +88,7 @@ impl Default for InferenceConfig {
8588
save: Self::DEFAULT_SAVE,
8689
save_frames: Self::DEFAULT_SAVE_FRAMES,
8790
rect: Self::DEFAULT_RECT,
91+
classes: None,
8892
}
8993
}
9094
}
@@ -302,6 +306,47 @@ impl InferenceConfig {
302306
self.rect = rect;
303307
self
304308
}
309+
310+
/// Set the class IDs to filter predictions.
311+
///
312+
/// Only detections belonging to the specified classes will be returned.
313+
///
314+
/// # Arguments
315+
///
316+
/// * `classes` - A vector of class IDs to keep.
317+
///
318+
/// # Example
319+
///
320+
/// ```rust
321+
/// use ultralytics_inference::InferenceConfig;
322+
///
323+
/// // Only detect persons (class 0) and cars (class 2)
324+
/// let config = InferenceConfig::new()
325+
/// .with_classes(vec![0, 2]);
326+
/// ```
327+
///
328+
/// # Returns
329+
///
330+
/// * The modified `InferenceConfig`.
331+
#[must_use]
332+
pub fn with_classes(mut self, classes: Vec<usize>) -> Self {
333+
self.classes = Some(classes);
334+
self
335+
}
336+
/// Check if a class should be included in the results.
337+
///
338+
/// # Arguments
339+
///
340+
/// * `class_id` - The class index to check.
341+
///
342+
/// # Returns
343+
///
344+
/// * `true` if the class should be kept.
345+
/// * `false` if the class should be filtered out.
346+
#[must_use]
347+
pub fn keep_class(&self, class_id: usize) -> bool {
348+
self.classes.as_ref().is_none_or(|c| c.contains(&class_id))
349+
}
305350
}
306351

307352
#[cfg(test)]
@@ -331,4 +376,22 @@ mod tests {
331376
assert_eq!(config.imgsz, Some((640, 640)));
332377
assert_eq!(config.num_threads, 8);
333378
}
379+
380+
#[test]
381+
fn test_keep_class() {
382+
let config = InferenceConfig::default();
383+
// Default: no filtering -> keep all
384+
assert!(config.keep_class(0));
385+
assert!(config.keep_class(100));
386+
387+
let config_filtered = InferenceConfig::new().with_classes(vec![1, 3]);
388+
// Class 1 is in list -> keep
389+
assert!(config_filtered.keep_class(1));
390+
// Class 3 is in list -> keep
391+
assert!(config_filtered.keep_class(3));
392+
// Class 0 is NOT in list -> filter out (keep = false)
393+
assert!(!config_filtered.keep_class(0));
394+
// Class 2 is NOT in list -> filter out (keep = false)
395+
assert!(!config_filtered.keep_class(2));
396+
}
334397
}

src/postprocessing.rs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,13 @@ fn extract_detect_boxes(
290290

291291
for (idx, &score) in max_scores.iter().enumerate() {
292292
if score > conf_thresh {
293+
let best_class = max_classes[idx];
294+
295+
// Filter by class if specified
296+
if !config.keep_class(best_class) {
297+
continue;
298+
}
299+
293300
let cx = unsafe { *output.get_unchecked(idx) };
294301
let cy = unsafe { *output.get_unchecked(num_predictions + idx) };
295302
let w = unsafe { *output.get_unchecked(2 * num_predictions + idx) };
@@ -303,7 +310,7 @@ fn extract_detect_boxes(
303310
candidates.push(Candidate {
304311
bbox: [x1, y1, x2, y2],
305312
score,
306-
class: max_classes[idx],
313+
class: best_class,
307314
});
308315
}
309316
}
@@ -343,6 +350,11 @@ fn extract_detect_boxes(
343350
}
344351

345352
if found {
353+
// Filter by class if specified
354+
if !config.keep_class(best_class) {
355+
continue;
356+
}
357+
346358
let cx = unsafe { *output.get_unchecked(base) };
347359
let cy = unsafe { *output.get_unchecked(base + 1) };
348360
let w = unsafe { *output.get_unchecked(base + 2) };
@@ -604,6 +616,11 @@ fn postprocess_segment(
604616
let scaled = scale_coords(&[x1, y1, x2, y2], preprocess.scale, preprocess.padding);
605617
let clipped = clip_coords(&scaled, preprocess.orig_shape);
606618

619+
// Filter by class if specified
620+
if !config.keep_class(best_class) {
621+
continue;
622+
}
623+
607624
candidates.push((
608625
[clipped[0], clipped[1], clipped[2], clipped[3]],
609626
best_score,
@@ -941,6 +958,11 @@ fn postprocess_pose(
941958
keypoints.push([scaled_x, scaled_y, kpt_conf]);
942959
}
943960

961+
// Filter by class if specified
962+
if !config.keep_class(best_class) {
963+
continue;
964+
}
965+
944966
candidates.push((
945967
[clipped[0], clipped[1], clipped[2], clipped[3]],
946968
best_score,
@@ -1178,6 +1200,11 @@ fn postprocess_obb(
11781200
#[allow(clippy::cast_precision_loss)]
11791201
let clipped_cy = scaled_cy.max(0.0).min(oh as f32);
11801202

1203+
// Filter by class if specified
1204+
if !config.keep_class(best_class) {
1205+
continue;
1206+
}
1207+
11811208
candidates.push((
11821209
[clipped_cx, clipped_cy, scaled_w, scaled_h, angle],
11831210
best_score,

tests/integration_test.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ fn test_run_prediction_e2e() {
2929
device: None,
3030
verbose: true,
3131
rect: false,
32+
classes: None,
3233
};
3334

3435
// This should run successfully (download model/images and predict)

0 commit comments

Comments
 (0)