Caso práctico: RGB-térmico perimetral con Jetson Orin Nano

Caso práctico: RGB-térmico perimetral con Jetson Orin Nano — hero

Objetivo y caso de uso

Qué construirás: Un nodo perimetral en Jetson Orin Nano 8GB que fusiona visión RGB (YOLOv5s TensorRT) con térmica y BME680 para detectar intrusiones y emitir alertas con evidencia en tiempo real. Acelerado por GPU (FP16) y optimizado para baja latencia.

Para qué sirve

  • Vigilancia de perímetros en instalaciones industriales: detectar una persona cruzando una línea de seguridad en exteriores, reduciendo falsos positivos por vegetación o sombras.
  • Protección de obra civil nocturna: activar una alerta solo cuando la detección de “persona” se acompaña de una firma térmica superior al ambiente.
  • Monitoreo de vallados en granjas o subestaciones: disparar eventos con evidencia (frames anotados y métricas) y registrar IAQ/humedad para correlacionar con falsas alarmas por niebla o polvo.
  • Control de accesos en almacenes: supervisar zonas de carga con barreras virtuales en ROI definidos y validación de temperatura para evitar alarmas por animales pequeños o reflejos.

Resultado esperado

  • FPS sostenido de la inferencia RGB ≥ 25 FPS con YOLOv5s FP.
  • Latencia de detección de intrusiones.
  • Precisión de detección superior al 90% en condiciones de luz variable.
  • Registro de datos de temperatura y humedad cada 5 segundos para análisis de IAQ.

Público objetivo: Desarrolladores y profesionales de seguridad; Nivel: Avanzado

Arquitectura/flujo: Integración de sensores RGB y térmicos con procesamiento en Jetson Orin Nano mediante pipelines

Nivel: Avanzado

Prerrequisitos (SO, toolchain y versiones exactas)

Se asume NVIDIA Jetson Orin Nano 8GB con JetPack (L4T) en Ubuntu:

  • Sistema base y SDK:
  • Ubuntu 20.04.6 LTS (arm64)
  • JetPack 5.1.2 (L4T 35.4.1)
  • CUDA 11.4
  • cuDNN 8.6.0
  • TensorRT 8.5.2.2
  • GStreamer 1.16.x
  • Python 3.8.10
  • OpenCV vía apt (python3-opencv 4.2.x en Ubuntu 20.04)
  • Cámara CSI gestionada por nvargus (IMX477 soportado por libargus con drivers de Arducam/IMX477).
  • Herramientas de rendimiento:
  • nvpmodel, jetson_clocks
  • tegrastats

Verifica tus versiones con:

cat /etc/nv_tegra_release
uname -a
dpkg -l | grep -E 'nvidia|tensorrt'
python3 -c "import cv2, sys; print('OpenCV', cv2.__version__); import tensorrt as trt; print('TensorRT', trt.__version__); import torch, pkgutil; print('torch?', 'torch' in [m.name for m in pkgutil.iter_modules()])"

Ejemplo esperado (aproximado):
– L4T R35.4.1, Jetson-5.1.2
– TensorRT 8.5.2
– OpenCV 4.2.x

Elegimos ruta A) TensorRT + ONNX (una sola ruta consistente), con motor FP16.

Materiales

  • NVIDIA Jetson Orin Nano 8GB (módulo + carrier con conector CSI y cabecera de 40 pines).
  • Arducam IMX477 (Raspberry Pi HQ Camera compatible con Jetson, conexión CSI).
  • MLX90640 (matriz térmica 32×24, interfaz I2C, dirección típica 0x33).
  • BME680 (sensor ambiental: temperatura, humedad, presión, gas/IAQ, I2C, dirección 0x76 u 0x77).
  • Cables:
  • Cable CSI para la IMX477.
  • Cables Dupont para I2C (SDA/SCL + 3V3 + GND).
  • Alimentación adecuada para Orin Nano (≥ 5 A recomendados).
  • Tarjeta microSD/SSD configurada con Ubuntu 20.04 + JetPack 5.1.2.

Modelo exacto a usar en todo el caso: NVIDIA Jetson Orin Nano 8GB + Arducam IMX477 + MLX90640 + BME680.

Preparación y conexión

Conexión física

  • Conecta la Arducam IMX477 al puerto CSI de la carrier board (asegúrate de la orientación del cable flex).
  • Conecta MLX90640 y BME680 al bus I2C de la cabecera de 40 pines del Jetson Orin Nano (bus 1: /dev/i2c-1):

Tabla de pines (cabecera de 40 pines, vista estándar del Jetson; revisa el pinout de tu carrier):

