feat(ml): implement v3.0 architecture with CBAM and calibrated inference
- Integrate Convolutional Block Attention Module (CBAM) for improved feature focus - Implement temperature scaling and confidence-based status reporting - Automate dataset acquisition using kagglehub - Update ONNX opset to 18 and refine preprocessing validation
This commit is contained in:
@@ -2,18 +2,24 @@ use anyhow::{Context, Result};
|
||||
use std::env;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const LABELS: [&str; 4] = ["Bercak Daun", "Daun Sehat", "Karat Daun", "Hawar Daun"];
|
||||
pub const LABELS: [&str; 4] = ["Bercak Daun", "Daun Sehat", "Hawar Daun", "Karat Daun"];
|
||||
pub const SERVICE_NAME: &str = "zeavis-ml-service";
|
||||
pub const SERVICE_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
pub const DEFAULT_INPUT_SIZE: u32 = 224;
|
||||
pub const DEFAULT_MODEL_PATH: &str = "../../Machine_Learning/model/model.onnx";
|
||||
pub const DEFAULT_TEMPERATURE: f32 = 1.0;
|
||||
pub const CONFIDENCE_THRESHOLD_HIGH: f32 = 0.70;
|
||||
pub const CONFIDENCE_THRESHOLD_LOW: f32 = 0.45;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Config {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub model_path: PathBuf,
|
||||
pub input_size: u32,
|
||||
pub temperature: f32,
|
||||
pub conf_threshold_high: f32,
|
||||
pub conf_threshold_low: f32,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -27,12 +33,18 @@ impl Config {
|
||||
let port = parse_env_u16("ML_SERVICE_PORT", 8000)?;
|
||||
let input_size = parse_env_u32("MODEL_INPUT_SIZE", DEFAULT_INPUT_SIZE)?;
|
||||
let model_path = env::var("MODEL_PATH").unwrap_or_else(|_| DEFAULT_MODEL_PATH.to_string());
|
||||
let temperature = parse_env_f32("MODEL_TEMPERATURE", DEFAULT_TEMPERATURE)?;
|
||||
let conf_threshold_high = parse_env_f32("MODEL_CONF_HIGH", CONFIDENCE_THRESHOLD_HIGH)?;
|
||||
let conf_threshold_low = parse_env_f32("MODEL_CONF_LOW", CONFIDENCE_THRESHOLD_LOW)?;
|
||||
|
||||
Ok(Self {
|
||||
host,
|
||||
port,
|
||||
model_path: resolve_model_path(base_dir, &model_path),
|
||||
input_size,
|
||||
temperature,
|
||||
conf_threshold_high,
|
||||
conf_threshold_low,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -64,13 +76,22 @@ fn parse_env_u32(name: &str, default: u32) -> Result<u32> {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_env_f32(name: &str, default: f32) -> Result<f32> {
|
||||
match env::var(name) {
|
||||
Ok(value) => value
|
||||
.parse::<f32>()
|
||||
.with_context(|| format!("{name} must be a valid f32")),
|
||||
Err(_) => Ok(default),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn labels_match_training_class_order_with_display_names() {
|
||||
assert_eq!(LABELS, ["Bercak Daun", "Daun Sehat", "Karat Daun", "Hawar Daun"]);
|
||||
assert_eq!(LABELS, ["Bercak Daun", "Daun Sehat", "Hawar Daun", "Karat Daun"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -23,13 +23,20 @@ async fn main() -> Result<()> {
|
||||
// Load configuration from environment
|
||||
let config = Config::from_env()?;
|
||||
|
||||
// Create ModelService and wrap in Arc
|
||||
let model = Arc::new(ModelService::new(&config.model_path, config.input_size));
|
||||
|
||||
// Log model status
|
||||
// Create ModelService with calibration and wrap in Arc
|
||||
let model = Arc::new(ModelService::with_calibration(
|
||||
&config.model_path,
|
||||
config.input_size,
|
||||
config.temperature,
|
||||
config.conf_threshold_high,
|
||||
config.conf_threshold_low,
|
||||
));
|
||||
tracing::info!(
|
||||
model_loaded = model.is_loaded(),
|
||||
model_path = ?config.model_path,
|
||||
temperature = config.temperature,
|
||||
conf_high = config.conf_threshold_high,
|
||||
conf_low = config.conf_threshold_low,
|
||||
"Model service initialized"
|
||||
);
|
||||
|
||||
|
||||
+123
-117
@@ -1,4 +1,4 @@
|
||||
use crate::config::LABELS;
|
||||
use crate::config::{CONFIDENCE_THRESHOLD_HIGH, CONFIDENCE_THRESHOLD_LOW, DEFAULT_TEMPERATURE, LABELS};
|
||||
use crate::error::ServiceError;
|
||||
use ndarray::Array4;
|
||||
use ort::{session::Session, value::TensorRef};
|
||||
@@ -7,32 +7,40 @@ use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Prediction result containing the top label, confidence, and all probabilities.
|
||||
/// Prediction result with temperature-calibrated probabilities and status.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Prediction {
|
||||
pub status: String, // "confident", "uncertain", "rejected"
|
||||
pub label: String,
|
||||
pub confidence: f32,
|
||||
pub probabilities: BTreeMap<String, f32>,
|
||||
}
|
||||
|
||||
/// Service for running ONNX model inference.
|
||||
/// Service for running ONNX model inference with temperature-scaled calibration.
|
||||
///
|
||||
/// Stores the model path, input size, and an optional thread-safe ONNX session.
|
||||
/// If the model fails to load, the session remains None and predictions will fail.
|
||||
/// The session is wrapped in a Mutex to ensure thread-safe access from concurrent Axum requests.
|
||||
/// The ONNX model outputs raw logits. Temperature scaling + softmax is applied
|
||||
/// in predict() to produce calibrated probabilities and a decision status:
|
||||
/// - confident: max_prob >= conf_threshold_high
|
||||
/// - uncertain: conf_threshold_low <= max_prob < conf_threshold_high
|
||||
/// - rejected: max_prob < conf_threshold_low
|
||||
pub struct ModelService {
|
||||
model_path: std::path::PathBuf,
|
||||
input_size: u32,
|
||||
temperature: f32,
|
||||
conf_threshold_high: f32,
|
||||
conf_threshold_low: f32,
|
||||
session: Option<Mutex<Session>>,
|
||||
}
|
||||
|
||||
impl ModelService {
|
||||
/// Creates a new ModelService, attempting to load the ONNX model from the given path.
|
||||
///
|
||||
/// If the model file does not exist or fails to load, the session is stored as None.
|
||||
/// This allows the service to report unloaded state via health checks.
|
||||
/// The session is wrapped in a Mutex for thread-safe concurrent access.
|
||||
pub fn new(model_path: &Path, input_size: u32) -> Self {
|
||||
Self::with_calibration(model_path, input_size, DEFAULT_TEMPERATURE,
|
||||
CONFIDENCE_THRESHOLD_HIGH, CONFIDENCE_THRESHOLD_LOW)
|
||||
}
|
||||
|
||||
/// Creates a new ModelService with temperature scaling and confidence thresholds.
|
||||
pub fn with_calibration(model_path: &Path, input_size: u32,
|
||||
temperature: f32, conf_high: f32, conf_low: f32) -> Self {
|
||||
let session = Session::builder()
|
||||
.ok()
|
||||
.and_then(|mut builder| builder.commit_from_file(model_path).ok())
|
||||
@@ -41,88 +49,101 @@ impl ModelService {
|
||||
Self {
|
||||
model_path: model_path.to_path_buf(),
|
||||
input_size,
|
||||
temperature,
|
||||
conf_threshold_high: conf_high,
|
||||
conf_threshold_low: conf_low,
|
||||
session,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if the model is loaded and ready for inference.
|
||||
pub fn is_loaded(&self) -> bool {
|
||||
self.session.is_some()
|
||||
}
|
||||
|
||||
/// Returns the path to the model file.
|
||||
pub fn model_path(&self) -> &Path {
|
||||
&self.model_path
|
||||
}
|
||||
|
||||
/// Returns the input size (width/height) for the model.
|
||||
pub fn input_size(&self) -> u32 {
|
||||
self.input_size
|
||||
}
|
||||
|
||||
/// Runs inference on the given input array.
|
||||
pub fn temperature(&self) -> f32 {
|
||||
self.temperature
|
||||
}
|
||||
|
||||
/// Runs inference and returns temperature-calibrated Prediction.
|
||||
///
|
||||
/// Returns ModelUnavailable if the model is not loaded.
|
||||
/// Returns PredictionFailed if inference fails, lock is poisoned, or output format is invalid.
|
||||
/// The ONNX model outputs raw logits (no softmax). Temperature scaling
|
||||
/// is applied: probs = softmax(logits / T).
|
||||
pub fn predict(&self, input: Array4<f32>) -> Result<Prediction, ServiceError> {
|
||||
let session = self
|
||||
.session
|
||||
.as_ref()
|
||||
let session = self.session.as_ref()
|
||||
.ok_or_else(|| ServiceError::ModelUnavailable("Model is not loaded".to_string()))?;
|
||||
|
||||
// Lock the session for thread-safe access
|
||||
let mut session_guard = session
|
||||
.lock()
|
||||
let mut session_guard = session.lock()
|
||||
.map_err(|_| ServiceError::PredictionFailed("Prediction failed".to_string()))?;
|
||||
|
||||
let input = TensorRef::from_array_view(&input)
|
||||
.map_err(|_| ServiceError::PredictionFailed("Prediction failed".to_string()))?;
|
||||
let outputs = session_guard
|
||||
.run(ort::inputs![input])
|
||||
let outputs = session_guard.run(ort::inputs![input])
|
||||
.map_err(|_| ServiceError::PredictionFailed("Prediction failed".to_string()))?;
|
||||
|
||||
let output_tensor = outputs[0]
|
||||
.try_extract_tensor::<f32>()
|
||||
let output_tensor = outputs[0].try_extract_tensor::<f32>()
|
||||
.map_err(|_| ServiceError::PredictionFailed("Prediction failed".to_string()))?;
|
||||
|
||||
let probabilities: Vec<f32> = output_tensor.1.iter().copied().collect();
|
||||
let logits: Vec<f32> = output_tensor.1.iter().copied().collect();
|
||||
|
||||
Self::prediction_from_probabilities(&probabilities)
|
||||
Self::calibrate_prediction(&logits, self.temperature,
|
||||
self.conf_threshold_high, self.conf_threshold_low)
|
||||
}
|
||||
|
||||
/// Maps a probability vector to a Prediction with label and all probabilities.
|
||||
///
|
||||
/// Expects a vector of length 4 (one per label in LABELS).
|
||||
/// Rejects non-finite values (NaN, +inf, -inf) to prevent invalid predictions.
|
||||
/// Returns PredictionFailed if the length is incorrect or any value is non-finite.
|
||||
pub fn prediction_from_probabilities(probs: &[f32]) -> Result<Prediction, ServiceError> {
|
||||
if probs.len() != LABELS.len() {
|
||||
/// Applies temperature scaling + softmax to raw logits, determines status.
|
||||
fn calibrate_prediction(logits: &[f32], temperature: f32,
|
||||
conf_high: f32, conf_low: f32) -> Result<Prediction, ServiceError> {
|
||||
if logits.len() != LABELS.len() {
|
||||
return Err(ServiceError::PredictionFailed("Prediction failed".to_string()));
|
||||
}
|
||||
|
||||
// Reject non-finite values (NaN, +inf, -inf)
|
||||
if probs.iter().any(|p| !p.is_finite()) {
|
||||
// Reject non-finite values
|
||||
if logits.iter().any(|p| !p.is_finite()) {
|
||||
return Err(ServiceError::PredictionFailed("Prediction failed".to_string()));
|
||||
}
|
||||
|
||||
// Find the index with the highest probability
|
||||
let top_idx = probs
|
||||
.iter()
|
||||
.enumerate()
|
||||
// Temperature scaling: divide by T
|
||||
let T = if temperature > 0.0 { temperature } else { 1.0 };
|
||||
let scaled: Vec<f32> = logits.iter().map(|l| l / T).collect();
|
||||
|
||||
// Numerically stable softmax: shift by max to avoid overflow
|
||||
let max_logit = scaled.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
|
||||
let exp_vals: Vec<f32> = scaled.iter().map(|l| (l - max_logit).exp()).collect();
|
||||
let sum: f32 = exp_vals.iter().sum();
|
||||
let probs: Vec<f32> = exp_vals.iter().map(|e| e / sum).collect();
|
||||
|
||||
// Find top probability
|
||||
let top_idx = probs.iter().enumerate()
|
||||
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
|
||||
.map(|(idx, _)| idx)
|
||||
.unwrap_or(0);
|
||||
|
||||
let top_label = LABELS[top_idx].to_string();
|
||||
let confidence = probs[top_idx];
|
||||
let top_label = LABELS[top_idx].to_string();
|
||||
|
||||
// Determine status
|
||||
let status = if confidence >= conf_high {
|
||||
"confident"
|
||||
} else if confidence >= conf_low {
|
||||
"uncertain"
|
||||
} else {
|
||||
"rejected"
|
||||
};
|
||||
|
||||
// Build probabilities map
|
||||
let mut probabilities = BTreeMap::new();
|
||||
for (i, &prob) in probs.iter().enumerate() {
|
||||
probabilities.insert(LABELS[i].to_string(), prob);
|
||||
}
|
||||
|
||||
Ok(Prediction {
|
||||
status: status.to_string(),
|
||||
label: top_label,
|
||||
confidence,
|
||||
probabilities,
|
||||
@@ -134,105 +155,90 @@ impl ModelService {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prediction_mapping_selects_top_label_and_all_probabilities() {
|
||||
let probs = [0.1, 0.2, 0.6, 0.1];
|
||||
let result = ModelService::prediction_from_probabilities(&probs);
|
||||
const T: f32 = 1.0;
|
||||
const HIGH: f32 = 0.70;
|
||||
const LOW: f32 = 0.45;
|
||||
|
||||
#[test]
|
||||
fn calibrate_probs_selects_top_label() {
|
||||
let logits = [1.0, 2.0, 3.0, 0.5];
|
||||
let result = ModelService::calibrate_prediction(&logits, T, HIGH, LOW);
|
||||
assert!(result.is_ok());
|
||||
let prediction = result.unwrap();
|
||||
|
||||
// Top label should be Karat Daun (index 2 with 0.6 probability)
|
||||
assert_eq!(prediction.label, "Karat Daun");
|
||||
assert_eq!(prediction.confidence, 0.6);
|
||||
|
||||
// All probabilities should be present
|
||||
assert_eq!(prediction.probabilities.len(), 4);
|
||||
assert_eq!(prediction.probabilities.get("Bercak Daun"), Some(&0.1));
|
||||
assert_eq!(prediction.probabilities.get("Daun Sehat"), Some(&0.2));
|
||||
assert_eq!(prediction.probabilities.get("Karat Daun"), Some(&0.6));
|
||||
assert_eq!(prediction.probabilities.get("Hawar Daun"), Some(&0.1));
|
||||
let p = result.unwrap();
|
||||
// Index 2 = Hawar Daun (highest logit)
|
||||
assert_eq!(p.label, "Hawar Daun");
|
||||
assert!(p.confidence > 0.5);
|
||||
assert_eq!(p.status, "confident");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prediction_mapping_rejects_wrong_output_length() {
|
||||
let probs = [0.25, 0.25, 0.25]; // Only 3 values instead of 4
|
||||
let result = ModelService::prediction_from_probabilities(&probs);
|
||||
|
||||
assert!(result.is_err());
|
||||
match result.unwrap_err() {
|
||||
ServiceError::PredictionFailed(msg) => {
|
||||
assert_eq!(msg, "Prediction failed");
|
||||
}
|
||||
_ => panic!("expected PredictionFailed error"),
|
||||
}
|
||||
fn calibrate_probs_uncertain_when_borderline() {
|
||||
let logits = [0.0, 0.4, 0.0, 0.0]; // softmax with low max
|
||||
let result = ModelService::calibrate_prediction(&logits, 2.0, HIGH, LOW);
|
||||
assert!(result.is_ok());
|
||||
let p = result.unwrap();
|
||||
// T=2.0 flattens further — likely uncertain or rejected
|
||||
assert!(p.status == "uncertain" || p.status == "rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prediction_mapping_rejects_nan_values() {
|
||||
let probs = [0.1, f32::NAN, 0.6, 0.1];
|
||||
let result = ModelService::prediction_from_probabilities(&probs);
|
||||
|
||||
assert!(result.is_err());
|
||||
match result.unwrap_err() {
|
||||
ServiceError::PredictionFailed(msg) => {
|
||||
assert_eq!(msg, "Prediction failed");
|
||||
}
|
||||
_ => panic!("expected PredictionFailed error"),
|
||||
}
|
||||
fn calibrate_probs_rejects_low_confidence() {
|
||||
let logits = [0.01, 0.01, 0.01, 0.02];
|
||||
let result = ModelService::calibrate_prediction(&logits, 10.0, HIGH, LOW);
|
||||
assert!(result.is_ok());
|
||||
let p = result.unwrap();
|
||||
assert_eq!(p.status, "rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prediction_mapping_rejects_positive_infinity() {
|
||||
let probs = [0.1, 0.2, f32::INFINITY, 0.1];
|
||||
let result = ModelService::prediction_from_probabilities(&probs);
|
||||
|
||||
assert!(result.is_err());
|
||||
match result.unwrap_err() {
|
||||
ServiceError::PredictionFailed(msg) => {
|
||||
assert_eq!(msg, "Prediction failed");
|
||||
}
|
||||
_ => panic!("expected PredictionFailed error"),
|
||||
}
|
||||
fn calibrate_probs_all_probabilities_present() {
|
||||
let logits = [1.0, 2.0, 3.0, 4.0];
|
||||
let result = ModelService::calibrate_prediction(&logits, T, HIGH, LOW);
|
||||
assert!(result.is_ok());
|
||||
let p = result.unwrap();
|
||||
assert_eq!(p.probabilities.len(), 4);
|
||||
assert!(p.probabilities.contains_key("Bercak Daun"));
|
||||
assert!(p.probabilities.contains_key("Daun Sehat"));
|
||||
assert!(p.probabilities.contains_key("Hawar Daun"));
|
||||
assert!(p.probabilities.contains_key("Karat Daun"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prediction_mapping_rejects_negative_infinity() {
|
||||
let probs = [0.1, 0.2, 0.6, f32::NEG_INFINITY];
|
||||
let result = ModelService::prediction_from_probabilities(&probs);
|
||||
|
||||
fn calibrate_probs_rejects_wrong_length() {
|
||||
let logits = [0.25, 0.25, 0.25];
|
||||
let result = ModelService::calibrate_prediction(&logits, T, HIGH, LOW);
|
||||
assert!(result.is_err());
|
||||
match result.unwrap_err() {
|
||||
ServiceError::PredictionFailed(msg) => {
|
||||
assert_eq!(msg, "Prediction failed");
|
||||
}
|
||||
_ => panic!("expected PredictionFailed error"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calibrate_probs_rejects_nan() {
|
||||
let logits = [0.1, f32::NAN, 0.6, 0.1];
|
||||
let result = ModelService::calibrate_prediction(&logits, T, HIGH, LOW);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temperature_one_gives_same_ranking() {
|
||||
let logits = [0.0, 1.0, 2.0, 3.0];
|
||||
let r1 = ModelService::calibrate_prediction(&logits, 1.0, 0.0, 0.0).unwrap();
|
||||
let r2 = ModelService::calibrate_prediction(&logits, 2.0, 0.0, 0.0).unwrap();
|
||||
assert_eq!(r1.label, r2.label);
|
||||
assert!(r1.confidence > r2.confidence); // T=2 flattens
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_model_file_creates_unloaded_service() {
|
||||
let model_path = Path::new("/nonexistent/model.onnx");
|
||||
let service = ModelService::new(model_path, 224);
|
||||
|
||||
let service = ModelService::new(Path::new("/nonexistent/model.onnx"), 224);
|
||||
assert!(!service.is_loaded());
|
||||
assert_eq!(service.model_path(), model_path);
|
||||
assert_eq!(service.input_size(), 224);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unloaded_service_returns_model_unavailable() {
|
||||
let model_path = Path::new("/nonexistent/model.onnx");
|
||||
let service = ModelService::new(model_path, 224);
|
||||
|
||||
let dummy_input = ndarray::Array4::zeros((1, 224, 224, 3));
|
||||
let result = service.predict(dummy_input);
|
||||
|
||||
let service = ModelService::new(Path::new("/nonexistent/model.onnx"), 224);
|
||||
let result = service.predict(ndarray::Array4::zeros((1, 224, 224, 3)));
|
||||
assert!(result.is_err());
|
||||
match result.unwrap_err() {
|
||||
ServiceError::ModelUnavailable(msg) => {
|
||||
assert_eq!(msg, "Model is not loaded");
|
||||
}
|
||||
ServiceError::ModelUnavailable(msg) => assert_eq!(msg, "Model is not loaded"),
|
||||
_ => panic!("expected ModelUnavailable error"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ pub struct MetadataResponse {
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct PredictionResponse {
|
||||
pub status: String,
|
||||
pub label: String,
|
||||
pub confidence: f32,
|
||||
pub probabilities: std::collections::BTreeMap<String, f32>,
|
||||
@@ -202,33 +203,35 @@ mod tests {
|
||||
assert_eq!(response.labels.len(), 4);
|
||||
assert_eq!(
|
||||
response.labels,
|
||||
vec!["Bercak Daun", "Daun Sehat", "Karat Daun", "Hawar Daun"]
|
||||
vec!["Bercak Daun", "Daun Sehat", "Hawar Daun", "Karat Daun"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prediction_response_matches_prediction_contract() {
|
||||
let prediction = Prediction {
|
||||
label: "Karat Daun".to_string(),
|
||||
status: "confident".to_string(),
|
||||
label: "Hawar Daun".to_string(),
|
||||
confidence: 0.6,
|
||||
probabilities: {
|
||||
let mut map = std::collections::BTreeMap::new();
|
||||
map.insert("Bercak Daun".to_string(), 0.1);
|
||||
map.insert("Daun Sehat".to_string(), 0.2);
|
||||
map.insert("Karat Daun".to_string(), 0.6);
|
||||
map.insert("Hawar Daun".to_string(), 0.1);
|
||||
map.insert("Hawar Daun".to_string(), 0.6);
|
||||
map.insert("Karat Daun".to_string(), 0.1);
|
||||
map
|
||||
},
|
||||
};
|
||||
|
||||
let response = prediction_response(prediction);
|
||||
|
||||
assert_eq!(response.label, "Karat Daun");
|
||||
assert_eq!(response.status, "confident");
|
||||
assert_eq!(response.label, "Hawar Daun");
|
||||
assert_eq!(response.confidence, 0.6);
|
||||
assert_eq!(response.probabilities.len(), 4);
|
||||
assert_eq!(response.probabilities.get("Bercak Daun"), Some(&0.1));
|
||||
assert_eq!(response.probabilities.get("Daun Sehat"), Some(&0.2));
|
||||
assert_eq!(response.probabilities.get("Karat Daun"), Some(&0.6));
|
||||
assert_eq!(response.probabilities.get("Hawar Daun"), Some(&0.1));
|
||||
assert_eq!(response.probabilities.get("Hawar Daun"), Some(&0.6));
|
||||
assert_eq!(response.probabilities.get("Karat Daun"), Some(&0.1));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user