深夜喂完奶后,我用 JavaScript 给运营团队搞了个“图像识别外挂”
凌晨 2:17,小宝终于睡熟了。我把奶瓶轻轻放在灶台上,蹑手蹑脚回到书房,打开那台贴满卡通贴纸的 MacBook——没错,这就是我的“深夜第二战场”。白天是全职妈妈,晚上是码农,最近刚入职新公司两个月,正在适应“带娃+上班”双线程运行的硬核生活。
上周五下班前,运营组的老张突然在钉钉上@我:“姐,能不能帮忙搞个能自动识别商品主图里有没有‘真人模特’的功能?我们双11素材审核快爆了。” 我一口咖啡差点喷出来——你管这叫“能不能”?这明明是个正经的计算机视觉任务啊!
但转念一想,我司做的是 C2M 电商平台,每天上传的商家图片成千上万,人工审核确实不现实。而且我本来就对底层原理有点执念(被前司架构师毒打出来的),既然都踩进来了,不如干脆撸起袖子干一票大的。
于是,就有了这个用 JavaScript 实现的商品图像智能分类项目。今天这篇水文,就是记录一下我这个“深夜带娃码农”是怎么在奶香和代码香之间,硬生生把 CV 项目跑通的全过程——包括那些让我想砸电脑的坑,和最后看到准确率飙升时嘴角疯狂上扬的瞬间。
起因:运营的“小需求”,程序员的“大工程”
先说清楚背景。我们平台允许商家上传商品图,但平台规范要求:服饰类商品主图必须包含真人模特试穿效果,不能只有平铺图或白底图。以前靠人工抽检,漏检率高不说,运营小姐姐们眼睛都快看瞎了。
老张原本以为加个“判断图里有没有人”的开关就行,殊不知——
“有没有人” 和 “有没有穿这件衣服的人” 是两码事!
比如这张图:
- 有真人,但穿的是牛仔裤,商品却是 T 恤 → 不合格
- 有真人,但只是手拿着衣服没穿 → 不合格
- 有真人,全身穿着商品同款 → 合格
所以问题本质不是通用人体检测,而是 “商品-人物关联性判断”。更麻烦的是,我们前端技术栈以 React + Node.js 为主,后端微服务也重度依赖 JavaScript 生态。老板明确说了:“能用 JS 解决的,别给我引入 Python 服务,运维会骂人的。”
得,那就用 JavaScript 干 CV。
技术选型:在 TensorFlow.js 和 ONNX 之间反复横跳
一开始我天真地以为直接用 TensorFlow.js 加载个预训练模型就行。毕竟官网示例里 mobilenet 几行代码就能分类 ImageNet,多爽。
import * as tf from '@tensorflow/tfjs';
import * as mobilenet from '@tensorflow-models/mobilenet';
const model = await mobilenet.load();
const predictions = await model.classify(imageElement);
结果一跑测试集就傻眼了:
- 模型说“有人”,但其实是衣架上的假人模特
- 模型说“无人”,但其实角落里有个穿同款的小朋友(像素太小)
- 最离谱的是,一张纯白底图被识别成“浴袍”(因为训练集里很多浴袍是白的……)
显然,通用图像分类模型根本不理解我们的业务语义。得重新训练。
数据从哪来?
我厚着脸皮找数据团队要了过去半年被人工标记为“合格/不合格”的主图,大概 8000 张。但问题来了:
- 合格样本只有 3000 张(商家懒,很多人传平铺图)
- 不合格样本里混杂了各种情况:无图、logo 图、场景图……
我花了两个晚上(趁娃睡了)手动清洗数据,用 LabelImg 标出“人物区域”和“商品区域”,最后整理出一个干净的二分类数据集:合格 vs 不合格。
提醒自己:下次一定要推动建立标准标注流程,不然数据脏得像我家地板(别问,问就是娃刚打翻了米糊)。
模型选择:轻量 vs 精准
考虑到要部署到前端(部分场景需实时反馈),模型不能太大。我对比了几种方案:
| 方案 | 框架 | 模型大小 | 推理速度 (CPU) | 准确率 (验证集) | 是否支持浏览器 |
|---|---|---|---|---|---|
| MobileNetV2 | TF.js | ~14MB | 120ms | 82.3% | ✅ |
| EfficientNet-Lite0 | TF.js | ~21MB | 180ms | 86.7% | ✅ |
| YOLOv5s (ONNX) | ONNX Runtime Web | ~28MB | 350ms | 91.2% | ✅(需转 ONNX) |
| 自定义 CNN | TF.js | ~8MB | 90ms | 79.5% | ✅ |
看起来 YOLO 最准,但它输出的是目标检测框,我还需要额外写逻辑判断“人物是否穿着目标商品”——这又回到了最初的问题。
最终我决定:用 EfficientNet-Lite0 做端到端二分类。理由很现实:
- 运营不需要知道“为什么不合格”,只需要“合不合格”
- 训练简单,调参少
- 能直接集成到现有 React 组件里
训练过程:从 loss 不降 到 准确率起飞
我在本地用 Python + Keras 先训了个原型(别打我,我只是借个环境),数据增强用了:
- 随机裁剪(模拟不同构图)
- 颜色抖动(应对不同光线)
- 水平翻转(衣服左右对称嘛)
训练脚本核心就这几行:
model = tf.keras.applications.EfficientNetLite0(
include_top=True,
weights=None,
input_shape=(224, 224, 3),
classes=2
)
model.compile(
optimizer='adam',
loss='categorical_crossentropy',
metrics=['accuracy']
)
history = model.fit(
train_ds,
validation_data=val_ds,
epochs=30,
callbacks=[EarlyStopping(patience=5)]
)
但第一次跑完,验证准确率卡在 75% 不动。我盯着 loss 曲线看了半小时,突然意识到——类别不平衡!不合格样本是合格的两倍多。
赶紧加上 class_weight:
from sklearn.utils.class_weight import compute_class_weight
class_weights = compute_class_weight(
'balanced',
classes=np.unique(train_labels),
y=train_labels
)
class_weight_dict = dict(enumerate(class_weights))
再跑,准确率直接冲到 88%。那一刻我差点在书房跳起来(然后想起隔壁睡着娃,默默坐下)。
转换与部署:让模型跑在浏览器里
训练完的 .h5 模型不能直接给前端用,得转成 TensorFlow.js 格式:
tensorflowjs_converter \
--input_format=keras \
./model.h5 \
./tfjs_model
生成一堆 .bin 和 model.json 文件,丢到 CDN 上。
前端调用代码也很简单:
// hooks/useImageClassifier.js
import * as tf from '@tensorflow/tfjs';
let model;
export const loadModel = async () => {
if (!model) {
model = await tf.loadLayersModel('/models/tfjs_model/model.json');
}
return model;
};
export const classifyImage = async (img) => {
const m = await loadModel();
const tensor = tf.browser.fromPixels(img)
.resizeNearestNeighbor([224, 224])
.toFloat()
.div(tf.scalar(255.0))
.expandDims();
const predictions = await m.predict(tensor).data();
tf.dispose(tensor); // 别忘了内存管理!
return {
isQualified: predictions[1] > 0.7, // 阈值可调
confidence: predictions[1]
};
};
重点来了:
- 必须
tf.dispose(),否则内存泄漏,浏览器卡死(亲身踩坑) - 图片预处理尺寸必须和训练时一致(224x224)
- 置信度阈值别设 0.5!我调到 0.7 才平衡了误杀率和漏杀率
上线后的“惊喜”:运营说“好像有点不准?”
模型上线第一天,运营反馈:“有些明显合格的图被打了回票!” 我心里一紧,赶紧查日志。
发现罪魁祸首是——图片加载时机!
前端在 <img> 的 onLoad 回调里调用分类,但如果图片还没完全渲染(比如网络慢),tf.browser.fromPixels 就会读到空白 canvas。
解决方案:加个 requestAnimationFrame 确保渲染完成:
const classifyWhenReady = (img) => {
return new Promise(resolve => {
const check = () => {
if (img.complete && img.naturalHeight !== 0) {
resolve(classifyImage(img));
} else {
requestAnimationFrame(check);
}
};
check();
});
};
另外,还有一次线上事故:某商家上传了一张 超大分辨率图(8000x6000),直接导致低端手机浏览器崩溃。现在前端会先压缩图片再送入模型:
const resizeImage = (file, maxWidth = 1024) => {
return new Promise((resolve) => {
const img = new Image();
img.src = URL.createObjectURL(file);
img.onload = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
let { width, height } = img;
if (width > maxWidth) {
height = (height * maxWidth) / width;
width = maxWidth;
}
canvas.width = width;
canvas.height = height;
ctx.drawImage(img, 0, 0, width, height);
resolve(canvas);
};
});
};
效果如何?运营终于不用加班了
上线两周后,我拉了份数据:
| 指标 | 上线前 | 上线后 |
|---|---|---|
| 日均人工审核量 | 5200 张 | 800 张 |
| 审核平均耗时 | 3.2 秒/张 | 0.4 秒/张 |
| 模型准确率 | - | 89.6% |
| 误杀率(合格被判不合格) | - | 6.2% |
| 漏杀率(不合格被判合格) | - | 4.1% |
老张昨天请我喝了杯奶茶(无糖,带娃后戒糖了),说:“现在我们只复核模型打低分的图,效率翻倍!”
最让我开心的是,这个模型现在不仅用于审核,还被产品拿去做 “商家素材质量评分”,甚至反哺到推荐系统——高质量主图的商品更容易被曝光。技术真的能驱动业务,这话不假。
写在最后:当妈后,我更懂“鲁棒性”了
说实话,做这个项目最大的挑战不是算法,而是在碎片时间里保持专注。有时候刚调好一行代码,娃就哭了;有时候半夜想到 loss 不降的原因,爬起来改配置又怕吵醒家人。
但正是这种“被打断”的日常,让我对系统的鲁棒性有了更深的理解——
就像带娃,你永远不知道下一秒会发生什么,但你得确保整个系统(家庭+工作)还能稳稳运行。
这次用 JavaScript 做 CV,虽然绕了些路,但也证明了:JS 生态早已不是当年那个“玩具语言”了。TensorFlow.js、ONNX Runtime Web、WebGL 加速……前端工程师也能玩转 AI。
如果你也在带娃写代码,或者被运营提了“小需求”,别慌。深呼吸,泡杯咖啡(或奶粉),一行一行 debug。毕竟——
我们既能哄睡人类幼崽,也能驯服机器学习模型,还有什么搞不定的?
(完)
P.S. 下次打算试试用 MediaPipe 做实时姿态估计,看看能不能判断模特是不是“自然展示商品”……不过得等小宝睡整觉再说 😴

评论 0