Señal Pin Jetson Función MLX90640 BME680 Notas
3V3 1 Alimentación 3.3 V VCC VCC MLX90640 y BME680 funcionan a 3V3
5V 2 Alimentación 5 V No conectar a sensores I2C 3V3
SDA 3 I2C-1 SDA (/dev/i2c-1) SDA SDA Pull-ups en placa; típicamente no necesarios externos
SCL 5 I2C-1 SCL (/dev/i2c-1) SCL SCL Frecuencia 100–400 kHz
GND 6 Tierra GND GND Común

Direcciones I2C típicas:
– MLX90640: 0x33
– BME680: 0x76 (algunas placas: 0x77, configurable con pad/jumper)

Comprobaciones iniciales

1) Cámara CSI:
– Reinicia el demonio Argus y lista el dispositivo.

sudo systemctl restart nvargus-daemon
v4l2-ctl --list-devices
  • Prueba rápida con GStreamer (ventana X opcional):
gst-launch-1.0 nvarguscamerasrc sensor-id=0 ! \
  "video/x-raw(memory:NVMM),width=1280,height=720,framerate=30/1,format=NV12" ! \
  nvvidconv ! "video/x-raw,format=I420" ! fakesink

Si no hay errores, el sensor está visible por nvargus.

2) I2C:

sudo apt-get update
sudo apt-get install -y i2c-tools
i2cdetect -y -r 1

Debes ver 0x33 (MLX90640) y 0x76 (BME680). Si ves 0x77 para BME680, ajusta la dirección en el código.

Configuración software y librerías

  • Paquetes base:
sudo apt-get install -y python3-pip python3-dev build-essential \
  python3-opencv gstreamer1.0-tools gstreamer1.0-plugins-good \
  gstreamer1.0-plugins-bad gstreamer1.0-libav v4l-utils
  • Librerías Python para sensores:
pip3 install --upgrade pip
pip3 install adafruit-blinka adafruit-circuitpython-mlx90640 adafruit-circuitpython-bme680 numpy pycuda

Añade tu usuario al grupo i2c (cierra sesión y vuelve a entrar):

sudo usermod -aG i2c $USER
  • Verifica TensorRT en Python:
python3 -c "import tensorrt as trt; print(trt.__version__)"
  • Prepara modo de potencia y clocks para rendimiento:
sudo nvpmodel -q
sudo nvpmodel -m 0
sudo jetson_clocks

Advertencia: supervisa térmicas/ventilación.

Código completo

A continuación, un script Python “rgb_thermal_perimeter.py” que:
– Captura video de la IMX477 con GStreamer en 1280×720.
– Ejecuta YOLOv5s (TensorRT FP16) sobre frames (entrada 640×640).
– Lee térmica MLX90640 y ambiental BME680.
– Fusiona: dispara “intrusión” si hay detección de “person” en ROI perimetral y ΔT (ROI vs ambiente) supera umbral adaptado por humedad.
– Registra FPS, GPU load (opcional por tegrastats) y eventos.

Requisitos previos: construir motor TensorRT FP16 (ver sección de compilación).

#!/usr/bin/env python3
# rgb_thermal_perimeter.py
import os
import sys
import time
import math
import ctypes
import threading
import numpy as np
import cv2

# TensorRT + PyCUDA
import tensorrt as trt
import pycuda.driver as cuda
import pycuda.autoinit  # noqa: F401

# Sensores I2C
import board
import busio
import adafruit_mlx90640
import adafruit_bme680

# ----------------------------
# Configuración general
# ----------------------------
ENGINE_PATH = "./yolov5s_fp16.engine"  # construido con trtexec
CONF_THRESH = 0.40
IOU_THRESH = 0.45
INPUT_W = 640
INPUT_H = 640

# ROI perimetral: banda inferior del frame (en coords de frame 1280x720)
# Se puede ajustar dinamicamente.
ROI_Y_RATIO = 0.75  # 75% alto hacia abajo
ROI_HEIGHT_RATIO = 0.25  # banda de 25% inferior

# Umbral térmico base (ΔT) a sumar a la temperatura ambiente
DELTA_T_BASE = 5.0  # °C
DELTA_T_HUMIDITY_BONUS = -2.0  # reduce umbral si humedad alta (>80%)

# Dirección I2C BME680
BME680_ADDR = 0x76  # cambiar a 0x77 si aplica

# ----------------------------
# Utilidades YOLOv5
# ----------------------------
def letterbox(im, new_shape=(640, 640), color=(114, 114, 114), auto=False, scaleFill=False, scaleup=True):
    shape = im.shape[:2]  # (h,w)
    if isinstance(new_shape, int):
        new_shape = (new_shape, new_shape)
    r = min(new_shape[0] / shape[0], new_shape[1] / shape[1])
    if not scaleup:
        r = min(r, 1.0)
    new_unpad = (int(round(shape[1] * r)), int(round(shape[0] * r)))
    dw, dh = new_shape[1] - new_unpad[0], new_shape[0] - new_unpad[1]
    dw /= 2
    dh /= 2
    if shape[::-1] != new_unpad:
        im = cv2.resize(im, new_unpad, interpolation=cv2.INTER_LINEAR)
    top, bottom = int(round(dh-0.1)), int(round(dh+0.1))
    left, right = int(round(dw-0.1)), int(round(dw+0.1))
    im = cv2.copyMakeBorder(im, top, bottom, left, right, cv2.BORDER_CONSTANT, value=color)
    return im, r, (dw, dh)

