☰
离线内网环境搭建交通路牌识别流水线:Anaconda+PyTorch+labelme全流程
2026/9/26 7:55:15 网站建设 项目流程

1. 项目缘起与整体思路拆解

1.1 这个方案到底在做什么

先把话说清楚:这套方案的核心目标,是在一台完全离线的本地机器上,从零搭起一条“交通路牌识别”的小型流水线。整条链路包括四个环节——用 Anaconda 管环境、用 PyTorch 跑模型、用 labelme 做数据标注、用 VSCode 写代码调试,最后用 FinalFTP 把成果文件夹传到指定位置。全程不碰远程服务器,不依赖云端算力,所有东西都在本机跑完。

为什么值得单独写一篇?因为大部分公开教程默认你有网、有 GPU 集群、有现成的标注平台。但现实里很多场景就是断网的:实验室的内网机、工厂车间的工控机、学校机房的固定终端。这种环境下,装个包都可能卡半天,更别说跑通一条完整链路。我前后在三四台这样的机器上复现过类似流程,踩的坑足够写满一页纸,所以把可复用的部分整理出来。

适合谁看:刚接触目标检测、需要自己标数据跑模型的学生;被分配到内网环境做算法验证的工程师;以及想搞清楚“标注—训练—推理”这条链路到底怎么串起来的人。不需要你有多深的深度学习背景,但得会基本的命令行操作,能看懂 Python 报错。

1.2 为什么选这套工具组合

工具选型不是拍脑袋定的,每个都有明确的替代关系。

Anaconda 而不是 pip + venv:内网环境下,conda 的离线包管理能力比 pip 强太多。conda 可以把整个环境(包括 Python 解释器、CUDA 运行时、各种 C 库)打包成一个.tar.bz2,拷到目标机器上直接conda install --offline就能还原。pip 的 wheel 包虽然也能离线装,但遇到需要编译的包(比如某些版本的 PyTorch 扩展)就容易翻车。而且 conda 的环境隔离更彻底,不会污染系统 Python。

PyTorch 而不是 TensorFlow:交通路牌识别属于典型的目标检测任务,PyTorch 生态里的 torchvision 提供了现成的 Faster R-CNN、RetinaNet 等预训练模型,改起来方便。另外 labelme 标注出来的 JSON 转 YOLO 格式的脚本,网上 PyTorch 版本的居多,社区支持好。TensorFlow 当然也能做,但在内网环境下,PyTorch 的离线安装包更容易搞到。

labelme 而不是 CVAT 或 LabelImg:CVAT 功能强但依赖 Docker,内网部署 Docker 本身就是个麻烦事。LabelImg 轻量但只支持矩形框,遇到路牌被遮挡、倾斜的情况,矩形框会框进大量背景。labelme 支持多边形标注,对不规则形状的路牌更友好,而且它的 JSON 格式通用性强,后续转 COCO、YOLO 都有现成脚本。

VSCode 而不是 PyCharm:PyCharm 社区版够用,但专业版才支持远程解释器(虽然这里用不上)。VSCode 的优势在于轻量、插件生态灵活,Python 插件 + Jupyter 插件 + Remote 插件(虽然不连远程,但本地多环境切换很方便)组合起来,调试体验不输 PyCharm。而且 VSCode 的配置文件是 JSON,方便版本管理和迁移。

FinalFTP 而不是直接拷贝:这个可能有人觉得多余——本地跑完直接 U 盘拷走不就行了?但实际项目里,成果往往需要归档到指定的文件服务器,或者交给另一个团队。FinalFTP 这类工具能保证传输的完整性和可追溯性,比手动拖拽靠谱。尤其是大文件(模型权重、标注数据集)传输时,断点续传和校验功能很重要。

1.3 整体架构与数据流向

整个流程的数据流向是这样的:

原始道路视频素材 → 抽帧成图片 → labelme 标注 → 生成 JSON → 转成训练格式(COCO 或 YOLO)→ PyTorch 加载预训练模型 → 微调训练 → 导出权重 → 推理测试 → 成果文件夹 → FinalFTP 上传。

