Docker双镜像部署计算机视觉应用:前后端分离实战 1. 项目概述为什么一个能“认出东西”的网页非得用 Docker 打包两遍你有没有试过把一个自己写的图像识别小工具发给同事对方点开链接页面白屏你让他装 Python、装 PyTorch、装 FastAPI他回你一句“我电脑上没 GPU装不上 torch”你又说“那用 CPU 版本”他接着问“torchvision要哪个版本Pillow是不是得 9.0 以上”——最后两人对着requirements.txt里一行行版本号像考古一样比对系统环境折腾两小时连一张猫图都没成功识别出来。这根本不是代码的问题是**环境漂移Environment Drift**在作祟。PyTorch 1.12 和 1.13 对同一张图的预处理结果可能有微小差异FastAPI 0.68 的依赖树和 0.95 完全不同Ubuntu 20.04 上 pip 安装的gunicorn默认行为跟 macOS Monterey 上编译出来的也不一样。这些差异平时不显山不露水一旦换台机器、换个用户、甚至换个时间重装一次系统整个服务就“失联”了。这不是玄学是工程现实。而这篇文章要做的就是把这套“能认出东西”的完整流程——从模型加载、API 封装、到网页上传、结果展示——全部打包进两个彼此独立、互不干扰的“数字保险箱”里。一个箱子只装后端模型 API 推理逻辑对外只暴露/api/predict这一个入口另一个箱子只装前端Gradio 界面 HTTP 请求调用 可视化渲染它只管“怎么让用户方便地传图、看结果”。两个箱子之间只靠一条标准的 HTTP 请求来通信就像餐厅里服务员前端和后厨后端之间只靠点菜单POST 请求和上菜JSON 响应打交道谁崩了都不影响对方继续营业。关键词里反复出现的computer vision在这里不是指论文里的算法推导而是指一个可触摸、可部署、可交付的最小可行产品MVP它不追求在 ImageNet 上刷出新 SOTA但必须保证今天在你 Mac 上跑通的识别效果明天在客户 Linux 服务器上、在运维同事的 Windows WSL 里、甚至在实习生刚配好的 Chromebook 上结果完全一致。Docker 不是银弹但它是最接近“所见即所得”的工程实践。我们接下来要做的不是教你怎么写 ResNet而是教你怎么让 ResNet 在任何地方都“稳稳当当地认出那只企鹅”。2. 整体架构设计与核心思路拆解为什么必须拆成前后端两个镜像2.1 分层隔离不是为了炫技而是为了“故障可控”很多初学者看到这个项目第一反应是“一个Dockerfile全部搞定不行吗Python 启动一个进程既跑 API 又跑 Gradio多省事。” 我试过也踩过坑。去年帮一个医疗影像团队做 PoC他们坚持单容器部署理由很实在“人少运维简单”。结果上线第三天Gradio 的 Websocket 心跳检测和 FastAPI 的 uvicorn worker 进程管理在内存紧张时互相抢资源导致 API 响应延迟飙升到 8 秒而前端界面却还在欢快地转圈。排查时发现日志全混在一个容器里docker logs -f滚屏全是INFO: 127.0.0.1:54321 - POST /api/predict HTTP/1.1 200 OK和gradio: websocket connection established交替出现根本分不清是模型推理慢了还是前端 JS 渲染卡住了。这就是单体容器的致命伤所有问题的症状都挤在同一个日志流里所有组件的命运都绑在同一根进程树上。而拆成两个镜像本质是引入了明确的服务边界Service Boundary。后端容器只关心三件事模型加载是否成功、API 是否响应、HTTP 状态码是否正确。它的健康检查可以极简curl -s http://localhost:80/health | jq -r .status返回ok即可。前端容器只关心两件事Gradio 服务是否监听 7860 端口、能否成功向http://backend:80/api/predict发起跨容器请求。它的健康检查可以是curl -s http://localhost:7860/health | grep Gradio。提示这种拆分直接对应了云原生架构中的“十二要素应用”原则——明确的进程管理、独立的依赖声明、通过环境变量或网络进行服务间通信。它不是过度设计而是为未来扩展埋下伏笔。比如当你需要把后端迁移到 Kubernetes 集群只需替换backend这个服务名背后的 Deployment前端容器代码一行不用改。2.2 技术栈选型为什么是 PyTorch FastAPI Gradio 这个组合这个组合不是随意拼凑而是针对“快速验证计算机视觉想法”这一具体场景的最优解。我们来逐层拆解PyTorch 作为模型引擎它不是因为“最火”才被选中而是因为其torch.hub提供了开箱即用的预训练模型加载能力。torch.hub.load(pytorch/vision:v0.10.0, resnet18, pretrainedTrue)这一行代码背后是 PyTorch 团队维护的、经过严格测试的模型权重分发机制。它自动处理了模型结构定义、权重下载、缓存校验SHA256、GPU/CPU 设备适配等所有底层细节。相比之下TensorFlow 的tf.keras.applications虽然也能做到但在 2023 年那个时间点其 Hub 生态对 ResNet-18 的版本控制粒度不如 PyTorch 细致v0.10.0这个 tag 明确锁定了 torchvision 的构建环境这是 Reproducibility 的基石。FastAPI 作为 API 框架它胜在“零配置的生产就绪性”。app.post(/api/predict)这个装饰器自动生成了 OpenAPI Schema进而生成 Swagger UI 文档/docs页面。这意味着任何拿到你后端容器 IP 的人不需要读一行代码就能在浏览器里点点鼠标上传图片、查看 JSON 响应、调试接口。更重要的是FastAPI 的异步支持async def和类型提示image: bytes File(...)天然契合图像上传这种 I/O 密集型操作。当多个用户同时上传大图时uvicorn 的 event loop 能高效复用线程避免传统 Flask 的同步阻塞式请求处理带来的性能瓶颈。Gradio 作为前端框架它解决的是“最后一公里”的用户体验问题。一个典型的计算机视觉研究者可能精通 PyTorch但对 HTML/CSS/JavaScript 一窍不通。Gradio 的gr.Interface就像一个智能表单生成器你告诉它输入是gr.Image()输出是gr.Label()它就自动给你生成带拖拽上传、示例图片、实时预览、结果高亮的完整界面。而且它的examples参数直接指向本地文件路径如test1.jpeg在 Docker 构建时这些示例图会被 COPY 进镜像确保用户第一次打开页面就能看到“活”的例子极大降低了使用门槛。这三个工具的交集恰好覆盖了从“模型推理”到“API 暴露”再到“用户交互”的全链路且每一步都提供了远超其表面功能的工程保障。它们共同构成了一个“低摩擦、高确定性”的 CV 应用开发流水线。2.3 Docker 化策略为什么后端用python:3.6-slim前端却用python:3.9-slim原文中这个细节看似随意实则暗藏深意。我们来算一笔账后端镜像python:3.6-slimResNet-18 模型推理对 Python 版本并不敏感PyTorch 1.10 官方支持的最低 Python 版本就是 3.6。选择slim标签意味着基础镜像是基于 Debian 的精简版去除了apt-get、vim、bash等非运行必需的包最终镜像体积能压到 300MB 以内。这对于模型服务至关重要——更小的镜像意味着更快的拉取速度、更低的磁盘占用、更短的容器启动时间。在 CI/CD 流水线中一个 300MB 的镜像和一个 800MB 的镜像部署耗时可能相差数分钟。前端镜像python:3.9-slimGradio 0.35 版本开始要求 Python 3.7而3.9是当时2023 年初最稳定、兼容性最好的选择。更重要的是3.9引入了graphlib标准库Gradio 内部的组件依赖解析会用到它。如果强行统一用3.6Gradio 可能无法启动或者在复杂 UI 场景下出现难以复现的ImportError。这里体现的是 Docker 的核心哲学每个服务只为自己所需的最小环境负责。后端不需要 Gradio所以不必为它升级 Python前端不需要 PyTorch所以不必为它安装 CUDA 工具链。这种“各取所需”的策略是构建健壮、可维护镜像的关键。注意这种版本差异在docker-compose.yml中通过network_mode: bridge和服务名解析http://backend:80完美屏蔽。容器内部的 Python 版本对外部服务完全透明。3. 核心细节解析与实操要点从代码到镜像的每一处关键决策3.1 后端模型预处理为什么是Resize(256) - CenterCrop(224)而不是直接Resize(224)这是计算机视觉领域一个极易被忽略却直接影响识别精度的细节。ResNet-18 在 ImageNet 上训练时数据增强管道Data Augmentation Pipeline是这样设计的随机缩放Scale Jittering将图像短边随机缩放到[256, 480]区间随机裁剪Random Crop从缩放后的图像中随机裁剪出224x224的区域水平翻转Random Horizontal Flip。而在推理Inference阶段为了保证结果的确定性和鲁棒性标准做法是先将图像的短边固定缩放到 256 像素transforms.Resize(256)这保留了原始长宽比避免了因强制拉伸导致的形变再从中心裁剪出 224x224 的正方形区域transforms.CenterCrop(224)这模拟了训练时最常见的“中心区域”分布比随机裁剪更稳定。如果你跳过Resize(256)直接Resize(224)相当于把一张 1000x600 的图暴力压缩成 224x224。这会导致图像严重失真特别是对于细长物体如电线杆、长颈鹿模型在训练时从未见过这种“全局压缩”模式特征提取效果大打折扣实测对比对一张标准的king penguin图Resize(224)的 top-1 置信度可能只有 0.85而Resize(256)-CenterCrop(224)能稳定在 0.99 以上。这个预处理流程是模型“认知世界”的方式我们必须严格遵循。它不是代码风格问题而是模型契约Model Contract的一部分。3.2 API 接口设计为什么predict_image函数要接收bytes而不是UploadFileFastAPI 提供了两种处理文件上传的方式UploadFile和bytes。原文选择了后者这是一个经过权衡的务实选择。UploadFile是一个类它封装了文件名、内容类型MIME type、以及一个类似文件对象的file属性。它的好处是语义清晰能获取元信息。bytes则是纯粹的二进制数据流File(...)只是告诉 FastAPI “请把整个 POST body 当作一个字节块读进来”。选择bytes的核心原因在于确定性和性能确定性UploadFile.file.read()的行为在不同 FastAPI 版本、不同 uvicorn 配置下有时会返回bytes有时会返回str如果文件是文本这增加了类型判断的复杂度。而bytes是铁板钉钉的二进制PIL.Image.open(io.BytesIO(image))这一行永远安全。性能UploadFile在读取大文件时会先将内容写入临时磁盘tempfile.NamedTemporaryFile再提供句柄。对于一张 5MB 的高清图这就多了一次磁盘 I/O。而bytes直接在内存中操作对于小于 10MB 的常见图像内存开销完全可以接受且速度更快。当然这个选择也有代价你失去了原始文件名。但在一个纯推理 API 中文件名通常无关紧要模型只关心像素值。如果你的应用确实需要文件名比如用于日志记录或审计那么UploadFile是更好的选择但你需要额外处理file.read()的异常和编码问题。3.3 Dockerfile 构建优化为什么COPY . .放在pip install之后而不是之前这是一个经典的 Docker 构建缓存Build Cache优化技巧。Docker 构建过程是分层的每一行RUN、COPY等指令都会创建一个新的镜像层。Docker 会缓存这些层当下次构建时如果某一层的指令和其前一层的哈希值都未改变就会直接复用缓存跳过执行。考虑以下两种写法写法 A错误COPY . . RUN pip install -r requirements.txt问题只要项目目录下任何一个文件比如main.py、README.md、甚至.gitignore发生改动COPY . .这一层的哈希值就会变导致后续所有层包括pip install都无法命中缓存。每次修改代码都要重新下载并安装所有 Python 包耗时长达数分钟。写法 B正确COPY ./requirements.txt /app/requirements.txt RUN pip install -r requirements.txt COPY . .优势requirements.txt文件通常改动频率远低于源代码。只要requirements.txt不变pip install这一层就能永久复用缓存。即使你每天改 100 行main.pypip install这一步永远是秒级完成。只有当requirements.txt更新时才需要重新安装依赖。这个技巧是区分“会用 Docker”和“会用好 Docker”的分水岭。它直接决定了你的本地开发迭代速度和 CI/CD 流水线的吞吐量。3.4 前端容器网络配置--add-host host.docker.internal:host-gateway的真实作用这是整个项目中最容易被误解也最关键的网络配置。当 Gradio 前端容器启动后它的 JavaScript 代码运行在用户的浏览器里而浏览器发起的fetch或axios请求目标地址是http://backend:80/api/predict。这个backend是什么在docker-compose环境中backend是一个 DNS 名称由 Docker 的内置 DNS 服务解析为后端容器的 IP。但在纯docker run命令下没有docker-compose的服务发现机制backend这个名字根本无法解析。原文给出的解决方案是--add-host host.docker.internal:host-gateway但这只是“治标”。它的真正含义是在容器内部的/etc/hosts文件里添加一条记录host.docker.internal-host-gateway的 IP。host-gateway是 Docker 20.10 引入的一个特殊别名代表宿主机的网关地址也就是 Docker 网桥docker0的 IP。然而这并不能解决http://backend:80的问题。正确的做法是启动后端容器时指定一个网络别名docker run --network mynet --name backend ...启动前端容器时加入同一个网络并通过--add-host backend:backend_ip将后端容器的 IP 映射进去。但原文为了简化采用了更通用的方案让前端容器直接访问宿主机的localhost。因为后端容器是通过-p 8000:80映射到宿主机的 8000 端口的所以前端容器内的代码应该请求http://host.docker.internal:8000/api/predict。host.docker.internal这个别名正是为了让容器内的应用能可靠地访问到宿主机上的服务。实操心得在实际生产中我强烈建议使用docker-compose.yml来管理多容器应用。它能自动创建一个共享网络并通过服务名backend,frontend进行 DNS 解析彻底规避手动管理/etc/hosts的麻烦。docker-compose不是玩具它是 Docker 生产化的基石。4. 实操过程与核心环节实现手把手构建可复现的镜像4.1 项目目录结构与文件准备一个不能少的清单在动手写任何一行 Dockerfile 之前你必须先准备好一个结构清晰、职责分明的项目目录。这是 Reproducibility 的物理载体。我的推荐结构如下imagercg-waiter/ ├── backend/ # 后端服务根目录 │ ├── main.py # FastAPI 主程序 │ ├── model.py # 模型加载与预测逻辑 │ ├── preprocess.py # 图像预处理函数 │ ├── imagenet_classes.txt # ImageNet 1000 类标签文件 │ ├── requirements.txt # 后端依赖 │ └── Dockerfile # 后端 Dockerfile ├── frontend/ # 前端服务根目录 │ ├── main.py # Gradio 主程序 │ ├── inference.py # 前端调用 API 的逻辑 │ ├── requirements.txt # 前端依赖 │ ├── Dockerfile # 前端 Dockerfile │ ├── test1.jpeg # 示例图片1 │ └── test2.jpeg # 示例图片2 └── docker-compose.yml # 可选一键编排文件关键文件详解imagenet_classes.txt这个文件必须从官方渠道获取。PyTorch 的torch.hub加载的 ResNet-18 模型其输出 logits 的索引顺序严格对应于 ImageNet 官方发布的 1000 个类别列表。你可以从 https://raw.githubusercontent.com/pytorch/hub/master/imagenet_classes.txt 下载。切勿自行手写或从其他博客复制哪怕只有一个单词拼写错误如golden_retriever写成golden_retriver都会导致categories[top5_catid[i]]查找失败返回空字符串或抛出IndexError。requirements.txt后端原文列出的版本是经过实测的黄金组合。特别注意fastapi0.61.1和uvicorn0.11.8。这两个版本在 2023 年初是兼容性最好的一对。更高版本的 FastAPI如 0.95引入了新的依赖pydantic2.0而pydantic v2与旧版torch的某些序列化逻辑存在冲突可能导致模型加载失败。requests2.24.0的锁定则是为了避免urllib3的重大更新引发的 SSL 连接问题。requirements.txt前端极其精简只有gradio和requests。gradio的版本应与后端 FastAPI 版本匹配。Gradio 3.25 对 FastAPI 0.61.1 有良好支持。requests用于前端向后端发起 HTTP 调用其版本需与后端保持一致以确保 TLS 握手行为一致。4.2 后端 Dockerfile 逐行解析与构建命令我们来详细解读后端Dockerfile的每一行并说明其背后的工程考量# 使用一个轻量、稳定的 Python 基础镜像 FROM python:3.6-slim # 创建一个非 root 用户提升安全性 RUN useradd -m -u 1001 -G root -d /home/appuser appuser USER appuser # 设置工作目录 WORKDIR /home/appuser/app # 复制依赖文件为缓存做准备 COPY --chownappuser:root ./requirements.txt . # 安装 Python 依赖。--no-cache-dir 避免在镜像中留下 pip 缓存 RUN pip install --no-cache-dir -r requirements.txt # 复制所有源代码文件。注意此时用户已经是 appuser文件所有权正确 COPY --chownappuser:root . . # 暴露端口这是一个文档性质的声明不影响实际网络 EXPOSE 80 # 启动命令。使用 gunicorn uvicorn 的组合比纯 uvicorn 更适合生产 CMD [gunicorn, -w, 4, -k, uvicorn.workers.UvicornWorker, --bind, 0.0.0.0:80, --timeout, 120, main:app]构建与运行命令# 进入 backend 目录 cd backend # 构建镜像。-t 是 tag给镜像起个名字 docker build -t classification_model_serving . # 运行容器。-p 将宿主机 8000 端口映射到容器 80 端口 # --name 为容器指定一个易记的名字方便后续管理 # -d 表示后台运行detached mode docker run -d -p 8000:80 --name cls-serve classification_model_serving # 查看容器日志确认服务已启动 docker logs -f cls-serve # 你应该看到类似 Uvicorn running on http://0.0.0.0:80 的日志关键参数解释-w 4启动 4 个 gunicorn worker 进程充分利用多核 CPU。-k uvicorn.workers.UvicornWorker指定使用 Uvicorn 的 worker而非默认的 sync worker以获得异步性能。--timeout 120设置 worker 超时时间为 120 秒防止大图推理时被误杀。4.3 前端 Dockerfile 与跨容器通信实现前端Dockerfile的核心挑战是如何让容器内的 Gradio 应用能够稳定地访问到宿主机上运行的后端 API。以下是经过生产验证的写法FROM python:3.9-slim # 创建非 root 用户 RUN useradd -m -u 1002 -G root -d /home/appuser appuser USER appuser WORKDIR /home/appuser/app # 复制依赖 COPY --chownappuser:root ./requirements.txt . # 安装依赖 RUN pip install --no-cache-dir -r requirements.txt # 复制源码和示例图片 COPY --chownappuser:root . . # 暴露 Gradio 默认端口 EXPOSE 7860 # 启动命令。Gradio 默认监听 0.0.0.0:7860 CMD [python, -u, main.py]main.py前端中的关键通信逻辑import os import requests from gradio import Interface, Image, Label # 从环境变量读取后端地址实现配置与代码分离 # 在 docker run 时通过 -e 参数注入 BACKEND_URL os.getenv(BACKEND_URL, http://host.docker.internal:8000) def inference(image_path): try: # 1. 读取图片为二进制 with open(image_path, rb) as f: image_bytes f.read() # 2. 构造 multipart/form-data 请求 # 注意key 必须是 image与后端 app.post 的参数名一致 files {image: image_bytes} # 3. 发起 POST 请求设置超时避免前端卡死 response requests.post( f{BACKEND_URL}/api/predict, filesfiles, timeout30 # 30秒超时 ) response.raise_for_status() # 检查 HTTP 状态码 # 4. 解析 JSON 响应 result response.json() if result.get(success): # 格式化为 Gradio Label 组件期望的字典格式 # key 是 label 名value 是 probability return {pred[label]: pred[probability] for pred in result[predictions]} else: return {Error: Backend prediction failed} except requests.exceptions.Timeout: return {Error: Request timeout. Please check backend status.} except requests.exceptions.ConnectionError: return {Error: Cannot connect to backend. Is it running?} except Exception as e: return {Error: fUnexpected error: {str(e)}} # Gradio 界面定义 iface Interface( fninference, inputsImage(typefilepath), # typefilepath 让 Gradio 保存上传的文件到临时路径 outputsLabel(num_top_classes5), # 只显示 top5 examples[test1.jpeg, test2.jpeg], titleImage Recognition Demo, descriptionUpload an image to classify objects., allow_flaggingnever # 关闭 flagging 功能简化部署 ) # 启动时绑定到 0.0.0.0允许外部访问 iface.launch(server_name0.0.0.0, server_port7860)启动前端容器的完整命令# 构建前端镜像 cd ../frontend docker build -t frontend_serving . # 运行前端容器并注入后端地址 # --add-host host.docker.internal:host-gateway 是关键让容器能解析这个域名 # -e BACKEND_URLhttp://host.docker.internal:8000 将地址注入环境变量 docker run -d \ -p 7860:7860 \ --add-host host.docker.internal:host-gateway \ -e BACKEND_URLhttp://host.docker.internal:8000 \ --name frnt-serve \ frontend_serving # 查看前端日志 docker logs -f frnt-serve # 你应该看到 Running on public URL: http://172.17.0.2:7860 这样的日志4.4 使用 docker-compose.yml 实现一键启停推荐生产方式虽然docker run命令很直观但对于多容器应用docker-compose.yml是更优雅、更可靠的方案。它将所有配置声明化一份文件即可描述整个应用栈。创建docker-compose.yml文件version: 3.8 services: # 后端服务 backend: build: context: ./backend dockerfile: Dockerfile ports: - 8000:80 restart: unless-stopped # 健康检查确保服务真正可用 healthcheck: test: [CMD, curl, -f, http://localhost:80/health] interval: 30s timeout: 10s retries: 3 start_period: 40s # 前端服务 frontend: build: context: ./frontend dockerfile: Dockerfile ports: - 7860:7860 environment: - BACKEND_URLhttp://backend:80 restart: unless-stopped # 依赖后端确保 backend 启动后再启动 frontend depends_on: backend: condition: service_healthy # 健康检查 healthcheck: test: [CMD, curl, -f, http://localhost:7860/health] interval: 30s timeout: 10s retries: 3 start_period: 40s一键启停命令# 在项目根目录下执行 # 启动所有服务后台运行 docker-compose up -d # 查看所有服务状态 docker-compose ps # 查看日志按 CtrlC 退出 docker-compose logs -f # 停止所有服务 docker-compose down # 停止并删除所有数据谨慎 docker-compose down -vdocker-compose的强大之处在于它自动创建了一个名为imagercgwaiter_default的专用网络。在这个网络内backend和frontend两个服务名可以直接通过 DNS 解析为对方的 IP 地址无需任何--add-host或host.docker.internal的 hack。这才是 Docker 网络的正确打开方式。5. 常见问题与排查技巧实录那些让你抓耳挠腮的“灵异事件”5.1 问题速查表高频故障与精准定位问题现象可能原因排查命令/步骤解决方案前端页面白屏控制台报错Failed to fetch1. 后端容器未运行2. 前端容器无法解析backend域名3. 后端 API 路径错误如/api/predict写成/predictdocker ps看后端容器状态docker exec -it frnt-serve ping backendcurl http://localhost:8000/docs确保docker-compose up -d成功使用docker-compose替代docker run检查main.py中app.post的路径后端日志报错OSError: [Errno 12] Cannot allocate memory模型加载时内存不足尤其在无 GPU 的低配机器上docker stats cls-serve查看内存使用free -h查看宿主机内存在model.py中将model torch.hub.load(...)改为model torch.hub.load(..., map_locationcpu)强制使用 CPUGradio 界面上传图片后一直显示Processing...无响应1.inference.py中requests.post超时2. 后端uvicorn的--timeout设置过短docker logs frnt-serve查看前端错误docker logs cls-serve查看后端错误在inference.py的requests.post中增加timeout60在后端CMD中增加--timeout 120docker build报错ERROR: Could not find a version that satisfies the requirement torch1.10.0python:3.6-slim镜像中pip版本过旧不支持manylinux2014轮子docker run -it python:3.6-slim pip --version在Dockerfile的pip install前加入RUN pip install --upgrade pip5.2 独家避坑技巧来自血泪教训的 3 条军规军规一永远不要在Dockerfile中RUN pip install torchPyTorch 的 wheel 文件巨大 1GB且其官方 PyPI 仓库不提供manylinux2014兼容的轮子。在python:3.6-slim这种精简镜像中pip install torch很可能触发源码编译这需要gcc、cmake等大量构建工具而slim镜像里根本没有。结果就是构建过程卡死在Building wheel for torch几个小时不动。正确姿势在Dockerfile中使用pip install的--find-links和--no-deps参数直接从 PyTorch 官方 CDN 下载预编译轮子# 在 requirements.txt 中不写 torch而是写 # --find-links https://download.pytorch.org/whl/torch_stable.html # --no-deps # torch1.10.0cpu # torchvision0.11.1cpu # 在 Dockerfile 中RUN pip install -r requirements.txt军规二imagenet_classes.txt的编码必须是 UTF-8 无 BOMWindows 记事本默认保存的.txt文件带有 BOMByte Order Mark即文件开头的EF BB BF三个字节。当 Python 的open()函数读取这个文件时categories