def non_max_suppression(prediction, conf_thres=0.25, iou_thres=0.45):
    # prediction: [N, 85] (x,y,w,h,conf,cls...)
    x = prediction
    conf = x[:, 4]
    x = x[conf > conf_thres]
    if x.shape[0] == 0:
        return []
    conf = x[:, 4:5]
    cls_conf = x[:, 5:]
    cls_ids = np.argmax(cls_conf, axis=1)
    cls_scores = cls_conf[np.arange(cls_conf.shape[0]), cls_ids]
    scores = conf.squeeze() * cls_scores
    boxes = xywh2xyxy(x[:, 0:4])
    # NMS básico
    keep = nms_numpy(boxes, scores, iou_thres)
    if len(keep) == 0:
        return []
    out = []
    for i in keep:
        out.append([boxes[i, 0], boxes[i, 1], boxes[i, 2], boxes[i, 3], scores[i], cls_ids[i]])
    return np.array(out)

def xywh2xyxy(x):
    # x: [N,4]
    y = np.zeros_like(x)
    y[:, 0] = x[:, 0] - x[:, 2] / 2  # x1
    y[:, 1] = x[:, 1] - x[:, 3] / 2  # y1
    y[:, 2] = x[:, 0] + x[:, 2] / 2  # x2
    y[:, 3] = x[:, 1] + x[:, 3] / 2  # y2
    return y

def nms_numpy(boxes, scores, iou_threshold):
    idxs = scores.argsort()[::-1]
    selected = []
    while len(idxs) > 0:
        i = idxs[0]
        selected.append(i)
        if len(idxs) == 1:
            break
        ious = bbox_iou(boxes[i], boxes[idxs[1:]])
        idxs = idxs[1:][ious < iou_threshold]
    return selected

def bbox_iou(box1, boxes2):
    # box1: [4], boxes2: [M,4]
    x11, y11, x12, y12 = box1
    x21, y21, x22, y22 = boxes2.T
    xa1 = np.maximum(x11, x21)
    ya1 = np.maximum(y11, y21)
    xa2 = np.minimum(x12, x22)
    ya2 = np.minimum(y12, y22)
    inter = np.maximum(0, xa2 - xa1) * np.maximum(0, ya2 - ya1)
    area1 = (x12 - x11) * (y12 - y11)
    area2 = (x22 - x21) * (y22 - y21)
    union = area1 + area2 - inter
    return inter / (union + 1e-7)

# ----------------------------
# Motor TensorRT
# ----------------------------
class TRTInfer:
    def __init__(self, engine_path):
        self.logger = trt.Logger(trt.Logger.WARNING)
        trt.init_libnvinfer_plugins(self.logger, "")
        with open(engine_path, "rb") as f, trt.Runtime(self.logger) as runtime:
            self.engine = runtime.deserialize_cuda_engine(f.read())
        self.context = self.engine.create_execution_context()
        # Bindings
        self.inputs = []
        self.outputs = []
        self.allocations = []
        for i in range(self.engine.num_bindings):
            name = self.engine.get_binding_name(i)
            dtype = trt.nptype(self.engine.get_binding_dtype(i))
            shape = self.context.get_binding_shape(i)
            if -1 in shape:
                # Define shapes dinámicas
                if self.engine.binding_is_input(i):
                    self.context.set_binding_shape(i, (1, 3, INPUT_H, INPUT_W))
                    shape = (1, 3, INPUT_H, INPUT_W)
                else:
                    # salida YOLOv5s típica: (1, 25200, 85)
                    # El engine debería codificar esto; se deja al runtime.
                    pass
            size = trt.volume(shape)
            host_mem = cuda.pagelocked_empty(size, dtype)
            device_mem = cuda.mem_alloc(host_mem.nbytes)
            self.allocations.append(device_mem)
            binding = {
                "index": i,
                "name": name,
                "dtype": dtype,
                "shape": shape,
                "host_mem": host_mem,
                "device_mem": device_mem,
                "is_input": self.engine.binding_is_input(i)
            }
            if binding["is_input"]:
                self.inputs.append(binding)
            else:
                self.outputs.append(binding)
        self.stream = cuda.Stream()

    def infer(self, img):
        # img: numpy float32 [1,3,640,640], normalizado [0,1], RGB
        np.copyto(self.inputs[0]["host_mem"], img.ravel())
        cuda.memcpy_htod_async(self.inputs[0]["device_mem"], self.inputs[0]["host_mem"], self.stream)
        bindings = [int(b["device_mem"]) for b in sorted(self.inputs + self.outputs, key=lambda x: x["index"])]
        self.context.execute_async_v2(bindings, self.stream.handle)
        for out in self.outputs:
            cuda.memcpy_dtoh_async(out["host_mem"], out["device_mem"], self.stream)
        self.stream.synchronize()
        return [out["host_mem"] for out in self.outputs]