这里面有几个关键决策点。第一,抽帧频率怎么定?交通路牌在视频里出现的时间通常很短,如果按每秒 1 帧抽,可能会漏掉。我的经验是先用 5 帧/秒抽一遍,看看路牌出现的密度,再决定是否加密。第二,标注策略怎么定?如果只是做验证,标 200-300 张就够看效果;如果要上线,至少 2000 张起步,且要覆盖不同光照、角度、遮挡情况。第三,预训练模型选哪个?Faster R-CNN 精度高但慢,YOLO 快但小目标容易漏。路牌识别里小目标不少,所以初期用 Faster R-CNN 验证可行性,后期再考虑蒸馏到 YOLO。

2. 环境搭建与核心工具配置

2.1 Anaconda 离线环境搭建

内网机器装 Anaconda,最稳的办法是提前在有网的机器上下好安装包和依赖包,然后整体迁移。

安装包去 Anaconda 官网下对应的版本,Windows 选.exe,Linux 选.sh。注意版本不要太新,太新的版本可能和 PyTorch 的某些依赖冲突。我一般选比最新版落后一两个小版本的,比如 2023.09 这种,稳定性经过验证。

装完之后第一件事是配镜像源。虽然内网用不上,但如果你是在半离线环境(能访问内网镜像站),配一下能省很多事。命令是:

conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main/ conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free/ conda config --set show_channel_urls yes

如果是完全离线,就跳过这步,直接准备离线包。离线包的获取方式:在有网的机器上conda create -n traffic_sign python=3.9,然后conda install pytorch torchvision torchaudio cudatoolkit=11.3 -c pytorch,装完后用conda pack -n traffic_sign -o traffic_sign.tar.gz打包。把.tar.gz拷到内网机器,解压到envs目录下就能用。

注意:conda pack打包的环境,路径是写死的。如果内网机器的 Anaconda 安装路径和有网机器不一样,需要手动改一下环境里的conda-meta和bin下的脚本路径。我一般把两台机器的 Anaconda 都装在同一个路径下,省得改。

2.2 PyTorch 与 CUDA 版本匹配

这是最容易出问题的地方。PyTorch 版本、CUDA 版本、显卡驱动版本,三者必须匹配。

先看显卡驱动支持的最高 CUDA 版本。命令行输入nvidia-smi,右上角会显示CUDA Version: 11.6之类的。这个数字是驱动支持的最高版本,不是你必须装的版本。你可以装比它低的 CUDA 版本。

然后去 PyTorch 官网查版本对应表。比如 PyTorch 1.12.1 对应 CUDA 11.3 和 11.6,PyTorch 2.0 对应 CUDA 11.7 和 11.8。选一个比你驱动支持版本低的 CUDA,比如驱动支持 11.6,你就选 CUDA 11.3 的 PyTorch,稳。

安装命令(以 CUDA 11.3 为例):

conda install pytorch==1.12.1 torchvision==0.13.1 torchaudio==0.12.1 cudatoolkit=11.3 -c pytorch

装完验证:

import torch print(torch.__version__) print(torch.cuda.is_available()) print(torch.cuda.get_device_name(0))

如果cuda.is_available()返回 False,先检查驱动,再检查 CUDA 版本是否匹配。有时候是环境变量的问题,CUDA_HOME没设对。

实操心得:如果内网机器没有 NVIDIA 显卡,或者显卡太老不支持 CUDA,那就装 CPU 版本的 PyTorch。命令里去掉cudatoolkit,把pytorch换成pytorch-cpu。CPU 训练慢,但验证流程够用。

2.3 labelme 安装与中文界面配置

labelme 的安装坑比较多,尤其是 PyQt5 的依赖问题。

标准安装命令:

pip install labelme

但内网环境下 pip 也可能用不了,所以还是走 conda:

conda install -c conda-forge labelme

如果报pyqt5-sip相关的错,说明 PyQt5 版本和 labelme 不兼容。解决办法是手动指定 PyQt5 版本:

pip install PyQt5==5.15.4 pip install labelme==5.8.3

labelme 5.8.3 是我用过比较稳的版本,界面响应快,JSON 格式也规范。

