3、Python基础回顾:面向对象编程、装饰器、多线程与多进程在视觉任务中的应用

各位同学,咱们直接进入正题。做边缘设备上的视觉应用,Python 基础不牢,后面会踩很多坑。今天聊的这三个东西——面向对象、装饰器、多线程与多进程,说白了就是你在嵌入式视觉战场上的三把武器。我当年刚入行时,觉得 Python 嘛,写个脚本跑通就行了。结果第一次做实时行人检测,帧率上不去,代码改得想哭。后来才明白,这些基础玩不转,根本没法做工程。

3.1 面向对象编程:把摄像头和检测器封装起来

面向对象编程,OOP,听起来高大上。其实核心就一句话:把数据和操作数据的方法打包在一起。在视觉任务里,你想想看,摄像头有分辨率、帧率这些属性,还有打开、关闭、读取帧这些操作。用类来封装,再合适不过。

我个人习惯,每个硬件模块都写成一个类。比如摄像头,我绝不会在全局代码里直接调 cv2.VideoCapture。为什么?因为项目一复杂,你到处改摄像头参数,改到后面自己都忘了哪个是哪个。

class Camera:
    def __init__(self, device_id=0, width=640, height=480):
        self.device_id = device_id
        self.width = width
        self.height = height
        self.cap = None

    def open(self):
        self.cap = cv2.VideoCapture(self.device_id)
        self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.width)
        self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.height)
        if not self.cap.isOpened():
            raise RuntimeError(f"摄像头 {self.device_id} 打不开,检查连接")

    def read(self):
        ret, frame = self.cap.read()
        if not ret:
            return None
        return frame

    def release(self):
        if self.cap:
            self.cap.release()

你看,这样一封装,摄像头初始化、读取、释放都在一个类里。我在项目中遇到过一个问题:多个检测器共用同一个摄像头,结果有人直接调 cap.read(),有人又调了 cap.release(),程序跑着跑着就崩了。用类封装后,加个引用计数,问题就解决了。

再说说继承。做行人检测,你可能会有不同的检测器:YOLO、SSD、或者传统的 HOG+SVM。它们都有共同的接口:加载模型、推理、后处理。这时候用基类定义接口,子类实现细节,代码就干净了。

我的建议:在嵌入式视觉项目里,把摄像头、检测器、显示模块都写成类。哪怕你只写一个脚本,也养成这个习惯。后面加功能、换硬件,你会感谢自己的。

3.2 装饰器:给函数加点“料”

装饰器,说白了就是一个函数,用来修改另一个函数的行为。在视觉任务里,最常见的场景就是计时、日志、权限检查。

举个例子,你写了一个行人检测函数,想知道它跑了多久。最笨的办法是在函数开头记时间,结尾算差值。但如果你有十个函数都要计时呢?每个都写一遍?太累了。装饰器就是干这个的。

import time