# ----------------------------
# Sensores I2C setup
# ----------------------------
def setup_sensors():
    i2c = busio.I2C(board.SCL, board.SDA, frequency=400000)
    mlx = adafruit_mlx90640.MLX90640(i2c)
    mlx.refresh_rate = adafruit_mlx90640.RefreshRate.REFRESH_8_HZ
    bme = adafruit_bme680.Adafruit_BME680_I2C(i2c, address=BME680_ADDR)
    bme.sea_level_pressure = 1013.25
    return mlx, bme

def read_mlx90640(mlx):
    frame = [0] * 768
    try:
        mlx.getFrame(frame)
        arr = np.array(frame).reshape((24, 32))  # 24 filas x 32 columnas
        return arr
    except Exception:
        return None

def read_bme680(bme):
    try:
        return {
            "temperature": float(bme.temperature),
            "humidity": float(bme.humidity),
            "pressure": float(bme.pressure),
            "gas": float(bme.gas)
        }
    except Exception:
        return None

# ----------------------------
# GStreamer capture
# ----------------------------
def build_gst_pipeline(width=1280, height=720, fps=30):
    return (
        f"nvarguscamerasrc sensor-id=0 bufapi-version=true ! "
        f"video/x-raw(memory:NVMM), width={width}, height={height}, framerate={fps}/1, format=NV12 ! "
        "nvvidconv flip-method=0 ! video/x-raw, format=BGRx ! "
        "videoconvert ! video/x-raw, format=BGR ! appsink drop=true max-buffers=1"
    )

# ----------------------------
# Fusión perimetral
# ----------------------------
def fuse_intrusion(dets, rgb_shape, thermal, ambient_temp, humidity):
    # dets: [N,6] -> x1,y1,x2,y2,score,cls
    # ROI perimetral en coords del RGB (1280x720)
    H, W = rgb_shape[:2]
    y0 = int(H * ROI_Y_RATIO)
    y1 = int(min(H, y0 + H * ROI_HEIGHT_RATIO))
    roi_rgb = (0, y0, W, y1)

    # Umbral térmico adaptado por humedad
    delta_t = DELTA_T_BASE + (DELTA_T_HUMIDITY_BONUS if humidity is not None and humidity > 80.0 else 0.0)

    # Ajuste grosero de FOV: mapeamos ROI de RGB a térmica en proporción (sin calibración)
    # asumimos campos de visión similares y centrados.
    # thermal: 24x32
    th_h, th_w = thermal.shape if thermal is not None else (24, 32)

    def rgb_box_to_thermal_box(box):
        x1, y1b, x2, y2b = box
        rx1 = int((x1 / W) * th_w)
        rx2 = int((x2 / W) * th_w)
        ry1 = int((y1b / H) * th_h)
        ry2 = int((y2b / H) * th_h)
        rx1 = max(0, min(th_w - 1, rx1))
        rx2 = max(0, min(th_w, rx2))
        ry1 = max(0, min(th_h - 1, ry1))
        ry2 = max(0, min(th_h, ry2))
        return rx1, ry1, rx2, ry2

    events = []
    if dets is None or len(dets) == 0 or thermal is None or ambient_temp is None:
        return events, roi_rgb, delta_t

    for d in dets:
        x1, y1b, x2, y2b, score, cls_id = d
        # Clase "person" en COCO es 0 en YOLOv5
        if int(cls_id) != 0 or score < CONF_THRESH:
            continue
        # intersección con ROI perimetral
        if y2b < roi_rgb[1] or y1b > roi_rgb[3]:
            continue  # no toca la banda inferior
        # Mapea bbox a térmica
        tx1, ty1, tx2, ty2 = rgb_box_to_thermal_box((x1, y1b, x2, y2b))
        patch = thermal[ty1:ty2, tx1:tx2]
        if patch.size == 0:
            continue
        t_obj = float(np.nanmean(patch))
        # Intrusión si t_obj > ambient + delta_t
        if t_obj > ambient_temp + delta_t:
            events.append({
                "bbox": (int(x1), int(y1b), int(x2), int(y2b)),
                "score": float(score),
                "cls_id": int(cls_id),
                "t_obj": t_obj,
                "t_ambient": ambient_temp,
                "delta_t": delta_t
            })
    return events, roi_rgb, delta_t

