Ingénierie du Déploiement CV Industriel : Du Modèle PyTorch au Service d'Inférence Optimisé

Le Fossé entre la Recherche et la Production en Vision par Ordinateur

Le déploiement de modèles de vision par ordinateur (CV) en milieu industriel ne se limite pas à l'exportation de poids entraînés. Le passage d'un environnement de recherche à une ligne de production exige une rigueur d'ingénierie spécifique. L'objectif n'est plus uniquement de maximiser les métriques théoriques, mais de garantir une inférence déterministe, à faible latence et hautement fiable. Les pièges classiques incluent la divergence des prétraitements entre l'entraînement et l'inférence, le couplage fort de la logique de post-traitement, et la'bsence de standardisation des formats de sortie. L'adoption d'une apporche orientée déploiement permet d'anticiper ces contraintes dès la phase de conception du modèle.

Architecture Modulaire du Pipeline d'Inférence

Pour assurer la maintenabilité et l'évolutivité, le service d'inférence doit être découpé en quatre couches orthogonales :

  • Couche d'Interface : Définit les contrats d'entrée/sortie (flux RTSP, tableaux NumPy, chaînes Base64) et garantit un typage strict via des protocoles d'interface.
  • Couche d'Adaptation : Isère les spécificités des architectures (ex: parsing des tenseurs de détection, conversion des masques de segmentation) pour exposer une API uniforme.
  • Couche Moteur : Abstrait le backend d'exécution (TensorRT, ONNX Runtime) et gère l'allocation mémoire directe sur le GPU pour éviter les transferts CPU/GPU coûteux.
  • Couche Service : Fournit les capacités opérationnelles (métriques Prometheus, points de terminaison de santé, rechargement à chaud de la configuration).

Maîtrise de la Conversion ONNX et du Prétraitement

L'exportation ONNX exige une déclaration explicite de toutes les dimensions dynamiques. Omettre la dimension spatiale ou le nombre d'ancres peut provoquer des dépassements de mémoire lors de l'inférence sur des résolutions variables.

import torch

def export_model_to_onnx(pytorch_model, sample_input, output_path):
    dynamic_dimensions = {
        "input_tensor": {0: "batch_size", 2: "spatial_height", 3: "spatial_width"},
        "output_predictions": {0: "batch_size", 1: "num_candidates"}
    }
    
    torch.onnx.export(
        pytorch_model,
        sample_input,
        output_path,
        input_names=["input_tensor"],
        output_names=["output_predictions"],
        dynamic_axes=dynamic_dimensions,
        opset_version=13,
        do_constant_folding=True
    )

Le prétraitement doit être rigoureusement identique à celui utilisé pendant l'entraînement. L'utilisation de bibliothèques de transformation en production introduit souvent des divergences subtiles (interpolation, normalisation). Il est impératif de reproduire exactement le pipeline avec OpenCV pour garantir la cohérence des pixels.

import cv2
import numpy as np
from typing import Tuple

def prepare_image_for_inference(bgr_image: np.ndarray, target_dims: Tuple[int, int]) -> np.ndarray:
    resized_frame = cv2.resize(bgr_image, target_dims, interpolation=cv2.INTER_LINEAR)
    rgb_frame = cv2.cvtColor(resized_frame, cv2.COLOR_BGR2RGB)
    
    normalized_float = rgb_frame.astype(np.float32) / 255.0
    
    channel_means = np.array([0.485, 0.456, 0.406], dtype=np.float32)
    channel_stds = np.array([0.229, 0.224, 0.225], dtype=np.float32)
    standardized = (normalized_float - channel_means) / channel_stds
    
    transposed = np.transpose(standardized, (2, 0, 1))
    return np.expand_dims(transposed, axis=0)

Optimisation TensorRT et Calibration

La configuration de la mémoire de travail (workspace) de TensorRT est cruciale. Une valeur trop élevée peut provoquer des erreurs d'allocation (OOM) ou forcer des fusions de couches agressives nuisibles à la précision. Il est recommandé de calibrer cette valeur dynamiquement en fonction de la VRAM disponible, tout en appliquant un plafond de sécurité.

def calculate_optimal_workspace(gpu_memory_gb: float) -> int:
    available_for_workspace = gpu_memory_gb * 0.70
    capped_gb = min(available_for_workspace, 2.0)
    return int(capped_gb * (1024 ** 3))

workspace_bytes = calculate_optimal_workspace(32.0)

Pour la quantification INT8, l'utilisation de données synthétiques dégrade souvent les performances en milieu industriel. La calibration doit impérativement s'effectuer sur un jeu de données représentatif des conditions réelles de la ligne de production (éclairage, bruit, angles). L'algorithme IInt8EntropyCalibrator2 est généralement préféré pour sa robustesse face au bruit.

