feat: add Vision LLM integration (CLIP + Qwen3-VL cascade)

- Add Qwen3-VL dynamic management (start/stop/status CLI)
- Add CLIP + Qwen3-VL cascade detection strategy
- Add Vision CLI commands (vision start/stop/status, detect)
- Add cascade_vision processor module
- Add clip processor module
- Add qwen_vl_manager module

Changes:
- scripts/start_qwen3vl.sh, stop_qwen3vl.sh: Qwen3-VL management scripts
- src/core/vision/: Qwen3-VL manager module
- src/core/processor/cascade_vision.rs: CLIP + Qwen3-VL cascade logic
- src/core/processor/clip.rs: CLIP classification and detection
- src/api/clip_api.rs: CLIP API endpoints
- src/cli/vision.rs: Vision CLI implementation
- src/cli/args.rs: Add Vision and Detect commands
- src/main.rs: Integrate Vision CLI
- src/core/mod.rs: Add vision module
- src/core/processor/mod.rs: Add cascade_vision module
This commit is contained in:
Accusys
2026-06-13 16:25:52 +08:00
parent 834b0d4865
commit 17e4e15860
37 changed files with 2185 additions and 294 deletions
+1 -4
View File
@@ -44,10 +44,7 @@ pub async fn process_asrx(
let executor = PythonExecutor::new()?;
let script_path = executor.script_path("asrx_processor.py");
tracing::info!(
"[ASRX] Starting hybrid speaker diarization: {}",
video_path
);
tracing::info!("[ASRX] Starting hybrid speaker diarization: {}", video_path);
if !script_path.exists() {
tracing::error!("[ASRX] Script not found: {:?}", script_path);
+308
View File
@@ -0,0 +1,308 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::time::Duration;
use tracing::{debug, info, warn};
use crate::core::processor::clip::{ClipPrediction, detect_objects};
use crate::core::vision::qwen_vl_manager::QwenVLManager;
const DEFAULT_CLIP_THRESHOLD: f32 = 0.7;
const QWENVL_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CascadeDetectionResult {
pub detections: Vec<ClipPrediction>,
pub model_used: String,
pub clip_confidence: f32,
pub qwenvl_used: bool,
pub processing_time_ms: u64,
}
pub struct CascadeVisionProcessor {
clip_threshold: f32,
qwen_vl_manager: QwenVLManager,
}
impl CascadeVisionProcessor {
pub fn new() -> Self {
Self {
clip_threshold: DEFAULT_CLIP_THRESHOLD,
qwen_vl_manager: QwenVLManager::new(),
}
}
pub fn with_threshold(threshold: f32) -> Self {
Self {
clip_threshold: threshold,
qwen_vl_manager: QwenVLManager::new(),
}
}
pub async fn detect_objects(&self, image_path: &Path, objects: &[&str]) -> Result<CascadeDetectionResult> {
let start_time = std::time::Instant::now();
info!(
"[Cascade] Starting detection for {:?} with {} object classes (threshold: {:.2})",
image_path,
objects.len(),
self.clip_threshold
);
let clip_result = self.run_clip_detection(image_path, objects).await?;
let max_clip_confidence = clip_result
.iter()
.map(|p| p.confidence)
.fold(0.0_f32, |max, val| if val > max { val } else { max });
debug!(
"[Cascade] CLIP max confidence: {:.3} (threshold: {:.2})",
max_clip_confidence,
self.clip_threshold
);
if max_clip_confidence > self.clip_threshold {
info!(
"[Cascade] High confidence ({:.3} > {:.2}) → triggering Qwen3-VL",
max_clip_confidence,
self.clip_threshold
);
let qwenvl_result = self.run_qwenvl_detection(image_path, objects).await?;
let processing_time = start_time.elapsed().as_millis() as u64;
return Ok(CascadeDetectionResult {
detections: qwenvl_result,
model_used: "qwen3vl".to_string(),
clip_confidence: max_clip_confidence,
qwenvl_used: true,
processing_time_ms: processing_time,
});
}
info!(
"[Cascade] Low confidence ({:.3} <= {:.2}) → using CLIP results only",
max_clip_confidence,
self.clip_threshold
);
let processing_time = start_time.elapsed().as_millis() as u64;
Ok(CascadeDetectionResult {
detections: clip_result,
model_used: "clip".to_string(),
clip_confidence: max_clip_confidence,
qwenvl_used: false,
processing_time_ms: processing_time,
})
}
async fn run_clip_detection(&self, image_path: &Path, objects: &[&str]) -> Result<Vec<ClipPrediction>> {
let image_path_str = image_path.display().to_string();
debug!("[Cascade] Running CLIP detection for {:?}", image_path);
let predictions = detect_objects(&image_path_str, objects, None, None)
.await
.context("CLIP detection failed")?;
debug!(
"[Cascade] CLIP detected {} objects",
predictions.len()
);
Ok(predictions)
}
async fn run_qwenvl_detection(&self, image_path: &Path, objects: &[&str]) -> Result<Vec<ClipPrediction>> {
let image_path_str = image_path.display().to_string();
debug!("[Cascade] Running Qwen3-VL detection for {:?}", image_path);
self.qwen_vl_manager.ensure_running().await?;
let prompt = self.build_detection_prompt(objects);
let client = reqwest::Client::new();
let url = format!("http://localhost:{}/v1/chat/completions", self.qwen_vl_manager.get_port());
let request_body = serde_json::json!({
"model": "Qwen3VL-8B-Instruct-Q8_0",
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": prompt
},
{
"type": "image_url",
"image_url": {
"url": format!("file://{}", image_path_str)
}
}
]
}
],
"max_tokens": 500,
"temperature": 0.1
});
let response = client
.post(&url)
.json(&request_body)
.timeout(QWENVL_TIMEOUT)
.send()
.await
.context("Qwen3-VL API request failed")?;
if !response.status().is_success() {
warn!("[Cascade] Qwen3-VL API error: {}", response.status());
anyhow::bail!("Qwen3-VL API returned error: {}", response.status());
}
let response_json: serde_json::Value = response
.json()
.await
.context("Failed to parse Qwen3-VL response")?;
let content = response_json
.get("choices")
.and_then(|choices| choices.get(0))
.and_then(|choice| choice.get("message"))
.and_then(|message| message.get("content"))
.and_then(|content| content.as_str())
.unwrap_or("");
debug!("[Cascade] Qwen3-VL response: {}", content);
let detections = self.parse_qwenvl_response(content, objects);
self.qwen_vl_manager.update_last_request_time().await;
info!(
"[Cascade] Qwen3-VL detected {} objects",
detections.len()
);
Ok(detections)
}
fn build_detection_prompt(&self, objects: &[&str]) -> String {
let object_list = objects.join(", ");
format!(
"Analyze this image and detect the following objects: {}.\n\
For each detected object, provide:\n\
1. The object name\n\
2. A confidence score (0.0 to 1.0)\n\
3. A brief description of what you see\n\
\n\
Format your response as JSON:\n\
{{\"detections\": [{{\"label\": \"object_name\", \"confidence\": 0.95, \"description\": \"brief description\"}}]}}\n\
\n\
If no objects are detected, return: {{\"detections\": []}}\n\
\n\
IMPORTANT: Only detect objects that are clearly visible and identifiable. Do not guess or hallucinate.",
object_list
)
}
fn parse_qwenvl_response(&self, content: &str, _objects: &[&str]) -> Vec<ClipPrediction> {
let json_start = content.find('{');
let json_end = content.rfind('}');
if json_start.is_none() || json_end.is_none() {
debug!("[Cascade] No JSON found in Qwen3-VL response");
return Vec::new();
}
let json_str = &content[json_start.unwrap()..=json_end.unwrap()];
let parsed: serde_json::Value = serde_json::from_str(json_str)
.unwrap_or(serde_json::json!({"detections": []}));
let detections = parsed
.get("detections")
.and_then(|d| d.as_array())
.map(|arr| arr.clone())
.unwrap_or_else(|| Vec::new());
detections
.iter()
.filter_map(|d| {
let label = d.get("label").and_then(|l| l.as_str()).unwrap_or("");
let confidence = d.get("confidence").and_then(|c| c.as_f64()).unwrap_or(0.0) as f32;
if !label.is_empty() && confidence > 0.0 {
Some(ClipPrediction {
label: label.to_string(),
confidence,
})
} else {
None
}
})
.collect()
}
}
impl Default for CascadeVisionProcessor {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_detection_prompt() {
let processor = CascadeVisionProcessor::new();
let objects = vec!["gun", "weapon", "person"];
let prompt = processor.build_detection_prompt(&objects);
assert!(prompt.contains("gun, weapon, person"));
assert!(prompt.contains("confidence score"));
assert!(prompt.contains("JSON"));
}
#[test]
fn test_parse_qwenvl_response() {
let processor = CascadeVisionProcessor::new();
let response = "{\"detections\": [{\"label\": \"gun\", \"confidence\": 0.95, \"description\": \"a handgun\"}]}";
let objects = vec!["gun"];
let detections = processor.parse_qwenvl_response(response, &objects);
assert_eq!(detections.len(), 1);
assert_eq!(detections[0].label, "gun");
assert!((detections[0].confidence - 0.95).abs() < 0.001);
}
#[test]
fn test_parse_empty_response() {
let processor = CascadeVisionProcessor::new();
let response = "{\"detections\": []}";
let objects = vec!["gun"];
let detections = processor.parse_qwenvl_response(response, &objects);
assert_eq!(detections.len(), 0);
}
#[test]
fn test_parse_invalid_json() {
let processor = CascadeVisionProcessor::new();
let response = "This is not JSON";
let objects = vec!["gun"];
let detections = processor.parse_qwenvl_response(response, &objects);
assert_eq!(detections.len(), 0);
}
}
+290
View File
@@ -0,0 +1,290 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::time::Duration;
use super::executor::PythonExecutor;
const CLIP_TIMEOUT: Duration = Duration::from_secs(300);
/// CLIP classification prediction
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ClipPrediction {
pub label: String,
pub confidence: f32,
}
/// CLIP classification result for a single image
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ClipImageResult {
pub image_path: String,
pub predictions: Vec<ClipPrediction>,
}
/// CLIP object detection result
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ClipDetectionResult {
pub image_path: String,
pub detected_objects: Vec<ClipPrediction>,
}
/// Classify a single image with given labels
pub async fn classify_image(
image_path: &str,
labels: &[&str],
top_k: Option<usize>,
model_name: Option<&str>,
) -> Result<Vec<ClipPrediction>> {
let executor = PythonExecutor::new()?;
let script_path = executor.script_path("clip_classifier.py");
if !script_path.exists() {
anyhow::bail!("clip_classifier.py not found at {:?}", script_path);
}
let top_k = top_k.unwrap_or(5);
let model = model_name.unwrap_or("openai/clip-vit-base-patch32");
let mut args = vec![
image_path.to_string(),
"--labels".to_string(),
labels.join(","),
"--top-k".to_string(),
top_k.to_string(),
"--model".to_string(),
model.to_string(),
];
let output_path = format!("{}.clip.json", image_path);
args.push("--output".to_string());
args.push(output_path.clone());
tracing::info!(
"[CLIP] Classifying image: {} with {} labels",
image_path,
labels.len()
);
executor
.run(
"clip_classifier.py",
&args.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
None,
"CLIP",
Some(CLIP_TIMEOUT),
)
.await
.context("Failed to run CLIP classifier")?;
let json_str = std::fs::read_to_string(&output_path)
.context("Failed to read CLIP output")?;
let results: std::collections::HashMap<String, Vec<ClipPrediction>> =
serde_json::from_str(&json_str)
.context("Failed to parse CLIP output")?;
let predictions = results
.get(image_path)
.cloned()
.unwrap_or_default();
tracing::info!(
"[CLIP] Top prediction: {} ({:.3})",
predictions.first().map(|p| p.label.as_str()).unwrap_or("none"),
predictions.first().map(|p| p.confidence).unwrap_or(0.0)
);
Ok(predictions)
}
/// Detect objects in an image
pub async fn detect_objects(
image_path: &str,
objects: &[&str],
threshold: Option<f32>,
model_name: Option<&str>,
) -> Result<Vec<ClipPrediction>> {
let executor = PythonExecutor::new()?;
let script_path = executor.script_path("clip_classifier.py");
if !script_path.exists() {
anyhow::bail!("clip_classifier.py not found at {:?}", script_path);
}
let threshold = threshold.unwrap_or(0.15);
let model = model_name.unwrap_or("openai/clip-vit-base-patch32");
let mut args = vec![
image_path.to_string(),
"--detect".to_string(),
objects.join(","),
"--threshold".to_string(),
threshold.to_string(),
"--model".to_string(),
model.to_string(),
];
let output_path = format!("{}.clip.json", image_path);
args.push("--output".to_string());
args.push(output_path.clone());
tracing::info!(
"[CLIP] Detecting {} objects in: {} (threshold: {:.2})",
objects.len(),
image_path,
threshold
);
executor
.run(
"clip_classifier.py",
&args.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
None,
"CLIP",
Some(CLIP_TIMEOUT),
)
.await
.context("Failed to run CLIP object detection")?;
let json_str = std::fs::read_to_string(&output_path)
.context("Failed to read CLIP output")?;
let results: std::collections::HashMap<String, Vec<ClipPrediction>> =
serde_json::from_str(&json_str)
.context("Failed to parse CLIP output")?;
let detected = results
.get(image_path)
.cloned()
.unwrap_or_default();
if !detected.is_empty() {
tracing::info!(
"[CLIP] Detected {} objects: {}",
detected.len(),
detected.iter().map(|p| p.label.as_str()).collect::<Vec<_>>().join(", ")
);
} else {
tracing::info!("[CLIP] No objects detected above threshold {:.2}", threshold);
}
Ok(detected)
}
/// Batch classify multiple images
pub async fn classify_images(
image_paths: &[&str],
labels: &[&str],
top_k: Option<usize>,
model_name: Option<&str>,
) -> Result<Vec<ClipImageResult>> {
let executor = PythonExecutor::new()?;
let script_path = executor.script_path("clip_classifier.py");
if !script_path.exists() {
anyhow::bail!("clip_classifier.py not found at {:?}", script_path);
}
let top_k = top_k.unwrap_or(5);
let model = model_name.unwrap_or("openai/clip-vit-base-patch32");
// Create temp file with image paths
let temp_file = format!("/tmp/clip_batch_{}.txt", uuid::Uuid::new_v4());
std::fs::write(&temp_file, image_paths.join("\n"))
.context("Failed to write batch file")?;
let mut args = vec![
temp_file.clone(),
"--batch".to_string(),
"--labels".to_string(),
labels.join(","),
"--top-k".to_string(),
top_k.to_string(),
"--model".to_string(),
model.to_string(),
];
let output_path = format!("/tmp/clip_batch_{}.json", uuid::Uuid::new_v4());
args.push("--output".to_string());
args.push(output_path.clone());
tracing::info!(
"[CLIP] Batch classifying {} images with {} labels",
image_paths.len(),
labels.len()
);
executor
.run(
"clip_classifier.py",
&args.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
None,
"CLIP",
Some(CLIP_TIMEOUT),
)
.await
.context("Failed to run batch CLIP classification")?;
let json_str = std::fs::read_to_string(&output_path)
.context("Failed to read CLIP batch output")?;
let results_map: std::collections::HashMap<String, Vec<ClipPrediction>> =
serde_json::from_str(&json_str)
.context("Failed to parse CLIP batch output")?;
let results: Vec<ClipImageResult> = image_paths
.iter()
.map(|path| ClipImageResult {
image_path: path.to_string(),
predictions: results_map.get(*path).cloned().unwrap_or_default(),
})
.collect();
// Cleanup temp files
let _ = std::fs::remove_file(&temp_file);
let _ = std::fs::remove_file(&output_path);
Ok(results)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_clip_prediction_serialization() {
let pred = ClipPrediction {
label: "person in room".to_string(),
confidence: 0.876,
};
let json = serde_json::to_string(&pred).unwrap();
assert!(json.contains("person in room"));
assert!(json.contains("0.876"));
}
#[test]
fn test_clip_prediction_deserialization() {
let json = r#"{"label":"outdoor scene","confidence":0.945}"#;
let pred: ClipPrediction = serde_json::from_str(json).unwrap();
assert_eq!(pred.label, "outdoor scene");
assert!((pred.confidence - 0.945).abs() < 0.001);
}
#[test]
fn test_clip_image_result() {
let result = ClipImageResult {
image_path: "/test/image.jpg".to_string(),
predictions: vec![
ClipPrediction {
label: "indoor".to_string(),
confidence: 0.92,
},
ClipPrediction {
label: "outdoor".to_string(),
confidence: 0.08,
},
],
};
assert_eq!(result.predictions.len(), 2);
assert_eq!(result.predictions[0].label, "indoor");
}
}
+4
View File
@@ -1,6 +1,8 @@
pub mod asr;
pub mod asrx;
pub mod caption;
pub mod cascade_vision;
pub mod clip;
pub mod cut;
pub mod executor;
pub mod face;
@@ -16,6 +18,8 @@ pub mod yolo;
pub use asr::{process_asr, AsrResult, AsrSegment};
pub use asrx::{process_asrx, AsrxResult, AsrxSegment};
pub use caption::{process_caption, CaptionResult, CaptionSummary, FrameCaption};
pub use cascade_vision::{CascadeDetectionResult, CascadeVisionProcessor};
pub use clip::{classify_image, classify_images, detect_objects, ClipDetectionResult, ClipImageResult, ClipPrediction};
pub use cut::{process_cut, CutResult, CutScene};
pub use executor::{validate_python_env, PythonExecutor, RetryConfig};
pub use face::{process_face, Face, FaceFrame, FaceResult};