# ----------------------------
# Main loop
# ----------------------------
def main():
    if not os.path.exists(ENGINE_PATH):
        print(f"ERROR: motor TensorRT no encontrado en {ENGINE_PATH}")
        sys.exit(1)

    cap = cv2.VideoCapture(build_gst_pipeline(), cv2.CAP_GSTREAMER)
    if not cap.isOpened():
        print("ERROR: no se puede abrir la cámara IMX477 vía GStreamer/nvargus.")
        sys.exit(1)

    trt_infer = TRTInfer(ENGINE_PATH)

    mlx, bme = setup_sensors()

    frame_count = 0
    t0 = time.perf_counter()
    last_thermal = None
    last_env = None
    last_thermal_t = 0.0
    last_env_t = 0.0

    print("Iniciando bucle. 'q' para salir.")
    while True:
        ok, frame = cap.read()
        if not ok:
            print("WARN: frame no disponible")
            continue

        H, W = frame.shape[:2]

        # Prepro YOLO
        img, r, (dw, dh) = letterbox(frame, (INPUT_H, INPUT_W))
        img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
        img = img.astype(np.float32) / 255.0
        img = np.transpose(img, (2, 0, 1))
        img = np.expand_dims(img, 0).copy()

        # Inferencia TRT
        t_infer0 = time.perf_counter()
        outputs = trt_infer.infer(img)
        t_infer1 = time.perf_counter()
        # Asumimos único output: [1, N, 85]
        out = outputs[0].reshape(1, -1, 85)
        preds = out[0]
        # Postpro YOLO
        dets = non_max_suppression(preds, CONF_THRESH, IOU_THRESH)

        # Lecturas sensores con throttling (no más de 8–10 Hz)
        now = time.perf_counter()
        if now - last_thermal_t > 0.12:
            th = read_mlx90640(mlx)
            if th is not None:
                last_thermal = th
                last_thermal_t = now
        if now - last_env_t > 0.5:
            env = read_bme680(bme)
            if env is not None:
                last_env = env
                last_env_t = now

        ambient_temp = last_env["temperature"] if last_env else None
        humidity = last_env["humidity"] if last_env else None

        events, roi_rgb, delta_t = fuse_intrusion(dets, frame.shape, last_thermal, ambient_temp, humidity)

        # Overlay para visualización/validación
        # Dibuja ROI perimetral
        cv2.rectangle(frame, (roi_rgb[0], roi_rgb[1]), (roi_rgb[2], roi_rgb[3]), (0, 255, 255), 2)
        # Dibuja detecciones
        if dets is not None and len(dets) > 0:
            for d in dets:
                x1, y1b, x2, y2b, score, cls_id = d.astype(int)
                color = (0, 255, 0) if int(cls_id) == 0 else (255, 0, 0)
                cv2.rectangle(frame, (x1, y1b), (x2, y2b), color, 2)
        # Dibuja eventos
        for ev in events:
            x1, y1b, x2, y2b = ev["bbox"]
            label = f"INTRUSION person {ev['score']:.2f} dT>{ev['delta_t']:.1f}C Tobj:{ev['t_obj']:.1f}C"
            cv2.putText(frame, label, (x1, max(0, y1b-10)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0,0,255), 2)

        # HUD con métricas
        fps_inst = 1.0 / max(1e-3, (t_infer1 - t_infer0))
        txt = f"FPS_infer:{fps_inst:.1f} TempAmb:{ambient_temp:.1f if ambient_temp else float('nan')}C Hum:{humidity:.1f if humidity else float('nan')}%"
        cv2.putText(frame, txt, (10, 24), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255,255,255), 2)

        # Muestra (si tienes X/GUI), si no, comentar:
        # cv2.imshow("rgb-thermal-perimeter-intrusion", frame)
        # if cv2.waitKey(1) & 0xFF == ord('q'):
        #     break

        # Log por stdout (modo headless)
        if len(events) > 0:
            print(f"[EVENT] {time.strftime('%F %T')} intrusions={len(events)} ambient={ambient_temp:.1f if ambient_temp else -1}C hum={humidity:.1f if humidity else -1}%")

        frame_count += 1

    cap.release()
    cv2.destroyAllWindows()

if __name__ == "__main__":
    main()

Explicación breve de las partes clave:
– TRTInfer: carga el engine TensorRT FP16 y prepara bindings de entrada/salida; infer ejecuta de modo asincrónico con stream CUDA.
– Pre/postprocesamiento YOLOv5: letterbox, normalización y NMS; salida típica [1,25200,85] con boxes xywh, conf y 80 clases COCO.
– Fusión: se define un ROI perimetral en la banda inferior del frame; se mapea grosso modo a la rejilla térmica 24×32 para estimar temperatura del objeto y compararla con ambiente + ΔT adaptado por humedad.
– Sensores: MLX90640 a ~8 Hz; BME680 a ~2 Hz para no sobrecargar I2C; se guarda la última lectura válida y se publica en overlay/log.

