Isaac Nason
Published © CC BY-NC-SA

AeroTherm: Autonomous Radiometric Thermal Drone

An autonomous 7-inch drone using a headless Pi and Lepton 3.5 to hunt, categorize, and log radiometric thermal anomalies in real-time.

AdvancedShowcase (no instructions)24 hours107
AeroTherm: Autonomous Radiometric Thermal Drone

Things used in this project

Hardware components

Raspberry Pi 3 Model A+
Raspberry Pi 3 Model A+
×1
Flir Radiometric Lepton 3.5
×1
PureThermal VoSPI Breakout Board.
×1
TF-Luna LiDAR Range Sensor
×1
iFlight XING 2806.5 1800KV
×4
Pixhawk 6C
Holybro Pixhawk 6C
×1
Tekko32 45A ESC
×1
DroneCAN M8N GPS
Holybro DroneCAN M8N GPS
×1
RadioMaster Zorro Radio Controller - ELRS
×1
RadioMaster ELRS-RP3-V2 Rx
×1
Matek 12S BEC (Step-Down)
×1
Teranty 5200mAh Lipo 60C
×1
L Bracket Corner Brace
×4

Software apps and online services

Fusion
Autodesk Fusion
Microsoft 3D Builder (Deprecated)
Raspbian
Raspberry Pi Raspbian
Lite OS 32-bit version

Hand tools and fabrication machines

Bambu Lab X 1-Carbon

Story

Read more

Custom parts and enclosures

FLIR Lepton 3.5 Case

TJoint_20mm_8mm

Lipo 6S Holder

Parametric version using AutoDesk Fusion

Landing Gear Rod Mount - Front

Landing Gear Rod Mount - Rear

Pi Case Mount

Pi Case

RadioMaster ELRS-RP3-V2 Rx Case

Schematics

Power Distribution Schematic

Compute Payload Schematic

Flight Dynamics Schematic

FLIR Lepton 3.5 Pinout

Mark 4 7-inch Kit

Mark 4 7 inch Wheelbase

Code

thermal_node.py

Python
import cv2
import numpy as np
import time
import threading
import os
from pylepton.Lepton3 import Lepton3

# ==============================================================================
# DEVELOPER CONFIGURATION
# ==============================================================================
COLOR_WARM = (0, 255, 255)    # Yellow
COLOR_HOT = (0, 140, 255)     # Orange
COLOR_CRITICAL = (0, 0, 255)  # Red

THRESHOLD_CONFIG = [
    {"label": "Elevated", "min_temp": 30.0, "max_temp": 50.0, "color": COLOR_WARM, "min_area": 12},
    {"label": "Warning",  "min_temp": 50.0, "max_temp": 75.0, "color": COLOR_HOT,  "min_area": 8},
    {"label": "Critical", "min_temp": 75.0, "max_temp": float("inf"), "color": COLOR_CRITICAL, "min_area": 4},
]

OUTPUT_DIR = "./captures"
os.makedirs(OUTPUT_DIR, exist_ok=True)

# ==========================================
# NasonNation Robotics - AeroTherm Pipeline
# ==========================================

class ThermalCameraThread(threading.Thread):
    def __init__(self):
        super().__init__()
        self.daemon = True
        self.lock = threading.Lock()
        self.running = True
        self.current_frame = None

    def run(self):
        try:
            with Lepton3() as l:
                while self.running:
                    frame, _ = l.capture()
                    with self.lock:
                        self.current_frame = frame
        except Exception as e:
            print(f"[!] Lepton capture failed: {e}")
            self.running = False

    def get_frame(self):
        with self.lock:
            if self.current_frame is not None:
                return self.current_frame.copy()
        return None

    def stop(self):
        self.running = False

