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

¶

In [ ]:
#!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
In [ ]:
# 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)
In [3]:
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

In [4]:
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
Out[4]:
{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.

In [5]:
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)
Out[5]:
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.

In [6]:
# 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/.

In [7]:
%%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:

In [8]:
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()
No description has been provided for this image

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.

In [9]:
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)
In [10]:
# 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:

In [11]:
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: YOLO11n and YOLO11s
  • Settings: with/without custom augmentation.
In [12]:
## 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:

In [13]:
# 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)
Out[13]:
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.

In [14]:
# 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)
        
In [13]:
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()
No description has been provided for this image

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.

In [15]:
_candidates = runs[runs["runName"].isin(["exp3_small","exp1_nano"])][["run_id","runName"]]
_candidates
Out[15]:
run_id runName
4 3df07ec83e3b42b89ce10f6717e7d909 exp3_small
5 f1eb8a20ef6b46359d4089ef5f70f930 exp1_nano
In [16]:
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¶

In [17]:
# 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)
Out[17]:
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:

In [18]:
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
In [19]:
#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
Out[19]:
'/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.

In [24]:
%%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
In [25]:
%%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
In [36]:
%%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 :

  • onnx format is the general solution for CPU model inference.
  • openVino format excels the performance of model inference using Intel CPU architecture.

Inference from Samples¶

In [29]:
# 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)
In [38]:
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()
No description has been provided for this image

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.

image.png

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.

In [ ]:
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)