Código auxiliar para comprobar sensores (opcional, útil para diagnóstico):

#!/usr/bin/env python3
# sensor_check.py
import board, busio, time, numpy as np
import adafruit_mlx90640, adafruit_bme680

i2c = busio.I2C(board.SCL, board.SDA, frequency=400000)
mlx = adafruit_mlx90640.MLX90640(i2c)
mlx.refresh_rate = adafruit_mlx90640.RefreshRate.REFRESH_8_HZ
bme = adafruit_bme680.Adafruit_BME680_I2C(i2c, address=0x76)

for i in range(5):
    frame = [0]*768
    mlx.getFrame(frame)
    arr = np.array(frame).reshape((24,32))
    print(f"MLX90640: min={np.min(arr):.1f}C max={np.max(arr):.1f}C mean={np.mean(arr):.1f}C")
    print(f"BME680: T={bme.temperature:.1f}C H={bme.humidity:.1f}% P={bme.pressure:.1f}hPa Gas={bme.gas:.0f}ohms")
    time.sleep(0.5)

Compilación/flash/ejecución

1) Verifica JetPack y modo potencia

cat /etc/nv_tegra_release
sudo nvpmodel -m 0
sudo jetson_clocks

2) Descarga modelo ONNX y construye motor TensorRT FP16

Usaremos YOLOv5s (COCO, 640). Descarga ONNX oficial y crea engine:

mkdir -p ~/models && cd ~/models
wget -O yolov5s.onnx https://github.com/ultralytics/yolov5/releases/download/v6.2/yolov5s.onnx
# Verifica tamaño ~ 14–15 MB
ls -lh yolov5s.onnx

Construye motor FP16 con trtexec (TensorRT 8.5.2 en JetPack 5.1.2):

/usr/src/tensorrt/bin/trtexec \
  --onnx=yolov5s.onnx \
  --saveEngine=yolov5s_fp16.engine \
  --explicitBatch \
  --fp16 \
  --workspace=2048 \
  --shapes=images:1x3x640x640 \
  --verbose

Notas:
– El input en muchos exportes se llama “images”; si tu ONNX tiene otro nombre (p.ej. “input”), ajusta –shapes.
– FP16 aprovecha los Tensor Cores del Orin Nano 8GB.
– El engine generado es específico de la versión de TensorRT/L4T.

Copia el engine junto al script:

cp yolov5s_fp16.engine ~/rgb-thermal-perimeter-intrusion/

3) Prepara el proyecto y dependencias Python

mkdir -p ~/rgb-thermal-perimeter-intrusion && cd ~/rgb-thermal-perimeter-intrusion
# Copia los scripts rgb_thermal_perimeter.py y sensor_check.py aquí.
pip3 install --upgrade numpy pycuda adafruit-blinka adafruit-circuitpython-mlx90640 adafruit-circuitpython-bme680

4) Pruebas de cámara y sensores

  • Cámara (headless):
gst-launch-1.0 nvarguscamerasrc sensor-id=0 ! \
 "video/x-raw(memory:NVMM), width=1280, height=720, framerate=30/1, format=NV12" ! \
 nvvidconv ! "video/x-raw,format=I420" ! fakesink
  • I2C:
i2cdetect -y -r 1
python3 sensor_check.py

5) Ejecuta la aplicación

En una terminal, monitorea recursos:

sudo tegrastats --interval 1000

En otra, lanza la app:

cd ~/rgb-thermal-perimeter-intrusion
python3 rgb_thermal_perimeter.py

Parámetros (si deseas ajustar ROI/umbrales), edítalos al inicio del script.

Salida esperada en consola (ejemplo):
– Logs de eventos: “[EVENT] 2025-01-01 12:00:00 intrusions=1 ambient=22.4C hum=65.1%”
– FPS de inferencia ~ 25–35 FPS (según escena/resolución/overlays).
– tegrastats mostrando uso de GPU (GR3D), memoria y clocks.

Para detener:
– Ctrl+C en la app y termina tegrastats con Ctrl+C.
– Revertir clocks/power (opcional):

sudo nvpmodel -m 1  # modo más eficiente (según disponibilidad)

Validación paso a paso

Verificación 1: Integridad del entorno

  • Comando: cat /etc/nv_tegra_release → Debe mostrar R35.4.1 (JetPack 5.1.2).
  • Comando: dpkg -l | grep tensorrt → Debe listar tensorrt 8.5.x.

Criterio: versiones coinciden con las declaradas.

