我在北京通勤一小时后,靠Go和OpenCode搞定图像分类项目

深度学习小白
2026-03-12 02:28
阅读 741

上周五晚上十一点半,地铁末班车刚到家,泡了杯速溶咖啡,我打开电脑准备继续肝那个视觉项目。说实话,入职这家公司才一个半月,试用期还没过,leader就丢给我个“小任务”:给内部文档系统加个智能封面识别功能——用户上传PDF时自动提取第一页图片,并判断是技术白皮书、产品手册还是会议纪要。听起来简单?呵,等你真上手就知道什么叫“产品经理一句话,程序员跑断腿”。

我是那种典型的夜猫子程序员,白天被各种站会、评审、联调折磨得不行,只有深夜才能静下心写代码。坐标北京回龙观,每天通勤来回两小时,地铁上刷GitHub Issues都成了日常。好在之前在上家公司搞过云原生那一套,对K8s还算熟,不然这次部署环节怕是要哭出来。

为什么不用Python?因为Go才是我们团队的信仰

坦白讲,接到这个需求的第一反应是:“这不就是个CV入门题吗?ResNet50微调一下,PyTorch跑起来,三天搞定。”但现实狠狠打了我一巴掌——我们团队的技术栈清一色Go,连数据服务层都是用Go写的gRPC接口。更离谱的是,运维那边明确说了:“别想带Python镜像进生产环境,除非你能说服SRE团队重构整个CI/CD流水线。”

行吧,那就Go干。其实也不是不能理解。公司去年全面拥抱云原生,所有服务都要跑在K8s上,而Go的编译产物小、启动快、内存占用低,配合Docker简直是绝配。而且我们用的OpenCode平台(公司自研的内部开发协作与部署一体化平台)对Go的支持特别友好,一键构建、自动压测、灰度发布全链路打通。

于是,我开始调研Go生态下的计算机视觉方案。主流选择其实不多:

  • Gorgonia:类似Go版的TensorFlow,但社区冷清,文档稀烂
  • GoCV:基于OpenCV的Go绑定,功能全但依赖C++库,镜像体积爆炸
  • ONNX Runtime Go API:可以加载导出的ONNX模型,轻量且跨平台

最后我选了第三条路:先用PyTorch训练模型,导出为ONNX格式,再用Go加载推理。这样既能享受Python生态的训练便利,又能满足生产环境的部署约束。算是“曲线救国”了。

数据准备:从混乱到有序的痛苦过程

我们的训练数据来自历史文档库,大概有1.2万份PDF。理论上每份都能抽第一页当样本,但实际上……坑太多了。

  • 有些PDF第一页是纯文字,根本没图
  • 有些扫描件分辨率低到看不清logo
  • 更气人的是,产品经理给的标签居然有“其他”这种类别,占比高达30%!

我当时真的想砸键盘。但转念一想,试用期员工哪有资格抱怨?只能硬着头皮清洗数据。我写了个Python脚本(别问,本地跑的),用pdf2image把PDF转成图片,再用OpenCV做基础过滤:

# 本地预处理脚本片段(非生产代码!)
import cv2
from pdf2image import convert_from_path

def is_valid_cover(img_path):
    img = cv2.imread(img_path)
    if img is None:
        return False
    # 过滤纯文本页面:通过边缘检测判断是否含足够图形元素
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    edges = cv2.Canny(gray, 50, 150)
    edge_ratio = np.sum(edges > 0) / (img.shape[0] * img.shape[1])
    return edge_ratio > 0.02  # 至少2%像素是边缘

最终筛出约8500张有效图片,三类分布还算均衡:

类别 样本数 占比
技术白皮书 3200 37.6%
产品手册 2900 34.1%
会议纪要 2400 28.3%

顺便吐槽一句:产品经理后来承认,“其他”标签其实是他们实习生乱标的结果……这锅甩得真是清新脱俗。

模型训练:Claude成了我的深夜陪练

训练阶段我本来打算自己搭CNN,但时间紧任务重(deadline就在下周demo日),果断放弃造轮子。直接拿MobileNetV2做迁移学习——轻量、准确率够用、适合移动端部署(虽然我们跑在服务器上,但谁让资源紧张呢)。

这里必须提一嘴Claude。不是那个AI助手吗?对,就是它!我们公司买了企业版,集成进了OpenCode的IDE插件里。说实话,一开始我觉得这玩意儿就是个噱头,直到某天凌晨两点卡在一个数据增强参数上:

我:“Claude,为什么我的验证集准确率一直卡在82%上不去?”

Claude:“你试试在Normalize之前加RandomErasing,或者检查下学习率调度策略是否过早衰减。”

结果真管用!加了RandomErasing后,准确率直接冲到89.3%。那一刻我差点给Claude磕一个。虽然它偶尔也会胡说八道(比如建议我用SVM做图像分类),但在调参这种经验性很强的任务上,确实能当个不错的“第二大脑”。

最终训练配置如下(PyTorch + Lightning):

# train.py 关键参数
model = timm.create_model('mobilenetv2_100', pretrained=True, num_classes=3)
train_transform = transforms.Compose([
    transforms.Resize((224, 224)),
    transforms.RandomHorizontalFlip(),
    transforms.ColorJitter(brightness=0.2),
    transforms.RandomErasing(p=0.3),  # ← 就是这行救命的!
    transforms.ToTensor(),
    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
])

训练完导出ONNX:

torch.onnx.export(
    model,
    dummy_input,
    "cover_classifier.onnx",
    input_names=["input"],
    output_names=["output"],
    dynamic_axes={"input": {0: "batch_size"}},  # 支持动态batch
    opset_version=13
)

