Create Rust crate skeleton with Cargo.toml, main.rs, and config.rs. Includes package metadata, dependencies (anyhow, axum, image, ndarray, ort, serde, tokio, tower, tracing), and config module with LABELS constants, SERVICE_NAME, SERVICE_VERSION, DEFAULT_INPUT_SIZE, and resolve_model_path function. Config tests verify label order, relative path resolution, and absolute path preservation. Also includes approved spec and implementation plan documents. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1782 lines
48 KiB
Markdown
1782 lines
48 KiB
Markdown
# Rust ONNX ML Service Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Replace the Python FastAPI ML service with a Rust Axum service that serves ONNX Runtime inference while preserving the current API contract and deployment shape.
|
|
|
|
**Architecture:** `apps/ml-service` becomes a Rust binary crate with focused modules for config, routes, errors, image preprocessing, and ONNX inference. The ML pipeline keeps TensorFlow/Keras for training/export and adds ONNX conversion plus manual parity validation. Docker continues to publish an `ml` service listening on port `8000`.
|
|
|
|
**Tech Stack:** Rust, Axum, Tokio, Serde, image, ndarray, ort, Python TensorFlow/tf2onnx/onnxruntime for export validation, Docker.
|
|
|
|
---
|
|
|
|
## File structure
|
|
|
|
### Create
|
|
|
|
- `apps/ml-service/Cargo.toml` — Rust crate metadata and dependencies.
|
|
- `apps/ml-service/src/main.rs` — application startup, shared state, router binding.
|
|
- `apps/ml-service/src/config.rs` — environment parsing, constants, labels, model path resolution.
|
|
- `apps/ml-service/src/error.rs` — service error enum and Axum response mapping.
|
|
- `apps/ml-service/src/image.rs` — image decode, RGB conversion, resizing, NHWC float32 tensor creation.
|
|
- `apps/ml-service/src/model.rs` — ONNX Runtime session wrapper and prediction result mapping.
|
|
- `apps/ml-service/src/routes.rs` — `/health`, `/metadata`, and `/predict` handlers.
|
|
- `Machine_Learning/convert_onnx.py` — convert exported SavedModel to `model/model.onnx` using tf2onnx.
|
|
- `Machine_Learning/validate_onnx_parity.py` — manual parity check between Keras and ONNX for sample images.
|
|
|
|
### Modify
|
|
|
|
- `apps/ml-service/Dockerfile` — replace Python runtime with Rust multi-stage build and ONNX model copy.
|
|
- `apps/ml-service/moon.yml` — replace uvicorn/py_compile tasks with cargo tasks.
|
|
- `apps/ml-service/.env.example` — update default model path and port for Rust service.
|
|
- `Machine_Learning/requirements.txt` — add ONNX conversion/parity dependencies.
|
|
- `Machine_Learning/README.md` — document ONNX conversion and parity validation.
|
|
- `README.md` — update service description, prerequisites, endpoints, artifacts, and troubleshooting.
|
|
|
|
### Remove
|
|
|
|
- `apps/ml-service/main.py` — superseded by Rust Axum entrypoint.
|
|
- `apps/ml-service/model.py` — superseded by Rust ONNX model module.
|
|
- `apps/ml-service/schemas.py` — superseded by Rust response structs.
|
|
- `apps/ml-service/test_model.py` — superseded by Rust tests.
|
|
- `apps/ml-service/requirements.txt` — no longer used by serving runtime.
|
|
|
|
---
|
|
|
|
## Task 1: Create Rust crate skeleton and config
|
|
|
|
**Files:**
|
|
- Create: `apps/ml-service/Cargo.toml`
|
|
- Create: `apps/ml-service/src/main.rs`
|
|
- Create: `apps/ml-service/src/config.rs`
|
|
|
|
- [ ] **Step 1: Write crate manifest**
|
|
|
|
Create `apps/ml-service/Cargo.toml`:
|
|
|
|
```toml
|
|
[package]
|
|
name = "zeavis-ml-service"
|
|
version = "0.1.0"
|
|
edition = "2021"
|
|
|
|
[dependencies]
|
|
anyhow = "1.0"
|
|
axum = { version = "0.7", features = ["multipart"] }
|
|
image = "0.25"
|
|
ndarray = "0.15"
|
|
ort = "2.0.0-rc.10"
|
|
serde = { version = "1.0", features = ["derive"] }
|
|
serde_json = "1.0"
|
|
tokio = { version = "1.0", features = ["macros", "rt-multi-thread", "net"] }
|
|
tower = "0.5"
|
|
tracing = "0.1"
|
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
|
|
|
[dev-dependencies]
|
|
temp-env = "0.3"
|
|
```
|
|
|
|
- [ ] **Step 2: Write config tests first**
|
|
|
|
Create `apps/ml-service/src/config.rs` with only constants and tests initially:
|
|
|
|
```rust
|
|
use std::path::{Path, PathBuf};
|
|
|
|
pub const LABELS: [&str; 4] = ["Bercak Daun", "Daun Sehat", "Karat Daun", "Hawar 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;
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct Config {
|
|
pub host: String,
|
|
pub port: u16,
|
|
pub model_path: PathBuf,
|
|
pub input_size: u32,
|
|
}
|
|
|
|
pub fn resolve_model_path(base_dir: &Path, model_path: &str) -> PathBuf {
|
|
let path = PathBuf::from(model_path);
|
|
if path.is_absolute() {
|
|
path
|
|
} else {
|
|
base_dir.join(path)
|
|
}
|
|
}
|
|
|
|
#[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"]);
|
|
}
|
|
|
|
#[test]
|
|
fn relative_model_path_resolves_from_service_directory() {
|
|
let base = Path::new("/repo/apps/ml-service");
|
|
let resolved = resolve_model_path(base, "../../Machine_Learning/model/model.onnx");
|
|
assert_eq!(resolved, PathBuf::from("/repo/apps/ml-service/../../Machine_Learning/model/model.onnx"));
|
|
}
|
|
|
|
#[test]
|
|
fn absolute_model_path_is_preserved() {
|
|
let base = Path::new("/repo/apps/ml-service");
|
|
let resolved = resolve_model_path(base, "/models/model.onnx");
|
|
assert_eq!(resolved, PathBuf::from("/models/model.onnx"));
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Run tests and verify expected compile failure**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cargo test --manifest-path apps/ml-service/Cargo.toml
|
|
```
|
|
|
|
Expected: compilation fails because there is no `src/main.rs` target yet or because the crate has no complete binary entrypoint.
|
|
|
|
- [ ] **Step 4: Add minimal main file**
|
|
|
|
Create `apps/ml-service/src/main.rs`:
|
|
|
|
```rust
|
|
mod config;
|
|
|
|
fn main() {
|
|
println!("zeavis-ml-service");
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Run tests and verify they pass**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cargo test --manifest-path apps/ml-service/Cargo.toml
|
|
```
|
|
|
|
Expected: all config tests pass.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add apps/ml-service/Cargo.toml apps/ml-service/src/main.rs apps/ml-service/src/config.rs
|
|
git commit -m "feat: scaffold Rust ML service crate"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Implement environment config loading
|
|
|
|
**Files:**
|
|
- Modify: `apps/ml-service/src/config.rs`
|
|
|
|
- [ ] **Step 1: Add failing tests for environment defaults and overrides**
|
|
|
|
Append these tests inside the existing `#[cfg(test)] mod tests` in `apps/ml-service/src/config.rs`:
|
|
|
|
```rust
|
|
#[test]
|
|
fn config_uses_default_values_when_env_is_absent() {
|
|
temp_env::with_vars_unset(
|
|
["ML_SERVICE_HOST", "ML_SERVICE_PORT", "MODEL_PATH", "MODEL_INPUT_SIZE"],
|
|
|| {
|
|
let config = Config::from_env_with_base_dir(Path::new("/repo/apps/ml-service")).unwrap();
|
|
|
|
assert_eq!(config.host, "0.0.0.0");
|
|
assert_eq!(config.port, 8000);
|
|
assert_eq!(config.input_size, 224);
|
|
assert_eq!(
|
|
config.model_path,
|
|
PathBuf::from("/repo/apps/ml-service/../../Machine_Learning/model/model.onnx")
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn config_reads_environment_overrides() {
|
|
temp_env::with_vars(
|
|
[
|
|
("ML_SERVICE_HOST", Some("127.0.0.1")),
|
|
("ML_SERVICE_PORT", Some("9000")),
|
|
("MODEL_PATH", Some("/tmp/model.onnx")),
|
|
("MODEL_INPUT_SIZE", Some("128")),
|
|
],
|
|
|| {
|
|
let config = Config::from_env_with_base_dir(Path::new("/repo/apps/ml-service")).unwrap();
|
|
|
|
assert_eq!(config.host, "127.0.0.1");
|
|
assert_eq!(config.port, 9000);
|
|
assert_eq!(config.input_size, 128);
|
|
assert_eq!(config.model_path, PathBuf::from("/tmp/model.onnx"));
|
|
},
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_port_returns_error() {
|
|
temp_env::with_vars(
|
|
[("ML_SERVICE_PORT", Some("not-a-port"))],
|
|
|| {
|
|
let error = Config::from_env_with_base_dir(Path::new("/repo/apps/ml-service")).unwrap_err();
|
|
assert!(error.to_string().contains("ML_SERVICE_PORT"));
|
|
},
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cargo test --manifest-path apps/ml-service/Cargo.toml config
|
|
```
|
|
|
|
Expected: FAIL with `no function or associated item named 'from_env_with_base_dir'`.
|
|
|
|
- [ ] **Step 3: Implement config loader**
|
|
|
|
Replace the top-level implementation in `apps/ml-service/src/config.rs` with:
|
|
|
|
```rust
|
|
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 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";
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct Config {
|
|
pub host: String,
|
|
pub port: u16,
|
|
pub model_path: PathBuf,
|
|
pub input_size: u32,
|
|
}
|
|
|
|
impl Config {
|
|
pub fn from_env() -> Result<Self> {
|
|
let base_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
|
Self::from_env_with_base_dir(&base_dir)
|
|
}
|
|
|
|
pub fn from_env_with_base_dir(base_dir: &Path) -> Result<Self> {
|
|
let host = env::var("ML_SERVICE_HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
|
|
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());
|
|
|
|
Ok(Self {
|
|
host,
|
|
port,
|
|
model_path: resolve_model_path(base_dir, &model_path),
|
|
input_size,
|
|
})
|
|
}
|
|
}
|
|
|
|
pub fn resolve_model_path(base_dir: &Path, model_path: &str) -> PathBuf {
|
|
let path = PathBuf::from(model_path);
|
|
if path.is_absolute() {
|
|
path
|
|
} else {
|
|
base_dir.join(path)
|
|
}
|
|
}
|
|
|
|
fn parse_env_u16(name: &str, default: u16) -> Result<u16> {
|
|
match env::var(name) {
|
|
Ok(value) => value
|
|
.parse::<u16>()
|
|
.with_context(|| format!("{name} must be a valid u16")),
|
|
Err(_) => Ok(default),
|
|
}
|
|
}
|
|
|
|
fn parse_env_u32(name: &str, default: u32) -> Result<u32> {
|
|
match env::var(name) {
|
|
Ok(value) => value
|
|
.parse::<u32>()
|
|
.with_context(|| format!("{name} must be a valid u32")),
|
|
Err(_) => Ok(default),
|
|
}
|
|
}
|
|
```
|
|
|
|
Keep the existing tests after this implementation.
|
|
|
|
- [ ] **Step 4: Run tests and verify they pass**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cargo test --manifest-path apps/ml-service/Cargo.toml config
|
|
```
|
|
|
|
Expected: all config tests pass.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add apps/ml-service/src/config.rs apps/ml-service/Cargo.toml
|
|
git commit -m "feat: load ML service config from environment"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Implement HTTP error mapping and response schemas
|
|
|
|
**Files:**
|
|
- Create: `apps/ml-service/src/error.rs`
|
|
- Create: `apps/ml-service/src/routes.rs`
|
|
- Modify: `apps/ml-service/src/main.rs`
|
|
|
|
- [ ] **Step 1: Write error mapping tests**
|
|
|
|
Create `apps/ml-service/src/error.rs`:
|
|
|
|
```rust
|
|
use axum::http::StatusCode;
|
|
use axum::response::{IntoResponse, Response};
|
|
use axum::Json;
|
|
use serde::Serialize;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum ServiceError {
|
|
BadRequest(String),
|
|
ModelUnavailable(String),
|
|
PredictionFailed(String),
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct ErrorResponse {
|
|
detail: String,
|
|
}
|
|
|
|
impl IntoResponse for ServiceError {
|
|
fn into_response(self) -> Response {
|
|
let (status, detail) = match self {
|
|
ServiceError::BadRequest(detail) => (StatusCode::BAD_REQUEST, detail),
|
|
ServiceError::ModelUnavailable(detail) => (StatusCode::SERVICE_UNAVAILABLE, detail),
|
|
ServiceError::PredictionFailed(detail) => (StatusCode::INTERNAL_SERVER_ERROR, detail),
|
|
};
|
|
|
|
(status, Json(ErrorResponse { detail })).into_response()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use axum::body::to_bytes;
|
|
|
|
#[tokio::test]
|
|
async fn bad_request_maps_to_400() {
|
|
let response = ServiceError::BadRequest("Uploaded file must be an image".to_string()).into_response();
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
|
|
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
|
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
|
assert_eq!(value["detail"], "Uploaded file must be an image");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn model_unavailable_maps_to_503() {
|
|
let response = ServiceError::ModelUnavailable("Model is not loaded".to_string()).into_response();
|
|
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn prediction_failed_maps_to_500() {
|
|
let response = ServiceError::PredictionFailed("Prediction failed".to_string()).into_response();
|
|
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run error tests and verify module is not wired**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cargo test --manifest-path apps/ml-service/Cargo.toml error
|
|
```
|
|
|
|
Expected: FAIL because `error.rs` is not declared in `main.rs` yet.
|
|
|
|
- [ ] **Step 3: Wire module and create route response structs**
|
|
|
|
Replace `apps/ml-service/src/main.rs` with:
|
|
|
|
```rust
|
|
mod config;
|
|
mod error;
|
|
mod routes;
|
|
|
|
fn main() {
|
|
println!("zeavis-ml-service");
|
|
}
|
|
```
|
|
|
|
Create `apps/ml-service/src/routes.rs`:
|
|
|
|
```rust
|
|
use crate::config::{LABELS, SERVICE_NAME, SERVICE_VERSION};
|
|
use serde::Serialize;
|
|
use std::collections::BTreeMap;
|
|
|
|
#[derive(Serialize)]
|
|
pub struct HealthResponse {
|
|
pub status: &'static str,
|
|
pub model_loaded: bool,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct MetadataResponse {
|
|
pub service_name: &'static str,
|
|
pub service_version: &'static str,
|
|
pub model_path: String,
|
|
pub model_loaded: bool,
|
|
pub input_size: u32,
|
|
pub labels: Vec<&'static str>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, PartialEq)]
|
|
pub struct PredictionResponse {
|
|
pub label: String,
|
|
pub confidence: f32,
|
|
pub probabilities: BTreeMap<String, f32>,
|
|
}
|
|
|
|
pub fn health_response(model_loaded: bool) -> HealthResponse {
|
|
HealthResponse {
|
|
status: "ok",
|
|
model_loaded,
|
|
}
|
|
}
|
|
|
|
pub fn metadata_response(model_path: String, model_loaded: bool, input_size: u32) -> MetadataResponse {
|
|
MetadataResponse {
|
|
service_name: SERVICE_NAME,
|
|
service_version: SERVICE_VERSION,
|
|
model_path,
|
|
model_loaded,
|
|
input_size,
|
|
labels: LABELS.to_vec(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn health_response_matches_existing_contract() {
|
|
let response = health_response(false);
|
|
let value = serde_json::to_value(response).unwrap();
|
|
|
|
assert_eq!(value["status"], "ok");
|
|
assert_eq!(value["model_loaded"], false);
|
|
}
|
|
|
|
#[test]
|
|
fn metadata_response_matches_existing_contract() {
|
|
let response = metadata_response("/models/model.onnx".to_string(), true, 224);
|
|
let value = serde_json::to_value(response).unwrap();
|
|
|
|
assert_eq!(value["service_name"], "zeavis-ml-service");
|
|
assert_eq!(value["service_version"], env!("CARGO_PKG_VERSION"));
|
|
assert_eq!(value["model_path"], "/models/model.onnx");
|
|
assert_eq!(value["model_loaded"], true);
|
|
assert_eq!(value["input_size"], 224);
|
|
assert_eq!(value["labels"], serde_json::json!(["Bercak Daun", "Daun Sehat", "Karat Daun", "Hawar Daun"]));
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests and verify they pass**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cargo test --manifest-path apps/ml-service/Cargo.toml error routes
|
|
```
|
|
|
|
Expected: error and route response tests pass.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add apps/ml-service/src/main.rs apps/ml-service/src/error.rs apps/ml-service/src/routes.rs
|
|
git commit -m "feat: define ML service API responses"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Implement image preprocessing
|
|
|
|
**Files:**
|
|
- Create: `apps/ml-service/src/image.rs`
|
|
- Modify: `apps/ml-service/src/main.rs`
|
|
|
|
- [ ] **Step 1: Write preprocessing tests**
|
|
|
|
Create `apps/ml-service/src/image.rs`:
|
|
|
|
```rust
|
|
use crate::error::ServiceError;
|
|
use ndarray::Array4;
|
|
|
|
pub fn preprocess_image(_bytes: &[u8], _input_size: u32) -> Result<Array4<f32>, ServiceError> {
|
|
unimplemented!("preprocess image bytes")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use image::{DynamicImage, ImageFormat, RgbImage};
|
|
use std::io::Cursor;
|
|
|
|
fn png_bytes() -> Vec<u8> {
|
|
let mut image = RgbImage::new(2, 1);
|
|
image.put_pixel(0, 0, image::Rgb([10, 20, 30]));
|
|
image.put_pixel(1, 0, image::Rgb([40, 50, 60]));
|
|
|
|
let mut bytes = Vec::new();
|
|
DynamicImage::ImageRgb8(image)
|
|
.write_to(&mut Cursor::new(&mut bytes), ImageFormat::Png)
|
|
.unwrap();
|
|
bytes
|
|
}
|
|
|
|
#[test]
|
|
fn preprocess_returns_nhwc_float32_batch() {
|
|
let tensor = preprocess_image(&png_bytes(), 2).unwrap();
|
|
|
|
assert_eq!(tensor.shape(), &[1, 2, 2, 3]);
|
|
assert_eq!(tensor[[0, 0, 0, 0]], 10.0);
|
|
assert_eq!(tensor[[0, 0, 0, 1]], 20.0);
|
|
assert_eq!(tensor[[0, 0, 0, 2]], 30.0);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_image_returns_bad_request() {
|
|
let error = preprocess_image(b"not an image", 224).unwrap_err();
|
|
|
|
match error {
|
|
ServiceError::BadRequest(detail) => assert_eq!(detail, "Uploaded file is not a valid image"),
|
|
other => panic!("expected bad request, got {other:?}"),
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Wire module and run tests to verify failure**
|
|
|
|
Add `mod image;` to `apps/ml-service/src/main.rs`:
|
|
|
|
```rust
|
|
mod config;
|
|
mod error;
|
|
mod image;
|
|
mod routes;
|
|
|
|
fn main() {
|
|
println!("zeavis-ml-service");
|
|
}
|
|
```
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cargo test --manifest-path apps/ml-service/Cargo.toml image
|
|
```
|
|
|
|
Expected: FAIL because `preprocess_image` is unimplemented.
|
|
|
|
- [ ] **Step 3: Implement preprocessing**
|
|
|
|
Replace `apps/ml-service/src/image.rs` implementation section above the tests with:
|
|
|
|
```rust
|
|
use crate::error::ServiceError;
|
|
use image::imageops::FilterType;
|
|
use ndarray::Array4;
|
|
|
|
pub fn preprocess_image(bytes: &[u8], input_size: u32) -> Result<Array4<f32>, ServiceError> {
|
|
let image = image::load_from_memory(bytes)
|
|
.map_err(|_| ServiceError::BadRequest("Uploaded file is not a valid image".to_string()))?
|
|
.to_rgb8();
|
|
|
|
let resized = image::imageops::resize(&image, input_size, input_size, FilterType::Triangle);
|
|
let size = input_size as usize;
|
|
let mut tensor = Array4::<f32>::zeros((1, size, size, 3));
|
|
|
|
for (x, y, pixel) in resized.enumerate_pixels() {
|
|
let x = x as usize;
|
|
let y = y as usize;
|
|
tensor[[0, y, x, 0]] = pixel[0] as f32;
|
|
tensor[[0, y, x, 1]] = pixel[1] as f32;
|
|
tensor[[0, y, x, 2]] = pixel[2] as f32;
|
|
}
|
|
|
|
Ok(tensor)
|
|
}
|
|
```
|
|
|
|
Keep the existing tests below this implementation.
|
|
|
|
- [ ] **Step 4: Run image tests and verify they pass**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cargo test --manifest-path apps/ml-service/Cargo.toml image
|
|
```
|
|
|
|
Expected: image preprocessing tests pass.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add apps/ml-service/src/main.rs apps/ml-service/src/image.rs
|
|
git commit -m "feat: preprocess uploaded images in Rust"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Implement ONNX model wrapper
|
|
|
|
**Files:**
|
|
- Create: `apps/ml-service/src/model.rs`
|
|
- Modify: `apps/ml-service/src/main.rs`
|
|
|
|
- [ ] **Step 1: Write prediction mapping tests**
|
|
|
|
Create `apps/ml-service/src/model.rs`:
|
|
|
|
```rust
|
|
use crate::config::LABELS;
|
|
use crate::error::ServiceError;
|
|
use ndarray::Array4;
|
|
use std::collections::BTreeMap;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
#[derive(Debug, PartialEq)]
|
|
pub struct Prediction {
|
|
pub label: String,
|
|
pub confidence: f32,
|
|
pub probabilities: BTreeMap<String, f32>,
|
|
}
|
|
|
|
pub struct ModelService {
|
|
model_path: PathBuf,
|
|
input_size: u32,
|
|
loaded: bool,
|
|
}
|
|
|
|
impl ModelService {
|
|
pub fn new(_model_path: &Path, _input_size: u32) -> Self {
|
|
unimplemented!("create model service")
|
|
}
|
|
|
|
pub fn is_loaded(&self) -> bool {
|
|
self.loaded
|
|
}
|
|
|
|
pub fn model_path(&self) -> &Path {
|
|
&self.model_path
|
|
}
|
|
|
|
pub fn input_size(&self) -> u32 {
|
|
self.input_size
|
|
}
|
|
|
|
pub fn predict(&self, _input: Array4<f32>) -> Result<Prediction, ServiceError> {
|
|
unimplemented!("run ONNX inference")
|
|
}
|
|
}
|
|
|
|
pub fn prediction_from_probabilities(probabilities: &[f32]) -> Result<Prediction, ServiceError> {
|
|
if probabilities.len() != LABELS.len() {
|
|
return Err(ServiceError::PredictionFailed("Prediction failed".to_string()));
|
|
}
|
|
|
|
let mut top_index = 0usize;
|
|
let mut top_value = probabilities[0];
|
|
let mut mapped = BTreeMap::new();
|
|
|
|
for (index, label) in LABELS.iter().enumerate() {
|
|
let value = probabilities[index];
|
|
if value > top_value {
|
|
top_index = index;
|
|
top_value = value;
|
|
}
|
|
mapped.insert((*label).to_string(), value);
|
|
}
|
|
|
|
Ok(Prediction {
|
|
label: LABELS[top_index].to_string(),
|
|
confidence: top_value,
|
|
probabilities: mapped,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn prediction_mapping_selects_top_label_and_all_probabilities() {
|
|
let prediction = prediction_from_probabilities(&[0.1, 0.2, 0.6, 0.1]).unwrap();
|
|
|
|
assert_eq!(prediction.label, "Karat Daun");
|
|
assert_eq!(prediction.confidence, 0.6);
|
|
assert_eq!(prediction.probabilities["Bercak Daun"], 0.1);
|
|
assert_eq!(prediction.probabilities["Daun Sehat"], 0.2);
|
|
assert_eq!(prediction.probabilities["Karat Daun"], 0.6);
|
|
assert_eq!(prediction.probabilities["Hawar Daun"], 0.1);
|
|
}
|
|
|
|
#[test]
|
|
fn prediction_mapping_rejects_wrong_output_length() {
|
|
let error = prediction_from_probabilities(&[0.1, 0.2]).unwrap_err();
|
|
|
|
match error {
|
|
ServiceError::PredictionFailed(detail) => assert_eq!(detail, "Prediction failed"),
|
|
other => panic!("expected prediction failure, got {other:?}"),
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Wire module and run tests to verify current failures**
|
|
|
|
Add `mod model;` to `apps/ml-service/src/main.rs`:
|
|
|
|
```rust
|
|
mod config;
|
|
mod error;
|
|
mod image;
|
|
mod model;
|
|
mod routes;
|
|
|
|
fn main() {
|
|
println!("zeavis-ml-service");
|
|
}
|
|
```
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cargo test --manifest-path apps/ml-service/Cargo.toml model
|
|
```
|
|
|
|
Expected: mapping tests pass, but `ModelService::new` and `predict` are still unimplemented for runtime behavior.
|
|
|
|
- [ ] **Step 3: Implement ONNX session storage**
|
|
|
|
Replace `apps/ml-service/src/model.rs` with:
|
|
|
|
```rust
|
|
use crate::config::LABELS;
|
|
use crate::error::ServiceError;
|
|
use ndarray::Array4;
|
|
use ort::session::Session;
|
|
use ort::value::TensorRef;
|
|
use std::collections::BTreeMap;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Mutex;
|
|
|
|
#[derive(Debug, PartialEq)]
|
|
pub struct Prediction {
|
|
pub label: String,
|
|
pub confidence: f32,
|
|
pub probabilities: BTreeMap<String, f32>,
|
|
}
|
|
|
|
pub struct ModelService {
|
|
model_path: PathBuf,
|
|
input_size: u32,
|
|
session: Option<Mutex<Session>>,
|
|
}
|
|
|
|
impl ModelService {
|
|
pub fn new(model_path: &Path, input_size: u32) -> Self {
|
|
let session = Session::builder()
|
|
.and_then(|builder| builder.commit_from_file(model_path))
|
|
.map(Mutex::new)
|
|
.ok();
|
|
|
|
Self {
|
|
model_path: model_path.to_path_buf(),
|
|
input_size,
|
|
session,
|
|
}
|
|
}
|
|
|
|
pub fn is_loaded(&self) -> bool {
|
|
self.session.is_some()
|
|
}
|
|
|
|
pub fn model_path(&self) -> &Path {
|
|
&self.model_path
|
|
}
|
|
|
|
pub fn input_size(&self) -> u32 {
|
|
self.input_size
|
|
}
|
|
|
|
pub fn predict(&self, input: Array4<f32>) -> Result<Prediction, ServiceError> {
|
|
let session = self
|
|
.session
|
|
.as_ref()
|
|
.ok_or_else(|| ServiceError::ModelUnavailable("Model is not loaded".to_string()))?;
|
|
|
|
let input = TensorRef::from_array_view(input.view())
|
|
.map_err(|_| ServiceError::PredictionFailed("Prediction failed".to_string()))?;
|
|
let mut session = session
|
|
.lock()
|
|
.map_err(|_| ServiceError::PredictionFailed("Prediction failed".to_string()))?;
|
|
let outputs = session
|
|
.run(ort::inputs![input])
|
|
.map_err(|_| ServiceError::PredictionFailed("Prediction failed".to_string()))?;
|
|
let output = outputs
|
|
.values()
|
|
.next()
|
|
.ok_or_else(|| ServiceError::PredictionFailed("Prediction failed".to_string()))?;
|
|
let probabilities = output
|
|
.try_extract_tensor::<f32>()
|
|
.map_err(|_| ServiceError::PredictionFailed("Prediction failed".to_string()))?;
|
|
let probabilities: Vec<f32> = probabilities.view().iter().copied().collect();
|
|
|
|
prediction_from_probabilities(&probabilities)
|
|
}
|
|
}
|
|
|
|
pub fn prediction_from_probabilities(probabilities: &[f32]) -> Result<Prediction, ServiceError> {
|
|
if probabilities.len() != LABELS.len() {
|
|
return Err(ServiceError::PredictionFailed("Prediction failed".to_string()));
|
|
}
|
|
|
|
let mut top_index = 0usize;
|
|
let mut top_value = probabilities[0];
|
|
let mut mapped = BTreeMap::new();
|
|
|
|
for (index, label) in LABELS.iter().enumerate() {
|
|
let value = probabilities[index];
|
|
if value > top_value {
|
|
top_index = index;
|
|
top_value = value;
|
|
}
|
|
mapped.insert((*label).to_string(), value);
|
|
}
|
|
|
|
Ok(Prediction {
|
|
label: LABELS[top_index].to_string(),
|
|
confidence: top_value,
|
|
probabilities: mapped,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn prediction_mapping_selects_top_label_and_all_probabilities() {
|
|
let prediction = prediction_from_probabilities(&[0.1, 0.2, 0.6, 0.1]).unwrap();
|
|
|
|
assert_eq!(prediction.label, "Karat Daun");
|
|
assert_eq!(prediction.confidence, 0.6);
|
|
assert_eq!(prediction.probabilities["Bercak Daun"], 0.1);
|
|
assert_eq!(prediction.probabilities["Daun Sehat"], 0.2);
|
|
assert_eq!(prediction.probabilities["Karat Daun"], 0.6);
|
|
assert_eq!(prediction.probabilities["Hawar Daun"], 0.1);
|
|
}
|
|
|
|
#[test]
|
|
fn prediction_mapping_rejects_wrong_output_length() {
|
|
let error = prediction_from_probabilities(&[0.1, 0.2]).unwrap_err();
|
|
|
|
match error {
|
|
ServiceError::PredictionFailed(detail) => assert_eq!(detail, "Prediction failed"),
|
|
other => panic!("expected prediction failure, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn missing_model_file_creates_unloaded_service() {
|
|
let service = ModelService::new(Path::new("/missing/model.onnx"), 224);
|
|
|
|
assert!(!service.is_loaded());
|
|
assert_eq!(service.model_path(), Path::new("/missing/model.onnx"));
|
|
assert_eq!(service.input_size(), 224);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run model tests and fix any ort API mismatch**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cargo test --manifest-path apps/ml-service/Cargo.toml model
|
|
```
|
|
|
|
Expected: all model tests pass. If the `ort` API differs from the snippets above, inspect compiler errors and update only `ModelService::new` and `ModelService::predict` to the equivalent current `ort` calls while preserving the public methods and tests.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add apps/ml-service/src/main.rs apps/ml-service/src/model.rs apps/ml-service/Cargo.toml
|
|
git commit -m "feat: add ONNX model inference wrapper"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Implement Axum routes and app startup
|
|
|
|
**Files:**
|
|
- Modify: `apps/ml-service/src/routes.rs`
|
|
- Modify: `apps/ml-service/src/main.rs`
|
|
|
|
- [ ] **Step 1: Add app state and route handler tests**
|
|
|
|
Replace `apps/ml-service/src/routes.rs` with:
|
|
|
|
```rust
|
|
use crate::config::{LABELS, SERVICE_NAME, SERVICE_VERSION};
|
|
use crate::error::ServiceError;
|
|
use crate::image::preprocess_image;
|
|
use crate::model::{ModelService, Prediction};
|
|
use axum::extract::{Multipart, State};
|
|
use axum::{routing::get, routing::post, Json, Router};
|
|
use serde::Serialize;
|
|
use std::collections::BTreeMap;
|
|
use std::sync::Arc;
|
|
|
|
#[derive(Clone)]
|
|
pub struct AppState {
|
|
pub model: Arc<ModelService>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct HealthResponse {
|
|
pub status: &'static str,
|
|
pub model_loaded: bool,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct MetadataResponse {
|
|
pub service_name: &'static str,
|
|
pub service_version: &'static str,
|
|
pub model_path: String,
|
|
pub model_loaded: bool,
|
|
pub input_size: u32,
|
|
pub labels: Vec<&'static str>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, PartialEq)]
|
|
pub struct PredictionResponse {
|
|
pub label: String,
|
|
pub confidence: f32,
|
|
pub probabilities: BTreeMap<String, f32>,
|
|
}
|
|
|
|
pub fn router(state: AppState) -> Router {
|
|
Router::new()
|
|
.route("/health", get(health))
|
|
.route("/metadata", get(metadata))
|
|
.route("/predict", post(predict))
|
|
.with_state(state)
|
|
}
|
|
|
|
pub async fn health(State(state): State<AppState>) -> Json<HealthResponse> {
|
|
Json(health_response(state.model.is_loaded()))
|
|
}
|
|
|
|
pub async fn metadata(State(state): State<AppState>) -> Json<MetadataResponse> {
|
|
Json(metadata_response(
|
|
state.model.model_path().display().to_string(),
|
|
state.model.is_loaded(),
|
|
state.model.input_size(),
|
|
))
|
|
}
|
|
|
|
pub async fn predict(
|
|
State(state): State<AppState>,
|
|
mut multipart: Multipart,
|
|
) -> Result<Json<PredictionResponse>, ServiceError> {
|
|
let mut image_bytes = None;
|
|
|
|
while let Some(field) = multipart
|
|
.next_field()
|
|
.await
|
|
.map_err(|_| ServiceError::BadRequest("Uploaded file must be an image".to_string()))?
|
|
{
|
|
if field.name() == Some("file") {
|
|
if let Some(content_type) = field.content_type() {
|
|
if !content_type.starts_with("image/") {
|
|
return Err(ServiceError::BadRequest("Uploaded file must be an image".to_string()));
|
|
}
|
|
}
|
|
|
|
image_bytes = Some(
|
|
field
|
|
.bytes()
|
|
.await
|
|
.map_err(|_| ServiceError::BadRequest("Uploaded file must be an image".to_string()))?,
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
|
|
let image_bytes = image_bytes
|
|
.ok_or_else(|| ServiceError::BadRequest("Uploaded file must be an image".to_string()))?;
|
|
let input = preprocess_image(&image_bytes, state.model.input_size())?;
|
|
let prediction = state.model.predict(input)?;
|
|
|
|
Ok(Json(prediction_response(prediction)))
|
|
}
|
|
|
|
pub fn health_response(model_loaded: bool) -> HealthResponse {
|
|
HealthResponse {
|
|
status: "ok",
|
|
model_loaded,
|
|
}
|
|
}
|
|
|
|
pub fn metadata_response(model_path: String, model_loaded: bool, input_size: u32) -> MetadataResponse {
|
|
MetadataResponse {
|
|
service_name: SERVICE_NAME,
|
|
service_version: SERVICE_VERSION,
|
|
model_path,
|
|
model_loaded,
|
|
input_size,
|
|
labels: LABELS.to_vec(),
|
|
}
|
|
}
|
|
|
|
pub fn prediction_response(prediction: Prediction) -> PredictionResponse {
|
|
PredictionResponse {
|
|
label: prediction.label,
|
|
confidence: prediction.confidence,
|
|
probabilities: prediction.probabilities,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::model::prediction_from_probabilities;
|
|
|
|
#[test]
|
|
fn health_response_matches_existing_contract() {
|
|
let response = health_response(false);
|
|
let value = serde_json::to_value(response).unwrap();
|
|
|
|
assert_eq!(value["status"], "ok");
|
|
assert_eq!(value["model_loaded"], false);
|
|
}
|
|
|
|
#[test]
|
|
fn metadata_response_matches_existing_contract() {
|
|
let response = metadata_response("/models/model.onnx".to_string(), true, 224);
|
|
let value = serde_json::to_value(response).unwrap();
|
|
|
|
assert_eq!(value["service_name"], "zeavis-ml-service");
|
|
assert_eq!(value["service_version"], env!("CARGO_PKG_VERSION"));
|
|
assert_eq!(value["model_path"], "/models/model.onnx");
|
|
assert_eq!(value["model_loaded"], true);
|
|
assert_eq!(value["input_size"], 224);
|
|
assert_eq!(value["labels"], serde_json::json!(["Bercak Daun", "Daun Sehat", "Karat Daun", "Hawar Daun"]));
|
|
}
|
|
|
|
#[test]
|
|
fn prediction_response_matches_existing_contract() {
|
|
let prediction = prediction_from_probabilities(&[0.1, 0.2, 0.6, 0.1]).unwrap();
|
|
let response = prediction_response(prediction);
|
|
let value = serde_json::to_value(response).unwrap();
|
|
|
|
assert_eq!(value["label"], "Karat Daun");
|
|
assert_eq!(value["confidence"], 0.6);
|
|
assert_eq!(value["probabilities"]["Karat Daun"], 0.6);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run route tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cargo test --manifest-path apps/ml-service/Cargo.toml routes
|
|
```
|
|
|
|
Expected: route response tests pass.
|
|
|
|
- [ ] **Step 3: Implement async main startup**
|
|
|
|
Replace `apps/ml-service/src/main.rs` with:
|
|
|
|
```rust
|
|
mod config;
|
|
mod error;
|
|
mod image;
|
|
mod model;
|
|
mod routes;
|
|
|
|
use anyhow::Context;
|
|
use config::Config;
|
|
use model::ModelService;
|
|
use routes::{router, AppState};
|
|
use std::sync::Arc;
|
|
use tokio::net::TcpListener;
|
|
use tracing_subscriber::EnvFilter;
|
|
|
|
#[tokio::main]
|
|
async fn main() -> anyhow::Result<()> {
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(EnvFilter::from_default_env())
|
|
.init();
|
|
|
|
let config = Config::from_env()?;
|
|
let model = Arc::new(ModelService::new(&config.model_path, config.input_size));
|
|
let address = format!("{}:{}", config.host, config.port);
|
|
let listener = TcpListener::bind(&address)
|
|
.await
|
|
.with_context(|| format!("failed to bind {address}"))?;
|
|
|
|
tracing::info!(address, model_loaded = model.is_loaded(), "starting ML service");
|
|
|
|
axum::serve(listener, router(AppState { model })).await?;
|
|
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run full Rust tests and build**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cargo test --manifest-path apps/ml-service/Cargo.toml
|
|
cargo build --manifest-path apps/ml-service/Cargo.toml --release
|
|
```
|
|
|
|
Expected: tests pass and release binary builds.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add apps/ml-service/src/routes.rs apps/ml-service/src/main.rs
|
|
git commit -m "feat: serve ML inference endpoints with Axum"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Replace ML service runtime files and tasks
|
|
|
|
**Files:**
|
|
- Modify: `apps/ml-service/moon.yml`
|
|
- Modify: `apps/ml-service/.env.example`
|
|
- Remove: `apps/ml-service/main.py`
|
|
- Remove: `apps/ml-service/model.py`
|
|
- Remove: `apps/ml-service/schemas.py`
|
|
- Remove: `apps/ml-service/test_model.py`
|
|
- Remove: `apps/ml-service/requirements.txt`
|
|
|
|
- [ ] **Step 1: Update Moon tasks**
|
|
|
|
Replace `apps/ml-service/moon.yml` with:
|
|
|
|
```yaml
|
|
tasks:
|
|
dev:
|
|
command: cargo run
|
|
typecheck:
|
|
command: cargo check
|
|
inputs:
|
|
- Cargo.toml
|
|
- src/**/*.rs
|
|
test:
|
|
command: cargo test
|
|
inputs:
|
|
- Cargo.toml
|
|
- src/**/*.rs
|
|
build:
|
|
command: cargo build --release
|
|
inputs:
|
|
- Cargo.toml
|
|
- src/**/*.rs
|
|
```
|
|
|
|
- [ ] **Step 2: Update local env example**
|
|
|
|
Replace `apps/ml-service/.env.example` with:
|
|
|
|
```env
|
|
MODEL_PATH=../../Machine_Learning/model/model.onnx
|
|
MODEL_INPUT_SIZE=224
|
|
ML_SERVICE_HOST=0.0.0.0
|
|
ML_SERVICE_PORT=8001
|
|
```
|
|
|
|
- [ ] **Step 3: Remove Python serving files**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
rm apps/ml-service/main.py apps/ml-service/model.py apps/ml-service/schemas.py apps/ml-service/test_model.py apps/ml-service/requirements.txt
|
|
```
|
|
|
|
Expected: Python service files are removed. Do not remove `.venv` or `__pycache__` in this task unless they are tracked by git.
|
|
|
|
- [ ] **Step 4: Run Rust service checks**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cargo test --manifest-path apps/ml-service/Cargo.toml
|
|
cargo check --manifest-path apps/ml-service/Cargo.toml
|
|
```
|
|
|
|
Expected: tests and check pass.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add apps/ml-service/moon.yml apps/ml-service/.env.example apps/ml-service/Cargo.toml apps/ml-service/src
|
|
git rm apps/ml-service/main.py apps/ml-service/model.py apps/ml-service/schemas.py apps/ml-service/test_model.py apps/ml-service/requirements.txt
|
|
git commit -m "refactor: replace Python ML service runtime with Rust"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Add ONNX conversion script
|
|
|
|
**Files:**
|
|
- Create: `Machine_Learning/convert_onnx.py`
|
|
- Modify: `Machine_Learning/requirements.txt`
|
|
|
|
- [ ] **Step 1: Add conversion dependencies**
|
|
|
|
Append these lines to `Machine_Learning/requirements.txt` if they are not present:
|
|
|
|
```txt
|
|
tf2onnx>=1.16.1
|
|
onnx>=1.16.0
|
|
onnxruntime>=1.17.0
|
|
```
|
|
|
|
- [ ] **Step 2: Write conversion script**
|
|
|
|
Create `Machine_Learning/convert_onnx.py`:
|
|
|
|
```python
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
|
|
|
|
DEFAULT_SAVED_MODEL_PATH = Path("model/saved_model")
|
|
DEFAULT_OUTPUT_PATH = Path("model/model.onnx")
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description="Convert ZeaVis SavedModel export to ONNX.")
|
|
parser.add_argument("--saved-model", type=Path, default=DEFAULT_SAVED_MODEL_PATH)
|
|
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT_PATH)
|
|
parser.add_argument("--opset", type=int, default=13)
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> None:
|
|
args = parse_args()
|
|
|
|
if not args.saved_model.exists():
|
|
raise FileNotFoundError(f"SavedModel directory not found: {args.saved_model}")
|
|
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
command = [
|
|
sys.executable,
|
|
"-m",
|
|
"tf2onnx.convert",
|
|
"--saved-model",
|
|
str(args.saved_model),
|
|
"--output",
|
|
str(args.output),
|
|
"--opset",
|
|
str(args.opset),
|
|
]
|
|
subprocess.run(command, check=True)
|
|
print(f"ONNX model exported to {args.output}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
```
|
|
|
|
- [ ] **Step 3: Compile-check script**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
python -m py_compile Machine_Learning/convert_onnx.py
|
|
```
|
|
|
|
Expected: command exits successfully.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add Machine_Learning/requirements.txt Machine_Learning/convert_onnx.py
|
|
git commit -m "feat: add ONNX conversion script"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Add manual Keras vs ONNX parity validation
|
|
|
|
**Files:**
|
|
- Create: `Machine_Learning/validate_onnx_parity.py`
|
|
|
|
- [ ] **Step 1: Write parity validation script**
|
|
|
|
Create `Machine_Learning/validate_onnx_parity.py`:
|
|
|
|
```python
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import onnxruntime as ort
|
|
from PIL import Image, UnidentifiedImageError
|
|
import tensorflow as tf
|
|
|
|
|
|
LABELS = ["Bercak Daun", "Daun Sehat", "Karat Daun", "Hawar Daun"]
|
|
DEFAULT_KERAS_MODEL_PATH = Path("best_model/best_model.keras")
|
|
DEFAULT_ONNX_MODEL_PATH = Path("model/model.onnx")
|
|
|
|
|
|
class ParityError(RuntimeError):
|
|
pass
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description="Validate Keras and ONNX predictions match for sample images.")
|
|
parser.add_argument("images", nargs="+", type=Path)
|
|
parser.add_argument("--keras-model", type=Path, default=DEFAULT_KERAS_MODEL_PATH)
|
|
parser.add_argument("--onnx-model", type=Path, default=DEFAULT_ONNX_MODEL_PATH)
|
|
parser.add_argument("--input-size", type=int, default=224)
|
|
parser.add_argument("--atol", type=float, default=1e-4)
|
|
return parser.parse_args()
|
|
|
|
|
|
def preprocess_image(image_path: Path, input_size: int) -> np.ndarray:
|
|
try:
|
|
image = Image.open(image_path).convert("RGB")
|
|
except (UnidentifiedImageError, OSError) as exc:
|
|
raise ParityError(f"Invalid image: {image_path}") from exc
|
|
|
|
image = image.resize((input_size, input_size))
|
|
image_array = np.asarray(image, dtype=np.float32)
|
|
return np.expand_dims(image_array, axis=0)
|
|
|
|
|
|
def predict_keras(model: tf.keras.Model, batch: np.ndarray) -> np.ndarray:
|
|
return np.asarray(model.predict(batch, verbose=0)[0], dtype=np.float32)
|
|
|
|
|
|
def predict_onnx(session: ort.InferenceSession, batch: np.ndarray) -> np.ndarray:
|
|
input_name = session.get_inputs()[0].name
|
|
predictions = session.run(None, {input_name: batch})[0][0]
|
|
return np.asarray(predictions, dtype=np.float32)
|
|
|
|
|
|
def validate_image(image_path: Path, keras_model: tf.keras.Model, onnx_session: ort.InferenceSession, input_size: int, atol: float) -> None:
|
|
batch = preprocess_image(image_path, input_size)
|
|
keras_probs = predict_keras(keras_model, batch)
|
|
onnx_probs = predict_onnx(onnx_session, batch)
|
|
|
|
keras_top = int(np.argmax(keras_probs))
|
|
onnx_top = int(np.argmax(onnx_probs))
|
|
|
|
if keras_top != onnx_top:
|
|
raise ParityError(
|
|
f"Top-1 mismatch for {image_path}: Keras={LABELS[keras_top]} ONNX={LABELS[onnx_top]}"
|
|
)
|
|
|
|
if not np.allclose(keras_probs, onnx_probs, atol=atol):
|
|
raise ParityError(
|
|
f"Probability mismatch for {image_path}: Keras={keras_probs.tolist()} ONNX={onnx_probs.tolist()}"
|
|
)
|
|
|
|
print(f"PASS {image_path}: {LABELS[keras_top]}")
|
|
|
|
|
|
def main() -> None:
|
|
args = parse_args()
|
|
|
|
if not args.keras_model.exists():
|
|
raise FileNotFoundError(f"Keras model not found: {args.keras_model}")
|
|
if not args.onnx_model.exists():
|
|
raise FileNotFoundError(f"ONNX model not found: {args.onnx_model}")
|
|
|
|
keras_model = tf.keras.models.load_model(args.keras_model, compile=False)
|
|
onnx_session = ort.InferenceSession(str(args.onnx_model), providers=["CPUExecutionProvider"])
|
|
|
|
for image_path in args.images:
|
|
validate_image(image_path, keras_model, onnx_session, args.input_size, args.atol)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
```
|
|
|
|
- [ ] **Step 2: Compile-check script**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
python -m py_compile Machine_Learning/validate_onnx_parity.py
|
|
```
|
|
|
|
Expected: command exits successfully.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add Machine_Learning/validate_onnx_parity.py
|
|
git commit -m "test: add ONNX parity validation script"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Update Docker image for Rust ML service
|
|
|
|
**Files:**
|
|
- Modify: `apps/ml-service/Dockerfile`
|
|
|
|
- [ ] **Step 1: Replace Dockerfile with Rust multi-stage image**
|
|
|
|
Replace `apps/ml-service/Dockerfile` with:
|
|
|
|
```dockerfile
|
|
FROM rust:1.82-bookworm AS builder
|
|
|
|
WORKDIR /app
|
|
COPY apps/ml-service/Cargo.toml ./Cargo.toml
|
|
COPY apps/ml-service/src ./src
|
|
RUN cargo build --release
|
|
|
|
FROM debian:bookworm-slim AS runner
|
|
|
|
WORKDIR /app
|
|
ENV MODEL_PATH=/app/model/model.onnx
|
|
ENV MODEL_INPUT_SIZE=224
|
|
ENV ML_SERVICE_HOST=0.0.0.0
|
|
ENV ML_SERVICE_PORT=8000
|
|
ENV RUST_LOG=info
|
|
|
|
RUN apt-get update \
|
|
&& apt-get install -y --no-install-recommends ca-certificates \
|
|
&& rm -rf /var/lib/apt/lists/*
|
|
|
|
COPY --from=builder /app/target/release/zeavis-ml-service /usr/local/bin/zeavis-ml-service
|
|
COPY Machine_Learning/model/model.onnx /app/model/model.onnx
|
|
|
|
EXPOSE 8000
|
|
CMD ["zeavis-ml-service"]
|
|
```
|
|
|
|
- [ ] **Step 2: Build Rust binary before Docker build**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cargo build --manifest-path apps/ml-service/Cargo.toml --release
|
|
```
|
|
|
|
Expected: release binary builds locally.
|
|
|
|
- [ ] **Step 3: Document model artifact requirement for Docker build in commit context**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
git diff -- apps/ml-service/Dockerfile
|
|
```
|
|
|
|
Expected: Dockerfile copies `Machine_Learning/model/model.onnx`; Docker build will require this generated artifact just like the previous image required `best_model.keras`.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add apps/ml-service/Dockerfile
|
|
git commit -m "build: containerize Rust ML service"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Update README documentation
|
|
|
|
**Files:**
|
|
- Modify: `README.md`
|
|
- Modify: `Machine_Learning/README.md`
|
|
- Create or Modify: `apps/ml-service/README.md`
|
|
|
|
- [ ] **Step 1: Update root README ML service sections**
|
|
|
|
In `README.md`, make these exact content changes:
|
|
|
|
- Replace `ML service berbasis FastAPI untuk inferensi penyakit daun jagung dari gambar.` with `ML service berbasis Rust, Axum, dan ONNX Runtime untuk inferensi penyakit daun jagung dari gambar.`
|
|
- Replace tech stack bullets `Python`, `TensorFlow/Keras`, `EfficientNetV2B0`, `FastAPI`, `Uvicorn`, `TFLite`, `TensorFlow.js` under `### Machine Learning` with:
|
|
|
|
```markdown
|
|
- Python untuk preprocessing, training, dan ekspor model
|
|
- TensorFlow/Keras
|
|
- EfficientNetV2B0
|
|
- Rust
|
|
- Axum
|
|
- ONNX Runtime
|
|
- TFLite
|
|
- TensorFlow.js
|
|
```
|
|
|
|
- Replace the ML service local run block with:
|
|
|
|
```markdown
|
|
### ML Service
|
|
|
|
```bash
|
|
cd apps/ml-service
|
|
cargo run
|
|
```
|
|
|
|
Default path model adalah:
|
|
|
|
```text
|
|
../../Machine_Learning/model/model.onnx
|
|
```
|
|
|
|
Jika model berada di lokasi lain, gunakan environment variable `MODEL_PATH`.
|
|
```
|
|
|
|
- Add `Machine_Learning/model/model.onnx` to artifact tables and generated artifact lists as the ONNX model used by the Rust service.
|
|
- Replace troubleshooting that points to `best_model.keras` for serving with `Machine_Learning/model/model.onnx` and show:
|
|
|
|
```bash
|
|
MODEL_PATH=/path/to/model.onnx cargo run
|
|
```
|
|
|
|
- [ ] **Step 2: Update Machine Learning README export section**
|
|
|
|
In `Machine_Learning/README.md`, after the SavedModel/TFLite export instructions, add this section:
|
|
|
|
```markdown
|
|
### Langkah 2: Konversi ke ONNX (untuk Rust ML Service)
|
|
|
|
Setelah `model/saved_model/` tersedia, jalankan:
|
|
|
|
```bash
|
|
python convert_onnx.py
|
|
```
|
|
|
|
Output default:
|
|
|
|
```text
|
|
model/model.onnx
|
|
```
|
|
|
|
Model ONNX ini digunakan oleh service Rust di `apps/ml-service`.
|
|
|
|
Untuk memvalidasi hasil ONNX terhadap model Keras, jalankan parity check manual dengan satu atau lebih gambar contoh:
|
|
|
|
```bash
|
|
python validate_onnx_parity.py /path/to/corn-leaf.jpg
|
|
```
|
|
|
|
Validasi ini mengecek label top-1 dan kedekatan probabilitas antara Keras dan ONNX.
|
|
```
|
|
|
|
Also add `model/model.onnx` to the final output table with usage `Inferensi server-side via Rust ONNX Runtime`.
|
|
|
|
- [ ] **Step 3: Create ML service README**
|
|
|
|
Create `apps/ml-service/README.md`:
|
|
|
|
```markdown
|
|
# ZeaVis ML Service
|
|
|
|
Rust service for corn leaf disease inference using Axum and ONNX Runtime.
|
|
|
|
## Requirements
|
|
|
|
- Rust stable toolchain
|
|
- ONNX model at `../../Machine_Learning/model/model.onnx`
|
|
|
|
## Run locally
|
|
|
|
```bash
|
|
cargo run
|
|
```
|
|
|
|
The service listens on `0.0.0.0:8001` when `ML_SERVICE_PORT=8001` is set in local env files. In production Docker it listens on port `8000`.
|
|
|
|
## Environment variables
|
|
|
|
| Variable | Default | Description |
|
|
|---|---|---|
|
|
| `MODEL_PATH` | `../../Machine_Learning/model/model.onnx` | ONNX model path |
|
|
| `MODEL_INPUT_SIZE` | `224` | Input image size |
|
|
| `ML_SERVICE_HOST` | `0.0.0.0` | Bind host |
|
|
| `ML_SERVICE_PORT` | `8000` | Bind port |
|
|
|
|
## Endpoints
|
|
|
|
```bash
|
|
curl http://localhost:8001/health
|
|
curl http://localhost:8001/metadata
|
|
curl -X POST http://localhost:8001/predict -F "file=@/path/to/corn-leaf.jpg"
|
|
```
|
|
|
|
## Verification
|
|
|
|
```bash
|
|
cargo test
|
|
cargo build --release
|
|
```
|
|
```
|
|
|
|
- [ ] **Step 4: Review documentation for stale FastAPI/Uvicorn runtime references**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
grep -R "FastAPI\|Uvicorn\|uvicorn\|best_model.keras" -n README.md apps/ml-service Machine_Learning/README.md
|
|
```
|
|
|
|
Expected: FastAPI/Uvicorn should not appear as the current serving runtime. `best_model.keras` may still appear only in training/export documentation.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add README.md Machine_Learning/README.md apps/ml-service/README.md
|
|
git commit -m "docs: document Rust ONNX ML service"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 12: Final verification
|
|
|
|
**Files:**
|
|
- No planned edits unless verification finds a defect.
|
|
|
|
- [ ] **Step 1: Run Rust tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cargo test --manifest-path apps/ml-service/Cargo.toml
|
|
```
|
|
|
|
Expected: all tests pass.
|
|
|
|
- [ ] **Step 2: Run Rust release build**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cargo build --manifest-path apps/ml-service/Cargo.toml --release
|
|
```
|
|
|
|
Expected: release build succeeds.
|
|
|
|
- [ ] **Step 3: Compile-check ML scripts**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
python -m py_compile Machine_Learning/convert_onnx.py Machine_Learning/validate_onnx_parity.py
|
|
```
|
|
|
|
Expected: command exits successfully.
|
|
|
|
- [ ] **Step 4: Run root typecheck if available**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
bun run typecheck
|
|
```
|
|
|
|
Expected: Moon typecheck tasks pass. If this fails because the root workspace assumes Python files that were removed, update the relevant Moon task to point at Rust cargo commands and rerun.
|
|
|
|
- [ ] **Step 5: Optional endpoint verification with real ONNX model**
|
|
|
|
Only run this if `Machine_Learning/model/model.onnx` exists:
|
|
|
|
```bash
|
|
cd apps/ml-service
|
|
ML_SERVICE_PORT=8001 cargo run
|
|
```
|
|
|
|
In another shell:
|
|
|
|
```bash
|
|
curl http://localhost:8001/health
|
|
curl http://localhost:8001/metadata
|
|
curl -X POST http://localhost:8001/predict -F "file=@/path/to/corn-leaf.jpg"
|
|
```
|
|
|
|
Expected: `/health` and `/metadata` return JSON matching the existing contract. `/predict` returns `label`, `confidence`, and `probabilities` when a valid image is provided.
|
|
|
|
- [ ] **Step 6: Inspect git status**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
git status --short
|
|
```
|
|
|
|
Expected: no unintended untracked files. Large generated artifacts such as `model.onnx` should not be committed unless repository policy explicitly allows it.
|
|
|
|
- [ ] **Step 7: Commit verification fixes if any**
|
|
|
|
If verification required fixes, commit them:
|
|
|
|
```bash
|
|
git add <changed-files>
|
|
git commit -m "fix: align Rust ML service verification"
|
|
```
|
|
|
|
If no fixes were needed, do not create an empty commit.
|
|
|
|
---
|
|
|
|
## Self-review notes
|
|
|
|
- Spec coverage: Rust Axum replacement, API compatibility, ONNX runtime, preprocessing, ONNX conversion, parity validation, Docker, docs, and verification are all mapped to tasks.
|
|
- Placeholder scan: no `TBD`, `TODO`, `FIXME`, or intentionally vague implementation steps remain.
|
|
- Type consistency: config, route response, model prediction, and error names are consistent across tasks.
|