Verificación 2: Cámara IMX477 operativa

  • Comando: gst-launch-1.0 nvarguscamerasrc … fakesink → Debe ejecutar sin errores “Argus”.
  • Alternativa (con X): usa xvimagesink para visualizar.

Criterio: no hay errores “No cameras available” ni timeouts.

Verificación 3: Sensores I2C visibles

  • Comando: i2cdetect -y -r 1 → Deben aparecer 0x33 y 0x76/0x77.
  • Comando: python3 sensor_check.py → Muestra min/max/mean de MLX90640 y T/H/P/Gas de BME680 con valores razonables.

Criterio: lecturas no NaN y en rangos plausibles (T 10–40 C, Hum 20–90%, etc.).

Verificación 4: Inferencia TensorRT

  • Comando: /usr/src/tensorrt/bin/trtexec –loadEngine=yolov5s_fp16.engine –shapes=images:1x3x640x640 –dumpProfile
  • Debe mostrar tiempos por capa y FPS estimado.
  • Ejecuta la app y observa “FPS_infer: XX.X” en el overlay/log.

Criterio: FPS ≥ 25 en modo MAXN y resolución 1280×720 (entrada 640×640 para modelo).

Verificación 5: Fusión y disparo de intrusión

  • Sitúa una persona cruzando la banda inferior del frame (ROI).
  • Con ambiente estable, verifica:
  • El bounding box “person” aparece con score > 0.4.
  • Se dispara evento cuando t_obj (en térmica) > T_amb + ΔT (5 C por defecto, 3 C si hum > 80%).
  • Forzar caso negativo:
  • Coloca cartón con foto de persona (solo RGB) sin firma térmica → no debe disparar.
  • Objeto caliente (bolsa de agua caliente) sin detección “person” → no debe disparar.

Criterio: eventos solo con simultaneidad de condiciones; falsos positivos marcadamente inferiores a pipeline solo-RGB.

Verificación 6: Métricas de sistema

  • tegrastats:
  • GR3D_FREQ ~ 30–70% durante inferencia sostenida.
  • Memoria estable; sin OOM.
  • Latencia de evento:
  • Añade timestamps o usa time.perf_counter() entre infer y log del evento (ya integrado).
  • Medir ≤ 150 ms.

Criterio: sistema estable y dentro del presupuesto térmico.

Troubleshooting

1) nvargus “No cameras available” o timeout:
– Revisa conexión del cable CSI (orientación y sujeción).
– Reinicia demonio: sudo systemctl restart nvargus-daemon
– Ejecuta dmesg | grep -i imx para ver si el driver IMX477 se cargó.
– Comprueba que no haya otra app usando la cámara (cierra pipelines GStreamer previos).

2) i2cdetect no muestra 0x33/0x76:
– Verifica cableado SDA/SCL y GND/3V3.
– Cambia la frecuencia del bus a 100 kHz si hay problemas de integridad (en código: I2C a 100000).
– Para BME680 en 0x77, cambia BME680_ADDR = 0x77 en el código.
– Asegúrate de pertenecer al grupo i2c (logout/login tras sudo usermod -aG i2c $USER).

3) Error al construir/trabajar con el engine TensorRT:
– Engine “incompatible” tras actualizar L4T: reconstruye con trtexec en el propio dispositivo (los engines no son portables entre versiones).
– Si –shapes falla por nombre de input, inspecciona el ONNX: onnxsim/netron; ajusta –shapes=:1x3x640x640.
– Si sin memoria (–workspace), reduce workspace o usa –optShapes/–minShapes/–maxShapes aunque sea batch=1.

4) FPS bajo (< 20 FPS):
– Asegúrate de modo MAXN y jetson_clocks activos.
– Cierra overlays/ventanas que consumen CPU.
– Evita conversiones innecesarias; mantén el preprocesado ligero (ya está optimizado).
– Verifica que sea FP16 (–fp16) y no FP32.
– Reduce resolución de captura a 1280×720 o menor; input del modelo es 640×640.

5) MLX90640 lecturas erráticas (NaN o saltos):
– Reduce refresh_rate a 4 Hz; incrementa tiempo entre lecturas.
– Asegura buena alimentación a 3V3 y cables cortos.
– Evita fuentes de calor IR directas saturando el sensor.

6) BME680 reporta humedad/temperatura irreales:
– Evita proximidad a disipador del Jetson (calienta el aire local).
– Añade “offset” de temperatura si instalas cerca de elementos calientes; o aléjalo unos cm.
– Comprueba dirección I2C y el chip (BME688 vs BME680 pueden necesitar librerías distintas).

7) Fallo GStreamer/OpenCV (“can’t open pipeline”):
– Instala gstreamer1.0-plugins-bad/libav.
– Verifica que el string de pipeline sea exactamente el especificado (comillas y caps).
– Usa gst-launch-1.0 para depurar segmento a segmento.