Go推理服务:在K8s上跑起来

模型有了,接下来就是Go部分。核心逻辑其实很简单:接收图片,预处理,调用ONNX Runtime,返回类别。

先安装依赖(注意:必须用CGO_ENABLED=1,因为ONNX Runtime底层是C++):

// go.mod
require (
    github.com/microsoft/onnxruntime-go v0.10.0
    gocv.io/x/gocv v0.32.0 // 只用于预处理,不用它做推理!
)

关键推理代码:

package main

import (
    "image"
    ort "github.com/microsoft/onnxruntime-go"
    "gocv.io/x/gocv"
)

type Classifier struct {
    session *ort.AllocatedSession
}

func NewClassifier(modelPath string) (*Classifier, error) {
    so := ort.NewSessionOptions()
    session, err := ort.NewSession(modelPath, so)
    if err != nil {
        return nil, err
    }
    return &Classifier{session: session}, nil
}

// Preprocess 将image.Image转为模型输入张量
func (c *Classifier) Preprocess(img image.Image) ([]float32, error) {
    // 用GoCV做resize和归一化(避免重复造轮子)
    mat := gocv.NewMatFromBytes( /* ... */ )
    defer mat.Close()
    
    resized := gocv.NewMat()
    defer resized.Close()
    gocv.Resize(mat, &resized, image.Pt(224, 224), 0, 0, gocv.InterpolationDefault)
    
    // 转为float32并归一化(模仿PyTorch的Normalize)
    normalized := make([]float32, 224*224*3)
    // ... 实现略,其实就是 (pixel/255 - mean)/std
    return normalized, nil
}

func (c *Classifier) Predict(img image.Image) (string, error) {
    input, _ := c.Preprocess(img)
    output, err := c.session.Run([]ort.Input{
        {Name: "input", Value: input},
    })
    if err != nil {
        return "", err
    }
    logits := output[0].Value().([]float32)
    classIdx := argmax(logits)
    labels := []string{"technical", "product", "meeting"}
    return labels[classIdx], nil
}

部署到K8s就更简单了。得益于OpenCode平台,我只需要写个Dockerfile:

FROM golang:1.22-alpine AS builder
RUN apk add --no-cache g++ musl-dev
WORKDIR /app
COPY . .
RUN CGO_ENABLED=1 GOOS=linux go build -o classifier .

FROM alpine:latest
RUN apk --no-cache add ca-certificates
COPY --from=builder /app/classifier /classifier
COPY model/cover_classifier.onnx /model/
EXPOSE 8080
CMD ["/classifier"]

然后在OpenCode Web界面上点几下:“创建服务 → 关联Git分支 → 设置资源限制(1核2G)→ 部署”。五分钟不到,服务就跑在测试集群上了。对比以前手动写YAML的日子,简直像从石器时代穿越到现代。

踩坑实录:那些让我凌晨三点还在debug的瞬间

当然,过程不可能一帆风顺。分享几个血泪教训:

坑1:ONNX模型在Go里输出维度不对

现象:PyTorch输出shape是[1,3],ONNX Runtime返回却是[3]。查了半天才发现,导出时没固定batch size,而Go里传入的input是[3,224,224]而不是[1,3,224,224]。

解决:在Preprocess里显式加batch维度:

// 输入张量 shape: [batch, channel, height, width]
inputTensor := make([]float32, 1*3*224*224)
copy(inputTensor, normalized) // normalized是[3*224*224]

坑2:GoCV在Alpine镜像里崩溃

GoCV依赖OpenCV的C++库,而Alpine用musl libc,和标准glibc不兼容。折腾半天发现,要么换Ubuntu基础镜像(镜像体积从30MB暴涨到300MB),要么用静态编译。

最终方案:在builder阶段用Ubuntu,运行时只拷贝二进制和必要的.so文件。虽然麻烦,但镜像控制在80MB以内,SRE团队勉强点头了。

坑3:K8s readiness probe失败

服务启动后,K8s一直报“Readiness probe failed”。原因是模型加载需要2秒,而默认probe延迟只有1秒。

解决:在deployment.yaml里调整:

readinessProbe:
  initialDelaySeconds: 5  # ← 关键!给模型加载留足时间
  periodSeconds: 10

效果与反思:试用期员工的自救指南

上线一周后,准确率统计如下(基于人工抽检500份):

类别 准确率 主要错误类型
技术白皮书 92.1% 和产品手册混淆(都有logo)
产品手册 88.7% 和白皮书混淆
会议纪要 94.3% 极少出错(通常无logo)

整体89.5%,勉强达到产品要求的“不低于85%”。更重要的是,响应时间P99 < 300ms,在1核2G的Pod上稳定运行。

现在回头看,这个项目虽然不大,但完整走通了“数据→训练→部署→监控”的闭环。对我这个试用期新人来说,价值远不止完成任务那么简单:

  • 学会了在约束中创新:不能用Python?那就用ONNX桥接。
  • 理解了工程权衡:准确率95% vs 镜像体积300MB,选哪个?当然是后者。
  • 体验了DevOps一体化:OpenCode平台真的香,省下大量沟通成本。

至于Claude?它现在成了我深夜coding的固定搭档。虽然有时候会一本正经地胡说八道,但关键时刻总能给出思路。比如昨天它还提醒我:“你有没有考虑过用Vision Transformer?不过以你们的数据量,可能过拟合……”

好吧,或许下次项目真该试试ViT。但现在,我得赶紧睡了——明天还要早起挤地铁,毕竟试用期不能迟到啊!

评论 0

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