def main():
    print("[*] Booting AeroTherm Headless Pipeline...")
    cam_thread = ThermalCameraThread()
    cam_thread.start()
    
    time.sleep(2)
    
    lowest_threshold = THRESHOLD_CONFIG[0]["min_temp"]
    
    try:
        while True:
            raw_frame = cam_thread.get_frame()
            if raw_frame is None:
                continue
                
            # 1. Normalize and Colorize for Display ONLY
            norm_8bit = cv2.normalize(raw_frame, None, 0, 255, cv2.NORM_MINMAX, dtype=cv2.CV_8U)
            color_frame = cv2.applyColorMap(norm_8bit, cv2.COLORMAP_INFERNO)
            display_frame = cv2.resize(color_frame, (640, 480))
            
            # 2. Isolate Heat Signatures using Absolute Temperature
            # Convert raw frame array directly to Celsius
            temp_frame = (raw_frame / 100.0) - 273.15
            
            # Create a native resolution binary mask for anything above minimum threshold
            binary_mask = np.zeros_like(temp_frame, dtype=np.uint8)
            binary_mask[temp_frame >= lowest_threshold] = 255
            
            # Scale mask up to match display frame size for accurate drawing
            mask_scaled = cv2.resize(binary_mask, (640, 480), interpolation=cv2.INTER_NEAREST)
            
            kernel = np.ones((5,5), np.uint8)
            opening = cv2.morphologyEx(mask_scaled, cv2.MORPH_OPEN, kernel)
            
            contours, _ = cv2.findContours(opening, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
            
            frame_height, frame_width = display_frame.shape[:2]
            raw_height, raw_width = raw_frame.shape[:2]
            
            scale_x = raw_width / frame_width
            scale_y = raw_height / frame_height
            
            anomaly_detected = False
            
            # 3. Process Contours and Extract Temperature
            for c in contours:
                x, y, w, h = cv2.boundingRect(c)
                
                raw_x, raw_y = int(x * scale_x), int(y * scale_y)
                raw_w, raw_h = int(w * scale_x), int(h * scale_y)
                
                roi = raw_frame[raw_y:raw_y+raw_h, raw_x:raw_x+raw_w]
                
                if roi.size == 0:
                    continue
                    
                max_raw_val = roi.max()
                temp_c = (max_raw_val / 100.0) - 273.15 
                
                active_tier = None
                for tier in THRESHOLD_CONFIG:
                    if tier["min_temp"] <= temp_c < tier["max_temp"]:
                        active_tier = tier
                        break
                
                if not active_tier or cv2.contourArea(c) < active_tier["min_area"]:
                    continue
                
                anomaly_detected = True
                
                # 4. Draw Overlay Graphics
                label = f"{active_tier['label']}: {temp_c:.1f}C"
                box_color = active_tier["color"]
                
                font = cv2.FONT_HERSHEY_SIMPLEX
                font_scale = 0.5
                thickness = 1
                
                text_size, _ = cv2.getTextSize(label, font, font_scale, thickness)
                text_w, text_h = text_size
                
                text_x = x
                if (text_x + text_w) > frame_width:
                    text_x = frame_width - text_w - 2
                    
                text_y = y - 5 if (y - 5) > text_h else y + 15
                
                cv2.rectangle(display_frame, (x, y), (x+w, y+h), box_color, 2)
                cv2.putText(display_frame, label, (text_x, text_y), font, font_scale, box_color, thickness)
            
            # 5. Execute Capture
            if anomaly_detected:
                filename = os.path.join(OUTPUT_DIR, f"aerotherm_capture_{int(time.time())}.jpg")
                cv2.imwrite(filename, display_frame)
                print(f"[*] Anomaly logged: {filename}")
                time.sleep(3)
                
    except KeyboardInterrupt:
        print("\n[*] Caught interrupt. Shutting down cleanly...")
    finally:
        cam_thread.stop()
        cam_thread.join(timeout=1.0)

if __name__ == "__main__":
    main()

Credits

Isaac Nason
5 projects • 11 followers
Software/Hardware Engineer (15 yrs) building end-to-end robotics, autonomous UAVs, embedded systems, and AI sensor fusion pipelines

Comments