8) PyCUDA error de compilación en instalación:
– Asegúrate de tener python3-dev y build-essential instalados.
– Verifica que CUDA 11.4 esté en /usr/local/cuda; si no, exporta CUDA_HOME=/usr/local/cuda y reinstala pycuda.
– Como alternativa, usa el intérprete del sistema (no en venv) para heredar rutas de CUDA del JetPack.

Mejoras/variantes

  • INT8 y calibración: genera engine INT8 con trtexec y dataset de calibración propio para ganar ~1.3–1.7× en FPS manteniendo precisión adecuada.
  • Homografía RGB↔térmico: calibra extrínsecos y FOV para mapear con precisión bbox RGB al plano térmico (usa un patrón térmico para registro).
  • Multi-ROI y reglas: define varias “líneas” perimetrales con distinta sensibilidad y lógica (conteo, dirección).
  • Publicación de eventos: envía JSON vía MQTT/HTTP con métricas (bbox, score, T_obj, T_amb, humedad), e integra con un dashboard (Grafana/InfluxDB).
  • DeepStream pipeline: migra a nvinfer + nvtracker si requieres múltiples flujos o visualización acelerada; este caso mantuvo Python+TRT para facilitar fusión de sensores I2C.
  • Gestión de energía: alterna nvpmodel según horario; baja FPS nocturno si no hay movimiento (detección de movimiento simple).

Checklist de verificación

  • [ ] El sistema ejecuta Ubuntu 20.04.6 + JetPack 5.1.2 (L4T 35.4.1).
  • [ ] TensorRT 8.5.x disponible y trtexec funcional.
  • [ ] IMX477 detectada por nvargus; pipeline GStreamer sin errores.
  • [ ] MLX90640 (0x33) y BME680 (0x76/0x77) visibles en i2cdetect.
  • [ ] sensor_check.py arroja valores coherentes (T/H/IR).
  • [ ] Motor FP16 generado: yolov5s_fp16.engine presente y cargable.
  • [ ] rgb_thermal_perimeter.py corre con FPS_infer ≥ 25.
  • [ ] Eventos de intrusión solo cuando hay “person” + ΔT térmico sobre umbral.
  • [ ] tegrastats muestra uso de GPU estable y sin OOM.
  • [ ] Modo potencia revertido (opcional): sudo nvpmodel -m 1 al finalizar.

Apéndice: comandos útiles de monitoreo y limpieza

  • Consultar modo de potencia:
sudo nvpmodel -q
  • Establecer MAXN y fijar clocks (recordatorio):
sudo nvpmodel -m 0
sudo jetson_clocks
  • Monitoreo en tiempo real:
sudo tegrastats --interval 1000
  • Reiniciar servicio de cámara:
sudo systemctl restart nvargus-daemon
  • Revertir a modo de menor consumo al terminar:
sudo nvpmodel -m 1

Con este caso práctico, has implementado un sistema de “rgb-thermal-perimeter-intrusion” completamente operativo en NVIDIA Jetson Orin Nano 8GB usando Arducam IMX477, MLX90640 y BME680, con toolchain JetPack 5.1.2, TensorRT 8.5.2 FP16 y GStreamer/OpenCV, validado con métricas cuantitativas y procedimientos reproducibles end-to-end.

Encuentra este producto y/o libros sobre este tema en Amazon

Ir a Amazon

Como afiliado de Amazon, gano con las compras que cumplan los requisitos. Si compras a través de este enlace, ayudas a mantener este proyecto.

Quiz rápido

Pregunta 1: ¿Cuál es el objetivo principal del nodo perimetral en Jetson Orin Nano 8GB?




Pregunta 2: ¿Qué tecnología se utiliza para la detección de personas en el sistema?




Pregunta 3: ¿Cuál es la latencia máxima permitida desde la detección hasta la publicación de la alerta?




Pregunta 4: ¿Qué tipo de datos se fusionan para mejorar la detección de intrusiones?




Pregunta 5: ¿Qué FPS sostenido se espera lograr con YOLOv5s FP16 a 640×640?




Pregunta 6: ¿Qué tipo de eventos se disparan en el monitoreo de vallados en granjas?




Pregunta 7: ¿Cuál es el consumo de energía esperado en modo MAXN?




Pregunta 8: ¿Qué se utiliza para el preprocesamiento en el flujo de trabajo?




Pregunta 9: ¿Quién es el público objetivo del sistema?




Pregunta 10: ¿Qué se busca reducir al activar alertas solo con firmas térmicas superiores al ambiente?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Ingeniero Superior en Electrónica de Telecomunicaciones e Ingeniero en Informática (titulaciones oficiales en España).

Sígueme:
Scroll to Top