中文界面配置:labelme 默认是英文,但可以改。找到 labelme 安装目录下的config/default_config.yaml,把labels里的类别名改成中文。不过界面按钮的文字是硬编码的,改不了。如果非要中文界面,可以找汉化版的 labelme,但我不建议——汉化版往往版本滞后,而且可能引入未知 bug。类别名用中文就够了,界面英文看两天就习惯了。

注意:labelme 标注时,类别名不要用特殊字符,空格也不要用。用下划线连接,比如speed_limit、no_parking。不然后面转格式的时候容易出问题。

2.4 VSCode 配置 Python 与调试环境

VSCode 本身是绿色软件,下载 zip 版解压就能用,不需要安装。内网机器上尤其方便。

装完之后装几个必备插件:Python、Pylance、Jupyter。如果要做 C++ 扩展(比如自定义算子),再装 C/C++ 插件。

配置 Python 解释器:Ctrl+Shift+P打开命令面板,输入Python: Select Interpreter,选你 conda 环境下的python.exe。路径一般是Anaconda安装目录/envs/traffic_sign/python.exe。

调试配置:在项目根目录建.vscode/launch.json,内容如下:

{ "version": "0.2.0", "configurations": [ { "name": "Python: Current File", "type": "python", "request": "launch", "program": "${file}", "console": "integratedTerminal", "justMyCode": false } ] }

justMyCode设为 false,这样调试时能进到 PyTorch 的源码里,看底层实现。对理解模型内部机制很有帮助。

实操心得:VSCode 的 Jupyter 插件支持在.ipynb里直接跑代码,适合做数据探索和模型验证。但正式训练脚本还是用.py文件,方便版本管理和参数化。

3. 数据标注与格式转换实操

3.1 道路视频抽帧策略

原始素材是道路视频,需要先抽成图片。工具用 FFmpeg,命令行:

ffmpeg -i road_video.mp4 -vf fps=5 frames/frame_%06d.jpg

fps=5表示每秒抽 5 帧。为什么是 5?交通路牌在视频里通常持续 2-3 秒,5 帧/秒能保证每个路牌至少出现 10 帧以上,足够覆盖不同角度。如果视频里路牌出现时间更短(比如高速行驶时),可以提到 10 帧/秒。

抽完帧后,先人工过一遍,把模糊的、重复度太高的删掉。我一般会保留 60%-70% 的帧,剩下的删掉。标注 1000 张清晰图片,比标注 2000 张模糊图片效果好。

注意:抽帧后的图片命名要有规律,比如frame_000001.jpg,方便后续和标注文件对应。不要用中文命名,不要有空格。

3.2 labelme 标注规范与技巧

打开 labelme:

labelme

界面出来后,Open Dir选抽帧文件夹,然后开始标。

标注规范:

  • 路牌边界要贴紧,不要留太多背景。但也不要贴太紧,留 1-2 个像素的余量,防止训练时数据增强把边缘裁掉。
  • 遮挡超过 50% 的路牌,不标。标了反而引入噪声。
  • 同一张图里多个路牌,每个都单独标,不要合并成一个框。
  • 类别名统一用英文小写加下划线,比如speed_limit_60、no_left_turn。

labelme 的快捷键:Ctrl+N下一张,Ctrl+S保存,Ctrl+Z撤销。标的时候左手放键盘上,右手握鼠标,效率能提高不少。

标完的 JSON 文件长这样:

{ "version": "5.8.3", "flags": {}, "shapes": [ { "label": "speed_limit_60", "points": [[100, 200], [150, 200], [150, 250], [100, 250]], "shape_type": "polygon", "flags": {} } ], "imagePath": "frame_000001.jpg", "imageData": null, "imageHeight": 1080, "imageWidth": 1920 }

points是多边形的顶点坐标,shape_type是polygon。如果标的是矩形,shape_type是rectangle,points只有两个点(左上和右下)。

3.3 JSON 转 COCO 与 YOLO 格式

labelme 的 JSON 不能直接喂给 PyTorch,需要转格式。转 COCO 格式的脚本:

import json import os from PIL import Image def labelme_to_coco(json_dir, output_file): coco = { "images": [], "annotations": [], "categories": [] } categories = {} annotation_id = 1 for idx, json_file in enumerate(os.listdir(json_dir)): if not json_file.endswith(".json"): continue with open(os.path.join(json_dir, json_file), "r", encoding="utf-8") as f: data = json.load(f) image_id = idx + 1 coco["images"].append({ "id": image_id, "file_name": data["imagePath"], "height": data["imageHeight"], "width": data["imageWidth"] }) for shape in data["shapes"]: label = shape["label"] if label not in categories: categories[label] = len(categories) + 1 coco["categories"].append({ "id": categories[label], "name": label, "supercategory": "traffic_sign" }) points = shape["points"] xs = [p[0] for p in points] ys = [p[1] for p in points] x_min, x_max = min(xs), max(xs) y_min, y_max = min(ys), max(ys) coco["annotations"].append({ "id": annotation_id, "image_id": image_id, "category_id": categories[label], "bbox": [x_min, y_min, x_max - x_min, y_max - y_min], "area": (x_max - x_min) * (y_max - y_min), "iscrowd": 0 }) annotation_id += 1 with open(output_file, "w", encoding="utf-8") as f: json.dump(coco, f, ensure_ascii=False, indent=2) labelme_to_coco("labels", "annotations.json")

转 YOLO 格式的脚本:

import json import os def labelme_to_yolo(json_dir, output_dir, class_file): classes = [] for json_file in os.listdir(json_dir): if not json_file.endswith(".json"): continue with open(os.path.join(json_dir, json_file), "r", encoding="utf-8") as f: data = json.load(f) img_w = data["imageWidth"] img_h = data["imageHeight"] txt_lines = [] for shape in data["shapes"]: label = shape["label"] if label not in classes: classes.append(label) class_id = classes.index(label) points = shape["points"] xs = [p[0] for p in points] ys = [p[1] for p in points] x_min, x_max = min(xs), max(xs) y_min, y_max = min(ys), max(ys) x_center = (x_min + x_max) / 2 / img_w y_center = (y_min + y_max) / 2 / img_h width = (x_max - x_min) / img_w height = (y_max - y_min) / img_h txt_lines.append(f"{class_id} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}") txt_file = os.path.join(output_dir, json_file.replace(".json", ".txt")) with open(txt_file, "w") as f: f.write("\n".join(txt_lines)) with open(class_file, "w") as f: f.write("\n".join(classes)) labelme_to_yolo("labels", "yolo_labels", "classes.txt")

注意:YOLO 格式的坐标是归一化的(0-1 之间),COCO 格式是绝对像素坐标。转的时候别搞混。

4. 模型训练与推理全流程

4.1 预训练模型选择与加载

交通路牌识别用 torchvision 里的 Faster R-CNN 就够。加载预训练权重:

import torchvision from torchvision.models.detection import FasterRCNN from torchvision.models.detection.rpn import AnchorGenerator backbone = torchvision.models.mobilenet_v2(pretrained=True).features backbone.out_channels = 1280 anchor_generator = AnchorGenerator( sizes=((32, 64, 128, 256, 512),), aspect_ratios=((0.5, 1.0, 2.0),) ) roi_pooler = torchvision.ops.MultiScaleRoIAlign( featmap_names=['0'], output_size=7, sampling_ratio=2 ) model = FasterRCNN( backbone, num_classes=len(classes) + 1, rpn_anchor_generator=anchor_generator, box_roi_pool=roi_pooler )

num_classes要加 1,因为背景也算一类。anchor_generator的sizes要根据路牌的实际大小调。如果路牌在图片里普遍偏小,就把sizes调小,比如((16, 32, 64, 128, 256),)。

实操心得:如果内网机器显存小(比如 4GB),把 backbone 换成mobilenet_v2或resnet18,别用resnet50。batch size 设成 2 或 4,用梯度累积模拟大 batch。

4.2 训练脚本与参数配置

训练脚本的核心部分:

import torch from torch.utils.data import DataLoader from torchvision.datasets import CocoDetection import torchvision.transforms as T def get_transform(): return T.Compose([ T.ToTensor(), T.RandomHorizontalFlip(0.5), T.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2) ]) dataset = CocoDetection( root="images", annFile="annotations.json", transforms=get_transform() ) def collate_fn(batch): return tuple(zip(*batch)) data_loader = DataLoader( dataset, batch_size=4, shuffle=True, num_workers=2, collate_fn=collate_fn ) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model.to(device) params = [p for p in model.parameters() if p.requires_grad] optimizer = torch.optim.SGD(params, lr=0.005, momentum=0.9, weight_decay=0.0005) lr_scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=3, gamma=0.1) num_epochs = 10 for epoch in range(num_epochs): model.train() for images, targets in data_loader: images = [img.to(device) for img in images] targets = [{k: v.to(device) for k, v in t.items()} for t in targets] loss_dict = model(images, targets) losses = sum(loss for loss in loss_dict.values()) optimizer.zero_grad() losses.backward() optimizer.step() lr_scheduler.step() print(f"Epoch {epoch+1}, Loss: {losses.item():.4f}") torch.save(model.state_dict(), f"checkpoint_epoch_{epoch+1}.pth")

学习率设 0.005 是 Faster R-CNN 微调的常用值。如果 loss 震荡,降到 0.001。StepLR每 3 个 epoch 降一次学习率,防止后期震荡。

注意:CocoDetection返回的targets格式和 Faster R-CNN 期望的格式不完全一样,需要手动转一下。具体是targets里的boxes要转成[x_min, y_min, x_max, y_max]格式,labels要转成 tensor。

4.3 推理测试与效果评估

训练完加载权重做推理:

model.load_state_dict(torch.load("checkpoint_epoch_10.pth")) model.eval() import cv2 import numpy as np img = cv2.imread("test.jpg") img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img_tensor = T.ToTensor()(img_rgb).unsqueeze(0).to(device) with torch.no_grad(): predictions = model(img_tensor) boxes = predictions[0]["boxes"].cpu().numpy() scores = predictions[0]["scores"].cpu().numpy() labels = predictions[0]["labels"].cpu().numpy() threshold = 0.5 for box, score, label in zip(boxes, scores, labels): if score < threshold: continue x_min, y_min, x_max, y_max = box.astype(int) cv2.rectangle(img, (x_min, y_min), (x_max, y_max), (0, 255, 0), 2) cv2.putText(img, f"{classes[label-1]}: {score:.2f}", (x_min, y_min-10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 2) cv2.imwrite("result.jpg", img)

评估指标用 mAP(mean Average Precision)。torchvision 里有现成的MeanAveragePrecision:

from torchvision.ops import box_iou from torchmetrics.detection import MeanAveragePrecision metric = MeanAveragePrecision() metric.update(predictions, targets) print(metric.compute())

mAP 超过 0.5 就算可用,超过 0.7 算不错。如果低于 0.5,先检查标注质量,再检查训练轮数够不够。

5. 常见问题与排查技巧实录

5.1 环境类问题速查

问题现象可能原因解决办法
conda install卡在 Solving environment依赖冲突用conda install -c conda-forge 包名指定频道,或改用 pip
torch.cuda.is_available()返回 FalseCUDA 版本不匹配检查nvidia-smi和 PyTorch 版本对应表,重装匹配版本
labelme 启动报PyQt5错误PyQt5 版本不兼容pip install PyQt5==5.15.4降级
VSCode 找不到 conda 环境解释器路径没配Ctrl+Shift+P→Python: Select Interpreter→ 手动输入路径
FinalFTP 上传中断网络不稳定或文件太大开启断点续传,分卷压缩后上传

5.2 标注与训练类问题

标注文件丢失或损坏:labelme 保存时如果程序崩溃,JSON 可能只写了一半。预防办法是标完一批就备份一次。如果已经损坏,用json.load()会报错,可以尝试用json.loads()逐行读,把能恢复的恢复出来。

类别不平衡:交通路牌里某些类别(比如限速 60)出现频率远高于其他类别。解决办法是在 loss 里加权重,或者对稀有类别做数据增强(旋转、裁剪、加噪声)。

过拟合:训练 loss 降但验证 loss 升。解决办法是加 dropout、加 weight decay、减少模型参数量、增加数据量。我一般先加数据,数据不够再加正则化。

小目标漏检:路牌在图片里只占几十个像素。解决办法是调小 anchor 的sizes,或者用 FPN(Feature Pyramid Network)结构。torchvision 的 Faster R-CNN 默认带 FPN,如果没用,可以手动加。

实操心得:训练前先用少量数据(比如 50 张)跑一遍,确认整个链路能通。别一上来就全量数据跑,出了问题排查成本太高。

5.3 FinalFTP 上传与成果归档

成果文件夹的结构建议这样组织:

traffic_sign_project/ ├── code/ # 训练和推理脚本 ├── data/ # 标注数据 │ ├── images/ # 原始图片 │ ├── labels/ # labelme JSON │ └── annotations.json # COCO 格式 ├── models/ # 模型权重 │ ├── checkpoint_epoch_10.pth │ └── best_model.pth ├── results/ # 推理结果 │ └── result.jpg └── README.md # 项目说明

FinalFTP 上传时,先压缩成 zip,再上传。压缩命令:

zip -r traffic_sign_project.zip traffic_sign_project/

上传后校验 MD5:

md5sum traffic_sign_project.zip

把 MD5 值记下来,对方下载后也算一遍,对比一致才算传输成功。

注意:FinalFTP 的被动模式和主动模式,内网环境下被动模式通常更稳。如果连不上,试试切换模式。

6. 一些零散但重要的经验

6.1 关于离线环境的包管理

内网机器最头疼的是装包。我的做法是维护一个“离线包仓库”文件夹,里面放常用的.tar.bz2和.whl文件。每次有新需求,先在有网机器上conda install --download-only把包下下来,再拷进去。

conda install --download-only会把包下到pkgs目录,不实际安装。然后把这个目录整个拷到内网机器的pkgs目录,再conda install --offline 包名就能装。

pip 的离线包用pip download 包名 -d ./offline_packages,然后pip install --no-index --find-links=./offline_packages 包名。

6.2 关于 VSCode 的远程开发(本地多环境)

虽然不连远程服务器,但 VSCode 的 Remote 插件可以连本地 WSL 或 Docker。如果内网机器是 Windows,但你想用 Linux 环境跑训练,可以装 WSL2,然后用 VSCode 的 Remote-WSL 插件连进去。这样既保留了 Windows 的便利,又有了 Linux 的兼容性。

WSL2 里装 Anaconda 和 PyTorch 的流程和纯 Linux 一样。注意 WSL2 的 CUDA 支持需要 Windows 驱动版本足够新,且要装 WSL 专用的 CUDA 驱动。

6.3 关于模型部署的后续扩展

训练完的模型如果要部署到边缘设备(比如 Jetson Nano),需要转成 ONNX 或 TensorRT。转 ONNX:

torch.onnx.export( model, dummy_input, "traffic_sign.onnx", opset_version=11, input_names=["input"], output_names=["boxes", "labels", "scores"] )

转完之后用onnxruntime验证一下输出是否一致。如果部署到 TensorRT,再用trtexec转成 engine 文件。

这个方案后续还可以扩展成视频流实时推理:用 OpenCV 读视频帧,逐帧推理,画框后写回视频文件。帧率低的话(比如 5 FPS),CPU 也能跑。

6.4 最后分享一个小技巧

labelme 标注时,如果同一张图里有多个相同类别的路牌,可以用Ctrl+C和Ctrl+V复制粘贴标注框,然后拖动到新位置。比重新画快很多。但粘贴后的框要微调,因为路牌角度可能不一样。

另外,labelme 的Edit菜单里有Undo和Redo,标错了别急着删,先撤销。删了再重画,容易把坐标画偏。

这个项目我前后复现了三次,每次都有新坑。第一次是 CUDA 版本不对,第二次是 labelme 的 PyQt5 冲突,第三次是 FinalFTP 传输中断。每次解决完都记下来,现在这套流程已经比较顺了。如果你也在内网环境做类似的事,希望这些经验能帮你少走点弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询