Cocoa Disease Localization
YOLO + MLflow + OpenVINO
This project develops an end-to-end pipeline to detect and localize key cocoa diseases from images using YOLO. The workflow covers annotation review, dataset preparation, experiment iteration with MLflow on DagsHub, candidate selection and full training, multi-format model export (PyTorch, ONNX, OpenVINO), inference benchmarking, and deployment as an interactive Gradio app on Hugging Face Spaces. The final model selected is YOLO11n (OpenVINO) based on a balance of accuracy and inference speed.
Project Objective
Build a reproducible, tracked ML pipeline to localize multiple cocoa diseases with YOLO and deploy the fastest validated model for real-time inference on Intel hardware.
Import Libraries
¶#!pip install -q dagshub 'mlflow>=2,<3'
!pip install -q mlflow ultralytics "openvino>=2024.0.0"
!pip install -q "onnxslim>=0.1.67" onnxruntime onnxruntime-tools
# Plot annotated predictions
import requests
from PIL import Image
from io import BytesIO
import matplotlib.pyplot as plt
def _add_text_with_border(label, x, y, img, color=(0, 255, 0), font_scale = 1.5):
font = cv2.FONT_HERSHEY_SIMPLEX
# Draw black border first
cv2.putText(img, label, (x, y), font, font_scale, (0,0,0), thickness=int(font_scale*10/1.5), lineType=cv2.LINE_AA)
# Draw colored text on top
cv2.putText(img, label, (x, y), font, font_scale, color, thickness=int(font_scale*3/1.5), lineType=cv2.LINE_AA)
def _add_box_with_contrast(img, color, x1,y1,x2,y2):
cv2.rectangle(img, (x1,y1), (x2,y2), (0,0,0), thickness=10)
cv2.rectangle(img, (x1,y1), (x2,y2), color, thickness=3)
def predict(image, model, conf=0.25, iou=0.45, from_internet=False, font_scale = 1.5):
if from_internet:
response = requests.get(image)
image = Image.open(BytesIO(response.content)).convert("RGB")
img_bgr = np.array(image)
else:
img_bgr = cv2.imread(image)
results = model.predict(source=image, imgsz=640, conf=conf, iou=iou, device='cpu', stream=False)
r = results[0]
#img_bgr = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
for box, score, cls in zip(r.boxes.xyxy.cpu().numpy(),
r.boxes.conf.cpu().numpy(),
r.boxes.cls.cpu().numpy().astype(int)):
x1,y1,x2,y2 = map(int, box)
label = f"{model.names[cls]} {score:.2f}"
colors_d = {0: (0, 200, 0), # green
1: (200, 200, 0), # blue
2: (200, 50, 200), #
3: (0, 250, 250), # yellow
4: (250, 250, 250)} # white
color = colors_d[cls]
_add_box_with_contrast(img_bgr, color, x1,y1,x2,y2)
_add_text_with_border(label, x1+10, y2-15, img_bgr, color, font_scale)
return cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
import numpy as np # linear algebra
import pandas as pd # data processing, CSV file I/O (e.g. pd.read_csv)
import os
Dataset Creation
¶Dataset & Annotation
- All image annotations were manually reviewed and corrected using Label Studio to ensure bounding boxes and class labels were consistent and accurate (notebook: dataset creation / annotation review cells).
- Images format (.jpg) and compression level (90%) were suited for model training.
For more detailed information of the dataset, here the public version.
The Dataset comprises the following Cocoa Samples Classes
import json
with open("/kaggle/input/cocoa-diseases/cocoa_diseases/notes.json", "r") as f:
cat_labels = json.load(f)
cat_labels = {el["id"]:el["name"] for el in cat_labels['categories']}
cat_labels
{0: 'Health',
1: 'carmenta foraseminis',
2: 'moniliophthora perniciosa',
3: 'moniliophthora roreri',
4: 'phytophthora palmivora'}
This dataset is imbalance, to address this we state a suitable train/validation splits based on class proportions.
base_dir = "/kaggle/input/cocoa-diseases/cocoa_diseases/"
_images_path = os.path.join(base_dir, "images")
_labels_path = os.path.join(base_dir, "labels")
_paths,_labels = [],[]
for el in os.listdir(_labels_path):
_path = os.path.join(_labels_path, el)
with open(_path, "r") as f:
label,*_ = f.readline()
_paths.append(el.strip(".txt"))
_labels.append(int(label))
# Generate Metadata train/val split
from sklearn.model_selection import train_test_split
metadata_df = pd.DataFrame({"name":_paths,"class":_labels})
y = metadata_df.pop("class")
X = metadata_df
X_train, X_val, y_train, y_val = train_test_split(X, y, test_size=0.2, random_state=2025)
pd.concat([y_train.value_counts(normalize=True).rename("train"),
y_val.value_counts(normalize=True).rename("val")], axis=1)
| train | val | |
|---|---|---|
| class | ||
| 0 | 0.683534 | 0.661316 |
| 4 | 0.107229 | 0.101124 |
| 3 | 0.105622 | 0.141252 |
| 1 | 0.072691 | 0.065811 |
| 2 | 0.030924 | 0.030498 |
Despite there is no direct way to create splits on YOLO, we can create controlled splits creating train.txt and val.txt files with the image files localion, in this way, our splits can be recognized by YOLO.
# Generate train and validation .txt
# Save split files into /kaggle/working/
def save_list(filename, file_list):
file_list = (file_list["name"]+".jpg").apply(lambda t: os.path.join(_images_path, t)).to_list()
with open(filename, "w") as f:
f.write("\n".join(file_list))
save_list("train.txt", X_train)
save_list("val.txt", X_val)
The following code generates a YOLO-compatible dataset.yaml
YOLO automatically assumes that the corresponding labels are in a parallel directory called labels/ with the same structure as images/.
%%writefile dataset.yaml
train: /kaggle/working/train.txt
val: /kaggle/working/val.txt
nc: 5
names:
0: Health
1: carmenta foraseminis
2: moniliophthora perniciosa
3: moniliophthora roreri
4: phytophthora palmivora
Writing dataset.yaml
Some samples from the training split dataset:
import os
import cv2
import matplotlib.pyplot as plt
# Helper: Draw YOLO annotations
def draw_yolo_annotations(image_path, label_path):
img = cv2.imread(image_path)
h, w, _ = img.shape
if os.path.exists(label_path):
with open(label_path, "r") as f:
lines = f.readlines()
for line in lines:
parts = line.strip().split()
cls_id = int(parts[0])
x_center, y_center, bw, bh = map(float, parts[1:])
# Convert YOLO normalized coords → pixel coords
x1 = int((x_center - bw/2) * w)
y1 = int((y_center - bh/2) * h)
x2 = int((x_center + bw/2) * w)
y2 = int((y_center + bh/2) * h)
# Draw rectangle + label
label = str(cls_id)
colors_d = {"0": (0, 200, 0), # green
"1": (200, 200, 0), # blue
"2": (200, 50, 200), #
"3": (0, 250, 250), # yellow
"4": (250, 250, 250)}
cv2.rectangle(img, (x1, y1), (x2, y2), (0,0,0), 16)
cv2.rectangle(img, (x1, y1), (x2, y2), colors_d[label], 12)
label = str(cls_id)
cv2.putText(img, label, (x1+20, y1 + 70),
cv2.FONT_HERSHEY_SIMPLEX, 1.8, (0,0,0), 14)
cv2.putText(img, label, (x1+20, y1 + 70),
cv2.FONT_HERSHEY_SIMPLEX, 1.8, colors_d[label], 8)
return cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
# Paths
images_dir = "/kaggle/input/cocoa-diseases/cocoa_diseases/images"
labels_dir = "/kaggle/input/cocoa-diseases/cocoa_diseases/labels"
f, axs = plt.subplots(ncols=5, figsize=(13,6))
for i,(ax, el) in enumerate(zip(axs.ravel(), ["IMG-20250131-WA0215.jpg", #0
"IMG_20240720_140959.jpg", #1
"IMG_20240720_144537.jpg", #2
"IMG_20240721_103027.jpg", #3
"45d42613-07f1-4f8f-966c-9f650662dc2f.jpg"])): #4
image_file = el
label_file = os.path.splitext(image_file)[0] + ".txt"
img_path = os.path.join(images_dir, image_file)
lbl_path = os.path.join(labels_dir, label_file)
ax.set_title(cat_labels[i])
annotated = draw_yolo_annotations(img_path, lbl_path)
ax.imshow(annotated)
ax.axis("off")
plt.show()
Experimental Design & Tracking
¶As a project develops, it may be helpful to have multiple model candidates which then can be compared. MLFLow offers an straight forward way to track experiments and log its metrics and artifacts. In the case of this project we start a DagsHub Connection for MLFlow model tracking.
import os # This force Authorization
from kaggle_secrets import UserSecretsClient
#import dagshub
import mlflow
# Load your Kaggle secret
user_secrets = UserSecretsClient()
DAGSHUB_TOKEN = user_secrets.get_secret("DAGSHUB_TOKEN")
# Set environment variables for MLflow tracking
os.environ["MLFLOW_TRACKING_URI"] = "https://dagshub.com/bryanddarquea/cocoa-diseases-yolo.mlflow" # "https://dagshub.com/<username>/<repository>.mlflow"
os.environ["MLFLOW_TRACKING_USERNAME"] = "bryanddarquea"
os.environ["MLFLOW_TRACKING_PASSWORD"] = DAGSHUB_TOKEN
mlflow.set_tracking_uri(os.environ["MLFLOW_TRACKING_URI"])
print("MLflow tracking URI:", mlflow.get_tracking_uri())
# manually activated
# Initialize is not needed as I set the config manually, this creates a new auth token
#dagshub.init(repo_owner="bryanddarquea",
# repo_name="cocoa-diseases-yolo",
# mlflow=True)
MLflow tracking URI: https://dagshub.com/bryanddarquea/cocoa-diseases-yolo.mlflow
If we set manually the env variables, then all metrics, parameters, and model artifacts logged via MLflow go straight to your DagsHub repo’s MLflow server.
It’s exactly the same as what dagshub.init(..., mlflow=True) does behind the scenes — but without forcing the browser token flow.
The YOLO autologger internally wraps the training call in its own MLflow context, due to Ultralytics’ built-in MLflow integration. It is possible to control the logsby manually configuring what to log, but in this project it will not be configured.
Manual Log to MLFlow server
def create_exp(model_name="yolo11n", run_name="exp1_base", EPOCHS=40, IMGSZ=640, kw={}):
model = YOLO(f"{model_name}.pt") # load a pretrained model (recommended for training)
# Start MLflow run
with mlflow.start_run(run_name=run_name):
# Log some static params
mlflow.log_param("model", model_name)
mlflow.log_param("epochs", EPOCHS)
mlflow.log_param("imgsz", IMGSZ)
# 3. Train YOLO
results = model.train(
data="/kaggle/working/dataset.yaml", # your dataset config
epochs=EPOCHS,
imgsz =IMGSZ,
batch=16,
#device=0
**kw
)
# read results.csv
df = pd.read_csv(results.save_dir / "results.csv")
# Loop through epochs
for epoch, row in df.iterrows():
epoch += 1
metrics = {# Remove "(",")", to log
"metrics/precisionB": row["metrics/precision(B)"],
"metrics/recallB": row["metrics/recall(B)"],
"metrics/mAP50": row["metrics/mAP50(B)"],
"metrics/mAP50_95": row["metrics/mAP50-95(B)"],
"train/box_loss": row["train/box_loss"],
"train/cls_loss": row["train/cls_loss"],
"train/dfl_loss": row["train/dfl_loss"],
"val/box_loss": row["val/box_loss"],
"val/cls_loss": row["val/cls_loss"],
"val/dfl_loss": row["val/dfl_loss"],
}
mlflow.log_metrics(metrics, step=epoch)
# Log the trained model, training tracks best metrics/mAP50-95(B) on valid dataset
mlflow.log_artifact(os.path.join(results.save_dir, "weights","best.pt"), artifact_path="models")
#save plots
_files = ["args.yaml","results.csv", "results.png",
"confusion_matrix_normalized.png",
"BoxF1_curve.png", "BoxPR_curve.png",
"BoxP_curve.png", "BoxR_curve.png"]
for el in _files:
_path = os.path.join(results.save_dir, el)
mlflow.log_artifact(_path, artifact_path=None)
# Set Manual MLFlow logs
from ultralytics import YOLO
from ultralytics import settings
# Update a setting for MLFlow autolog
settings.update({"mlflow": True})
# Reset settings to default values
settings.reset()
Creating new Ultralytics Settings v0.0.6 file ✅ View Ultralytics Settings with 'yolo settings' or at '/root/.config/Ultralytics/settings.json' Update Settings with 'yolo settings key=value', i.e. 'yolo settings runs_dir=path/to/dir'. For help see https://docs.ultralytics.com/quickstart/#ultralytics-settings.
In YOLO model training, augmentations are selected randomly from a fixed pool at each training step.
With this, we can achieve:
Experiment tracking: All experiments were logged with MLflow using a DagsHub-hosted MLflow server. Logged artifacts include model weights and training/validation metrics per epoch (loss, mAP/IoU, precision, recall).
Reproducibility: Each experiment’s parameters, seeds, and dataset.yaml were stored as MLflow parameters/artifacts to enable full reproducibility.
Now, we set the structure for the experiments to be tracked:
def create_exp(exp_name, model_name="yolo11n", run_name="exp1_base", EPOCHS=40, IMGSZ=640, kw={}):
# Define experiment & model
mlflow.set_experiment(exp_name)
model = YOLO(f"{model_name}.pt") # load a pretrained model (recommended for training)
# Train YOLO
results = model.train(
data="/kaggle/working/dataset.yaml", # your dataset config
epochs=EPOCHS,
imgsz =IMGSZ,
batch=16,
project=exp_name,
name=run_name,
#device=[0,1],
**kw
)
Performed four short/limited-epoch exploratory experiments to explore model architecture and augmentation choices:
- Models:
YOLO11nandYOLO11s - Settings: with/without custom augmentation.
## Custom augmentations
kw = {"mosaic" :0.8,
"degrees" :15.0, # rotations (in degrees)
"translate":0.1, # Shifts objects up to 10% of the image dimensions
"scale":0.4,
"shear":0.5, # Apply a shear transformation with a factor of 0.1
"flipud":0.1,
"fliplr":0.5, # Horizontal flip probability
"close_mosaic":5, # Disables mosaic augmentation 5 epochs before training ends (with epochs=100, mosaic stops at epoch 70)
"multi_scale":False} # Enable multi-scale training for better detection of objects of varied sizes
## Context Aware Data Augmentations
kw2 = {
# General augmentations
"hsv_h": 0.015, # hue jitter (natural color variation of cocoa pods)
"hsv_s": 0.7, # saturation variation
"hsv_v": 0.4, # brightness variation
# Geometric
"degrees": 5.0, # small rotations, realistic
"translate": 0.05, # minimal translation (avoid cutting pods)
"scale": 0.0, # allow zoom-out (more background context),[0.8, 1.2]
"shear": 0.0, # usually unnecessary for natural objects
# Flips
"flipud": 0.0, # upside-down is unnatural for pods on tree
"fliplr": 0.5, # left-right flip is realistic, keep
# Context-sensitive
"mosaic": 0.6, # reduced, still gives variety but won’t dominate
"mixup": 0.0, # avoid unrealistic overlaps
"copy_paste": 0.0, # avoid pasting pods into unrelated contexts
"erasing": 0.0, # avoid removing key regions
}
# Load a model
######################## Config
exp_name = "yolov11-cocoa-diseases"
create_exp(exp_name, model_name="yolo11n", run_name="exp1_nano", EPOCHS=40, IMGSZ=640, kw={})
create_exp(exp_name, model_name="yolo11n", run_name="exp2_nano_aug", EPOCHS=40, IMGSZ=640, kw=kw)
create_exp(exp_name, model_name="yolo11n", run_name="exp4_nano_aug2", EPOCHS=40, IMGSZ=640, kw=kw2)
create_exp(exp_name, model_name="yolo11s", run_name="exp3_small", EPOCHS=40, IMGSZ=640, kw={})
4 Experiments in Total:
| Run Name Tag | model | augmentation |
|---|---|---|
| exp1_nano | yolo11n | default |
| exp2_nano_aug | yolo11n | custom |
| exp4_nano_aug2 | yolo11n | custom 2 |
| exp3_small | yolo11s | default |
Now, we can retrieve the tracked experiment metrics for comparison purposes:
# Set experiment
mlflow.set_experiment("yolov11-cocoa-diseases")
experiment_id = mlflow.get_experiment_by_name("yolov11-cocoa-diseases").experiment_id
# Convert metrics to a DataFrame
runs = mlflow.search_runs(experiment_ids=[experiment_id]).rename(columns={"tags.mlflow.runName":"runName"})
cols = ['runName',
'metrics.metrics/recallB', 'metrics.metrics/precisionB',
'metrics.metrics/mAP50B', 'metrics.metrics/mAP50-95B',
'metrics.train/cls_loss','metrics.train/box_loss','metrics.train/dfl_loss',
'metrics.val/cls_loss', 'metrics.val/box_loss', 'metrics.val/dfl_loss']
runs = runs[runs["runName"].isin(["exp1_nano","exp3_small","exp4_nano_aug2","exp2_nano_aug"])]
runs[cols].rename(columns={el:el[8:] for el in cols[1:]}).sort_values("metrics/mAP50-95B", ascending=False)
| runName | metrics/recallB | metrics/precisionB | metrics/mAP50B | metrics/mAP50-95B | train/cls_loss | train/box_loss | train/dfl_loss | val/cls_loss | val/box_loss | val/dfl_loss | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 5 | exp1_nano | 0.780821 | 0.852747 | 0.867108 | 0.798633 | 0.27914 | 0.37793 | 0.94121 | 0.42711 | 0.44660 | 0.96129 |
| 4 | exp3_small | 0.806051 | 0.840647 | 0.875864 | 0.797683 | 0.28084 | 0.38114 | 0.94383 | 0.42821 | 0.44330 | 0.96140 |
| 2 | exp4_nano_aug2 | 0.776354 | 0.853041 | 0.842244 | 0.764366 | 0.23412 | 0.38294 | 0.94047 | 0.46096 | 0.46704 | 0.97371 |
| 3 | exp2_nano_aug | 0.785294 | 0.800490 | 0.834911 | 0.748224 | 0.35459 | 0.41971 | 1.03425 | 0.46307 | 0.50067 | 0.99601 |
From the Mean Average Precision averaged over IoU thresholds from 0.50 to 0.95 (mAP50-95) aggregated, the exp1_nano and exp3_small are models with the general best data augmentation approach for our Dataset.
# Download the full metric history per run
def _plot_log(name="metrics/mAP50-95B", ax=None):
run_ids = runs["run_id"].tolist()
for run_id in run_ids:
_tag_name = runs[runs["run_id"] == run_id]["runName"].iloc[0]
history = mlflow.MlflowClient().get_metric_history(run_id, name)
history = [(m.step,m.value) for m in history[:-1]] # last one is the aggregated value, only in "metrics/"
x, y = zip(*history)
if _tag_name == "exp1_nano":
color="darkorange"
elif _tag_name == "exp3_small":
color="navy"
else:
color="lightgrey"
ax.plot(x,y, label = _tag_name + f": {history[-1][1]:.3f}", color=color, lw=2, alpha=1)
import matplotlib.pyplot as plt
import seaborn as sns
f,axs=plt.subplots(ncols=3, figsize=(12,5))
_plot_log(name="metrics/mAP50-95B", ax=axs[0])
axs[0].set_title("YOLO Training - metrics/mAP50-95B")
axs[0].set_ylim([0.5,0.8])
_plot_log(name="val/cls_loss", ax=axs[1])
axs[1].set_title("YOLO Training - val/cls_loss")
axs[1].set_ylim([0.4,0.8])
_plot_log(name="val/dfl_loss", ax=axs[2])
axs[2].set_title("YOLO Training - val/dfl_loss")
axs[2].set_ylim([0.9,1.2])
for i in range(3):
axs[i].set_xlim([10,40])
axs[i].set_xlabel("step")
axs[i].legend()
plt.show()
According to the plots: exp1_nano and exp3_small have the best augmentations configurations under the 40 EPOCH training.
- exp3_small is based on the model YOLO 11 small
- exp1_nano is based on the model YOLO 11 nano
Candidate Selection & Full Training
¶From the exploratory runs, two candidate configurations were chosen. The candidates were re-trained to convergence on the same full training/validation data with longer epochs and learning-rate scheduling.
_candidates = runs[runs["runName"].isin(["exp3_small","exp1_nano"])][["run_id","runName"]]
_candidates
| run_id | runName | |
|---|---|---|
| 4 | 3df07ec83e3b42b89ce10f6717e7d909 | exp3_small |
| 5 | f1eb8a20ef6b46359d4089ef5f70f930 | exp1_nano |
from mlflow.tracking import MlflowClient
client = MlflowClient()
exp_name = "yolov11-cocoa-diseases"
for _,_row in _candidates.iterrows():
continue
print("#"*40, f"Training for {_row['runName']}\n")
run_id = _row["run_id"] #"<candidate_run_id>"
local_path = client.download_artifacts(run_id, "weights/last.pt")
print("Downloaded to", local_path)
# ReTraining
model = YOLO(local_path)
model.train(data="/kaggle/working/dataset.yaml",
epochs=60,
#resume=True, # Since the training finish, we can only use its weights
patience=10, # Early stop after 10 epochs without improvement
optimizer="AdamW", # 'auto', ignores lr0
lr0=1e-4, # set lower LR
project=exp_name,
name=f"{_row['runName']}_v2",
warmup_epochs = 0,
mosaic=0.5,
close_mosaic = 50,
verbose=False)
Review Metrics of Retrained Models¶
# Set experiment
mlflow.set_experiment("yolov11-cocoa-diseases")
experiment_id = mlflow.get_experiment_by_name("yolov11-cocoa-diseases").experiment_id
# Convert metrics to a DataFrame
runs = mlflow.search_runs(experiment_ids=[experiment_id]).rename(columns={"tags.mlflow.runName":"runName"})
cols = ['runName',
'metrics.metrics/recallB', 'metrics.metrics/precisionB',
'metrics.metrics/mAP50B', 'metrics.metrics/mAP50-95B',
'metrics.train/cls_loss','metrics.train/box_loss','metrics.train/dfl_loss',
'metrics.val/cls_loss', 'metrics.val/box_loss', 'metrics.val/dfl_loss']
runs = runs[runs["runName"].isin(["exp1_nano_v2","exp3_small_v2"])]
runs[cols].rename(columns={el:el[8:] for el in cols[1:]}).sort_values("metrics/mAP50-95B", ascending=False)
| runName | metrics/recallB | metrics/precisionB | metrics/mAP50B | metrics/mAP50-95B | train/cls_loss | train/box_loss | train/dfl_loss | val/cls_loss | val/box_loss | val/dfl_loss | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | exp1_nano_v2 | 0.807483 | 0.860560 | 0.870304 | 0.800315 | 0.25062 | 0.35963 | 0.93665 | 0.43242 | 0.46876 | 0.96588 |
| 1 | exp3_small_v2 | 0.781326 | 0.844019 | 0.872107 | 0.799755 | 0.24592 | 0.36197 | 0.93845 | 0.43702 | 0.45694 | 0.96513 |
Considering metrics/mAP50-95B and model size, model saved in experiment exp1_nano_v2, based on yolo11n.pt, is our best candidate to localize Cacao Diseases in our dataset. The next step is to register our model for future use.
YOLO11n was chosen as the best trade-off between accuracy and inference speed after the extended training runs.
Model Export & Multi-format Validation
¶The selected YOLO11n model can be exported into several deployment formats:
- PyTorch (.pt) — native model file.
- ONNX (.onnx) — for cross-runtime compatibility and later conversion steps.
- OpenVINO (.xml/.bin) — optimized IR for Intel CPUs/accelerators.
The export & conversion steps are completed in the cells bellow:
run_id =runs.query("runName == 'exp1_nano_v2'")["run_id"].iloc[0] #"<candidate_run_id>"
local_path = client.download_artifacts(run_id, "weights/best.pt", dst_path="/kaggle/working/")
print("Downloaded to", local_path)
!yolo export model={local_path} format=onnx
Downloading artifacts: 0%| | 0/1 [00:00<?, ?it/s]
Downloaded to /kaggle/working/best.pt Ultralytics 8.3.204 🚀 Python-3.11.13 torch-2.6.0+cu124 CPU (Intel Xeon CPU @ 2.20GHz) 💡 ProTip: Export to OpenVINO format for best performance on Intel hardware. Learn more at https://docs.ultralytics.com/integrations/openvino/ YOLO11n summary (fused): 100 layers, 2,583,127 parameters, 0 gradients, 6.3 GFLOPs PyTorch: starting from '/kaggle/working/best.pt' with input shape (1, 3, 640, 640) BCHW and output shape(s) (1, 9, 8400) (5.2 MB) ONNX: starting export with onnx 1.18.0 opset 19... ONNX: slimming with onnxslim 0.1.70... ONNX: export success ✅ 2.9s, saved as '/kaggle/working/best.onnx' (10.1 MB) Export complete (3.8s) Results saved to /kaggle/working Predict: yolo predict task=detect model=/kaggle/working/best.onnx imgsz=640 Validate: yolo val task=detect model=/kaggle/working/best.onnx imgsz=640 data=/kaggle/working/dataset.yaml Visualize: https://netron.app 💡 Learn more at https://docs.ultralytics.com/modes/export
#OpenVINO
run_id =runs.query("runName == 'exp1_nano_v2'")["run_id"].iloc[0] #"<candidate_run_id>"
local_path = client.download_artifacts(run_id, "weights/best.pt", dst_path="/kaggle/working/")
print("Downloaded to", local_path)
# Load a YOLO11n PyTorch model
model = YOLO(local_path)
# Export the model
model.export(format="openvino") # creates 'best_openvino_model/'
Downloading artifacts: 0%| | 0/1 [00:00<?, ?it/s]
Downloaded to /kaggle/working/best.pt Ultralytics 8.3.204 🚀 Python-3.11.13 torch-2.6.0+cu124 CPU (Intel Xeon CPU @ 2.20GHz) 💡 ProTip: Export to OpenVINO format for best performance on Intel hardware. Learn more at https://docs.ultralytics.com/integrations/openvino/ YOLO11n summary (fused): 100 layers, 2,583,127 parameters, 0 gradients, 6.3 GFLOPs PyTorch: starting from '/kaggle/working/best.pt' with input shape (1, 3, 640, 640) BCHW and output shape(s) (1, 9, 8400) (5.2 MB) OpenVINO: starting export with openvino 2025.3.0-19807-44526285f24-releases/2025/3... OpenVINO: export success ✅ 5.6s, saved as '/kaggle/working/best_openvino_model/' (10.2 MB) Export complete (6.2s) Results saved to /kaggle/working Predict: yolo predict task=detect model=/kaggle/working/best_openvino_model imgsz=640 Validate: yolo val task=detect model=/kaggle/working/best_openvino_model imgsz=640 data=/kaggle/working/dataset.yaml Visualize: https://netron.app
'/kaggle/working/best_openvino_model'
Evaluate onnx and Open-Vino models on Validation Dataset¶
Each exported model was validated on the same validation set using the yolo detect val commands to compare correctness and to generate inference-time benchmarks. Validation covers both detection correctness (mAP / precision / recall) and runtime measurement.
%%time
!yolo detect val imgsz=640 model=/kaggle/working/best.pt data=/kaggle/working/dataset.yaml split=val
Ultralytics 8.3.202 🚀 Python-3.11.13 torch-2.6.0+cu124 CPU (Intel Xeon CPU @ 2.20GHz) YOLO11n summary (fused): 100 layers, 2,583,127 parameters, 0 gradients, 6.3 GFLOPs val: Fast image access ✅ (ping: 1.5±1.3 ms, read: 167.2±54.9 MB/s, size: 180.4 KB) val: Scanning /kaggle/input/cocoa-diseases/cocoa_diseases/labels... 623 images, 0 backgrounds, 0 corrupt: 100% ━━━━━━━━━━━━ 623/623 624.9it/s 1.0s0.1s WARNING ⚠️ val: Cache directory /kaggle/input/cocoa-diseases/cocoa_diseases is not writeable, cache not saved. Class Images Instances Box(P R mAP50 mAP50-95): 100% ━━━━━━━━━━━━ 39/39 0.3it/s 1:513.0sss /usr/local/lib/python3.11/dist-packages/matplotlib/colors.py:721: RuntimeWarning: invalid value encountered in less xa[xa < 0] = -1 /usr/local/lib/python3.11/dist-packages/matplotlib/colors.py:721: RuntimeWarning: invalid value encountered in less xa[xa < 0] = -1 all 623 664 0.86 0.808 0.87 0.799 Health 414 430 0.952 0.968 0.979 0.898 carmenta foraseminis 41 44 0.831 0.727 0.793 0.737 moniliophthora perniciosa 19 19 0.839 0.823 0.882 0.817 moniliophthora roreri 88 99 0.883 0.838 0.902 0.802 phytophthora palmivora 63 72 0.798 0.681 0.793 0.744 Speed: 4.6ms preprocess, 161.6ms inference, 0.0ms loss, 0.7ms postprocess per image Results saved to /kaggle/working/runs/detect/val4 💡 Learn more at https://docs.ultralytics.com/modes/val CPU times: user 2.9 s, sys: 531 ms, total: 3.43 s Wall time: 2min 5s
%%time
!yolo detect val imgsz=640 model=/kaggle/working/best.onnx data=/kaggle/working/dataset.yaml split=val
Ultralytics 8.3.202 🚀 Python-3.11.13 torch-2.6.0+cu124 CPU (Intel Xeon CPU @ 2.20GHz) Loading /kaggle/working/best.onnx for ONNX Runtime inference... Using ONNX Runtime CPUExecutionProvider Setting batch=1 input of shape (1, 3, 640, 640) val: Fast image access ✅ (ping: 0.6±0.3 ms, read: 160.1±57.7 MB/s, size: 139.9 KB) val: Scanning /kaggle/input/cocoa-diseases/cocoa_diseases/labels... 623 images, 0 backgrounds, 0 corrupt: 100% ━━━━━━━━━━━━ 623/623 610.6it/s 1.0s<0.0s WARNING ⚠️ val: Cache directory /kaggle/input/cocoa-diseases/cocoa_diseases is not writeable, cache not saved. Class Images Instances Box(P R mAP50 mAP50-95): 100% ━━━━━━━━━━━━ 623/623 7.2it/s 1:27<0.1ss /usr/local/lib/python3.11/dist-packages/matplotlib/colors.py:721: RuntimeWarning: invalid value encountered in less xa[xa < 0] = -1 /usr/local/lib/python3.11/dist-packages/matplotlib/colors.py:721: RuntimeWarning: invalid value encountered in less xa[xa < 0] = -1 all 623 664 0.852 0.805 0.864 0.79 Health 414 430 0.961 0.963 0.98 0.895 carmenta foraseminis 41 44 0.867 0.738 0.795 0.74 moniliophthora perniciosa 19 19 0.784 0.842 0.859 0.795 moniliophthora roreri 88 99 0.88 0.788 0.897 0.792 phytophthora palmivora 63 72 0.767 0.694 0.789 0.728 Speed: 2.4ms preprocess, 121.8ms inference, 0.0ms loss, 1.8ms postprocess per image Results saved to /kaggle/working/runs/detect/val5 💡 Learn more at https://docs.ultralytics.com/modes/val CPU times: user 3.03 s, sys: 737 ms, total: 3.77 s Wall time: 1min 40s
%%time
!yolo detect val imgsz=640 model=best_openvino_model data=/kaggle/working/dataset.yaml split=val
Ultralytics 8.3.204 🚀 Python-3.11.13 torch-2.6.0+cu124 CPU (Intel Xeon CPU @ 2.20GHz) Loading best_openvino_model for OpenVINO inference... Using OpenVINO LATENCY mode for batch=1 inference on CPU... Setting batch=1 input of shape (1, 3, 640, 640) val: Fast image access ✅ (ping: 1.3±1.3 ms, read: 224.6±61.6 MB/s, size: 161.2 KB) val: Scanning /kaggle/input/cocoa-diseases/cocoa_diseases/labels... 623 images, 0 backgrounds, 0 corrupt: 100% ━━━━━━━━━━━━ 623/623 740.7it/s 0.8s0.0s WARNING ⚠️ val: Cache directory /kaggle/input/cocoa-diseases/cocoa_diseases is not writeable, cache not saved. Class Images Instances Box(P R mAP50 mAP50-95): 100% ━━━━━━━━━━━━ 623/623 9.2it/s 1:08<0.1ss /usr/local/lib/python3.11/dist-packages/matplotlib/colors.py:721: RuntimeWarning: invalid value encountered in less xa[xa < 0] = -1 /usr/local/lib/python3.11/dist-packages/matplotlib/colors.py:721: RuntimeWarning: invalid value encountered in less xa[xa < 0] = -1 all 623 664 0.852 0.805 0.864 0.79 Health 414 430 0.961 0.963 0.98 0.895 carmenta foraseminis 41 44 0.867 0.738 0.795 0.74 moniliophthora perniciosa 19 19 0.784 0.842 0.859 0.795 moniliophthora roreri 88 99 0.88 0.788 0.897 0.792 phytophthora palmivora 63 72 0.767 0.694 0.789 0.728 Speed: 2.9ms preprocess, 93.3ms inference, 0.0ms loss, 1.4ms postprocess per image Results saved to /kaggle/working/runs/detect/val2 💡 Learn more at https://docs.ultralytics.com/modes/val CPU times: user 2.19 s, sys: 504 ms, total: 2.69 s Wall time: 1min 19s
Benchmarking & Final Decision¶
Inference speed vs accuracy: The exported models were benchmarked for:
- Average inference time per image (measured on the target CPU).
| model_format | Time |
|---|---|
| Torch | 2min 5s |
| onnx | 1min 40s |
| openVino | 1min 19s |
- Detection accuracy on the validation set (mAP and common detection metrics): All models offers similar performance, that means that optimization/conversion done does not cause a representative performance degradation.
Result: The OpenVINO build provided the best inference performance for the same detection quality — making it the final deployment choice.
On a CPU environtment, our model validation time over val dataset is :
onnxformat is the general solution for CPU model inference.openVinoformat excels the performance of model inference using Intel CPU architecture.
Inference from Samples¶
# Load the exported OpenVINO model
model = YOLO("/kaggle/working/best_openvino_model/", task="detect")
r = predict("/kaggle/input/cocoa-diseases/cocoa_diseases/images/00c23d10-f901-4dc0-9c6c-5aab92db10c4.jpg", model,
conf=0.25, iou=0.45, from_internet=False)
r2 = predict("https://www.frontiersin.org/_rtmag/_next/image?url=https%3A%2F%2Fwww.frontiersin.org%2Fimage%2Fresearchtopic%2F18630&w=828&q=90", model,
conf=0.25, iou=0.45, from_internet=True, font_scale = 0.5)
Loading /kaggle/working/best_openvino_model/ for OpenVINO inference... Using OpenVINO LATENCY mode for batch=1 inference on CPU... image 1/1 /kaggle/input/cocoa-diseases/cocoa_diseases/images/00c23d10-f901-4dc0-9c6c-5aab92db10c4.jpg: 640x640 1 Health, 103.8ms Speed: 5.4ms preprocess, 103.8ms inference, 1.6ms postprocess per image at shape (1, 3, 640, 640) 0: 640x640 1 moniliophthora perniciosa, 93.0ms Speed: 4.7ms preprocess, 93.0ms inference, 1.5ms postprocess per image at shape (1, 3, 640, 640)
fig = plt.figure(figsize=(12, 5))
gs = fig.add_gridspec(1, 3) # 1 row, 3 columns
# Vertical image takes 1 column
ax1 = fig.add_subplot(gs[0, 0])
ax1.set_title("Local File")
ax1.imshow(r)
# Horizontal image spans 2 columns
ax2 = fig.add_subplot(gs[0, 1:])
ax2.set_title("From Internet")
ax2.imshow(r2)
ax1.axis("off")
ax2.axis("off")
#plt.tight_layout()
plt.show()
Deployment
¶- Gradio interface: A lightweight Gradio app was built to serve the OpenVINO model for interactive testing (user uploads an image → model returns bounding boxes and labels).
- Hosting: The Gradio app was deployed on
Hugging Face Spaces(link) so the model can be demoed publicly.
Model registry: The final OpenVINO model artifacts, metadata, and the tested inference metrics can be registered in the MLflow model registry for versioning and future rollbacks.
from mlflow.tracking import MlflowClient
client = MlflowClient(tracking_uri=mlflow.get_tracking_uri())
run_id = runs[runs["runName"] == "exp1_nano_v2"]["run_id"].iloc[0]
# register a new model version pointing to the artifact
artifact_uri = f"runs:/{run_id}/weights/best.pt"
client.create_registered_model("yolo11n_cocoa_localization") # one-time
client.create_model_version("yolo11n_cocoa_localization", artifact_uri, run_id)