Intégration Système, Sécurité et Observabilité

La robustesse d'un service industriel repose sur une gestion stricte des erreurs, des logs structurés et une sécurité renforcée.

  • Logs Structurés : Chaque requête doit être associée à un trace_id unique pour permettre le suivi transversal à travers les microservices.
  • Sécurité : La désérialisation pickle doit être globalement interdite pour prévenir les exécutions de code à distance. Les entrées doivent être validées via des schémas stricts.
  • Gestion de Configuration : Les chemins et paramètres ne doivent jamais être codés en dur. L'utilisation de fichiers YAML avec surcharge par variables d'environnement assure la portabilité.

Le post-traitement, notamment le Non-Maximum Suppression (NMS), doit être externalisé et ses seuils configurables à l'exécution pour s'adapter aux différentes densités d'objets.

import os
import torchvision

class InferenceConfig:
    def __init__(self):
        self.iou_limit = float(os.environ.get("OVERLAP_THRESHOLD", "0.45"))
        self.confidence_limit = float(os.environ.get("CONFIDENCE_LIMIT", "0.50"))

def apply_non_max_suppression(boxes, scores, class_ids, config: InferenceConfig):
    valid_indices = torchvision.ops.batched_nms(
        boxes=boxes,
        scores=scores,
        idxs=class_ids,
        iou_threshold=config.iou_limit
    )
    return valid_indices

Déploiement Conteneurisé et Pont OT/IT

Le service d'inférence doit être encapsulé dans une image Docker minimale, basée sur les runtime CUDA officiels, en éliminant les dépendances inutiles pour réduire la surface d'attaque et le temps de démarrage.

from flask import Flask, request, jsonify
import cv2
import numpy as np
import base64
from inference_engine import OptimizedEngine
from utils import format_output_payload

app = Flask(__name__)
vision_engine = OptimizedEngine(engine_path="model_optimized.engine")

@app.route("/api/v1/analyze", methods=["POST"])
def analyze_visual_data():
    try:
        raw_image_data = request.get_json().get("base64_image")
        if not raw_image_data:
            return jsonify({"status": "error", "message": "Missing image data"}), 400
            
        decoded_bytes = np.frombuffer(base64.b64decode(raw_image_data), np.uint8)
        image_matrix = cv2.imdecode(decoded_bytes, cv2.IMREAD_COLOR)
        
        processed_input = prepare_image_for_inference(image_matrix, (640, 640))
        raw_predictions = vision_engine.execute(processed_input)
        
        config = InferenceConfig()
        final_detections = apply_non_max_suppression(
            raw_predictions['boxes'], raw_predictions['scores'], 
            raw_predictions['classes'], config
        )
        
        response_payload = format_output_payload(final_detections, image_matrix.shape)
        return jsonify(response_payload), 200
    except Exception as err:
        return jsonify({"status": "error", "message": str(err)}), 500

Pour connecter ce service aux automates programmables (PLC) dans les environnements OT, un pont Modbus TCP léger peut être déployé en parallèle. Il expose des registres de maintien pour les résultats de détection et des registres d'entrée pour les signaux de déclenchement matériel.

from pymodbus.server import StartTcpServer
from pymodbus.datastore import ModbusSlaveContext, ModbusServerContext, ModbusSequentialDataBlock
import requests

def setup_modbus_bridge():
    block_discrete = ModbusSequentialDataBlock(0, [0] * 50)
    block_coils = ModbusSequentialDataBlock(0, [0] * 50)
    block_holding = ModbusSequentialDataBlock(0, [0] * 100)
    block_input = ModbusSequentialDataBlock(0, [0] * 50)

    slave_context = ModbusSlaveContext(
        di=block_discrete, co=block_coils, hr=block_holding, ir=block_input
    )
    server_context = ModbusServerContext(slaves=slave_context, single=True)

    def process_plc_trigger():
        trigger_signal = slave_context.getValues(4, 0, count=1)[0]
        if trigger_signal == 1:
            api_response = requests.post("http://127.0.0.1:5000/api/v1/analyze", json={"base64_image": "..."})
            data = api_response.json()
            
            if data.get("status") == "success" and data.get("detections"):
                first_box = data["detections"][0]["coordinates"]
                slave_context.setValues(3, 10, [int(coord) for coord in first_box])
                
                confidence_val = int(data["detections"][0]["confidence"] * 1000)
                slave_context.setValues(3, 14, [confidence_val])

    StartTcpServer(context=server_context, address=("0.0.0.0", 502))

Étiquettes: PyTorch ONNX TensorRT Computer-Vision Model-Deployment

Publié le 16 juillet à 13h07