def timer(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        end = time.time()
        print(f"{func.__name__} 耗时 {end - start:.3f} 秒")
        return result
    return wrapper

@timer
def detect_pedestrians(frame):
    # 这里是检测逻辑
    time.sleep(0.1)  # 模拟耗时
    return []

嗯,这里要注意:装饰器里的 wrapper 函数,参数要用 *args 和 **kwargs,这样才能适配任意函数。我曾经犯过一个错,装饰器写死了参数个数,结果换了个函数就报错,排查了半天。

在边缘设备上,装饰器还有一个妙用:缓存推理结果。比如你检测到某帧没有行人,下一帧可能也没有,你可以用装饰器缓存结果,减少模型推理次数。当然,这要看具体场景,不能乱用。

避坑指南:我曾经在装饰器里直接修改了被装饰函数的返回值,结果调用方拿到的数据格式变了,导致后续处理全错。记住,装饰器最好只做“附加”操作,不要改变函数的核心行为。

3.3 多线程与多进程:让边缘设备跑起来

边缘设备算力有限,但我们可以通过并发来榨干每一滴性能。多线程和多进程,很多人搞混。我简单说:多线程适合 I/O 密集型任务,比如读取摄像头、网络请求;多进程适合 CPU 密集型任务,比如模型推理。

为什么?因为 Python 有 GIL(全局解释器锁),多线程在 CPU 密集任务上反而可能更慢。但在 I/O 等待时,GIL 会释放,所以多线程能提高效率。

在行人检测项目里,我常用的架构是:一个线程专门读摄像头,一个线程做检测,一个线程做显示。这样读帧和检测可以并行,不会因为检测慢了就卡住画面。

import threading
import queue

class Pipeline:
    def __init__(self, camera, detector):
        self.camera = camera
        self.detector = detector
        self.frame_queue = queue.Queue(maxsize=2)
        self.result_queue = queue.Queue(maxsize=2)

    def capture_thread(self):
        while True:
            frame = self.camera.read()
            if frame is None:
                break
            self.frame_queue.put(frame)

    def detect_thread(self):
        while True:
            frame = self.frame_queue.get()
            results = self.detector.detect(frame)
            self.result_queue.put((frame, results))

    def start(self):
        t1 = threading.Thread(target=self.capture_thread)
        t2 = threading.Thread(target=self.detect_thread)
        t1.start()
        t2.start()

你看,用队列来传递数据,线程之间解耦。我建议队列大小不要太大,2-3 就够了,否则延迟会很高。实时系统最怕延迟。

再说多进程。如果你要在边缘设备上同时跑多个模型,比如一个检测行人,一个检测车辆,那就用多进程。每个进程有自己的 GIL,可以真正并行。但要注意,进程间通信开销大,数据要序列化,所以别频繁传递大图片。

特性 多线程 多进程
适用场景 I/O 密集型(读摄像头、网络) CPU 密集型(模型推理)
GIL 影响 有,但 I/O 时释放 无,每个进程独立
数据共享 容易,共享内存 困难,需序列化
启动开销
崩溃影响 可能影响主进程 独立,不影响其他进程

我的经验:在树莓派或 Jetson Nano 上做实时检测,我通常用多线程架构。一个线程读帧,一个线程做预处理(缩放、归一化),一个线程跑模型推理。如果模型太大,推理时间超过 100ms,那就考虑用多进程,或者换更轻量的模型。

3.4 综合实战:一个简单的行人检测流水线

好了,我们把前面三个知识点串起来。写一个简单的行人检测流水线,用类封装摄像头和检测器,用装饰器计时,用多线程并行处理。

import cv2
import time
import threading
import queue

def timer(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        print(f"{func.__name__}: {time.time()-start:.3f}s")
        return result
    return wrapper

class PedestrianDetector:
    def __init__(self, model_path):
        self.net = cv2.dnn.readNet(model_path)

    @timer
    def detect(self, frame):
        # 简化的检测逻辑
        blob = cv2.dnn.blobFromImage(frame, 1/255.0, (416, 416))
        self.net.setInput(blob)
        outputs = self.net.forward()
        return outputs

class Pipeline:
    def __init__(self, camera_id=0):
        self.camera = Camera(camera_id)
        self.detector = PedestrianDetector("yolo.weights")
        self.frame_queue = queue.Queue(maxsize=2)
        self.result_queue = queue.Queue(maxsize=2)

    def capture(self):
        self.camera.open()
        while True:
            frame = self.camera.read()
            if frame is None:
                break
            self.frame_queue.put(frame)

    def process(self):
        while True:
            frame = self.frame_queue.get()
            results = self.detector.detect(frame)
            self.result_queue.put((frame, results))

    def run(self):
        t1 = threading.Thread(target=self.capture)
        t2 = threading.Thread(target=self.process)
        t1.start()
        t2.start()
        t1.join()
        t2.join()

if __name__ == "__main__":
    pipe = Pipeline()
    pipe.run()

这段代码虽然简单,但结构清晰。你想想看,如果以后要换摄像头,只需要改 Camera 类;要换检测器,只需要改 PedestrianDetector 类。这就是面向对象的好处。装饰器让你一眼就能看到每个函数的耗时,方便调优。多线程让读帧和检测并行,帧率自然就上去了。

嗯,今天就到这里。这些基础看似简单,但真正用好,需要你在项目中反复打磨。下一章我们会深入 YOLO 模型在边缘设备上的部署,到时候这些知识都会用上。

课后练习:把上面的 Pipeline 改成多进程版本。注意进程间如何传递图片数据。提示:可以用 multiprocessing.Queue,但图片要先压缩再传。