
你是不是也遇到过这种情况看到一个AI图像分类的Demo觉得挺酷但一想到要配Python环境、装CUDA、跑训练脚本瞬间就泄气了。实话讲我第一次折腾深度学习环境的时候光是版本兼容问题就耗了一整个下午。后来接触到TensorFlow.js算是彻底改变了我的做法——你完全不需要离开浏览器就能跑起一个真实的图像分类AI程序。这里所说的第一个AI程序就是用TensorFlow.js在浏览器端实现图像分类。核心流程是浏览器加载一个预训练好的图像分类模型比如MobileNet把用户上传的图片或摄像头画面转换成的张量输入模型模型在前端完成推理直接输出这是猫这是狗这是一台路由器这样的分类结果。整个过程不需要后端服务器参与数据不出浏览器对于图像分类这类任务体验非常丝滑。你可能会问这跟传统的Python方案比到底有什么区别简单来说TensorFlow.js把深度学习能力搬到了浏览器里通过WebGL和WebAssembly让模型可以在GPU上运行。这意味着你可以用它快速做原型验证、开发插件、写交互式教程甚至部署轻量级应用。对于还没踏过AI门槛的新手这也是接触图像分类算法、理解模型推理流程最低成本的一条路。这篇文章我只聊一件事如何从零写好这第一个AI程序。我会把我实际踩过的坑、改过的代码、以及让程序更顺手的优化思路都写出来希望能帮你少走点弯路。1. 为什么第一个AI程序要选浏览器端1.1 零安装起步绕过环境地狱过去我们在电脑上跑AI第一关就是环境配置。Python版本、TensorFlow还是PyTorch、CUDA能不能识别GPU、conda虚拟环境是否干净——任何一个环节出问题都可能耗掉大量时间。很多初学者还没看到识别成功的画面就已经在安装各种依赖时放弃掉了。TensorFlow.js直接把这一环节砍掉了。你只需要一个现代浏览器然后引入一个JavaScript脚本模型在浏览器里加载推理在浏览器里执行。没有Python解释器没有pip install没有GPU驱动没有环境变量的纠结。对新手来说第一个AI程序的全部意义就在于看到效果、理解流程、建立信心。把这套心智负担降到最低比什么都重要。而且这种体验带来的正反馈非常直接。我见过不少朋友按网上教程跑了三天环境终于在命令行里看到loss下降但他对AI到底是怎么工作的仍然没有直观概念。但如果你在浏览器里拖一张图片进去一两秒就弹出这是柯基犬置信度86%你会立刻理解分类这个词的含义。1.2 浏览器端推理到底是怎么运作的这里要稍微解释一下TensorFlow.js的底层逻辑。它之所以能在浏览器里做深度学习推理主要靠两套后端WebGL后端将张量数据传到GPU通过Shader实现卷积、全连接等矩阵运算。这是目前速度最快的主流后端在多数桌面浏览器上表现都很不错。WebAssembly后端在CPU上执行运算跨平台兼容性更好适合没有GPU或者WebGL受限的设备。你不需要直接跟这两个后端打交道TensorFlow.js会自动检测环境并选择可用的最佳后端。但有一点需要记住你写的代码是在JavaScript语言层面底层运算已经交给GPU了。所以最终代码写起来并不复杂——冲着一个Tensor进去最后一个带概率分布的Tensor出来。那图像分类在这条流程里具体做了什么简单讲就是模型把一张图片抽象成一组特征然后根据这些特征判断它属于预定义类别中的哪一类。MobileNet这类模型已经在大规模数据集如ImageNet包含1000个常见物体类别上训练好了我们直接复用它的权重对自己本地的图像做推理完全不需要重新训练。1.3 为什么是MobileNet而不是更大的模型选择MobileNet是因为它是浏览器端图像分类应用中最常用、最稳妥的起点。理由很直白体积适中完整的MobileNet权重文件大约几十MB放在网页场景里虽然不算小但通过合理的静态资源托管可以接受而且模型加载后浏览器会缓存。速度更快它的设计目标就是移动端和嵌入式设备采用深度可分离卷积参数量和计算量远小于VGG16、ResNet这类大模型。精度够用针对ImageNet的1000类分类任务表现已经不错而且它输出的类别范围涵盖了猫、狗、汽车、桌子、食物等大量日常生活场景作为入门示例恰到好处。如果你的项目需要更极致的速度还可以用MobileNet的量化版本如果你想追求更高精度也可以切换到其他预训练模型后续我单独讲。但第一版程序我建议你坚持用MobileNet跑通全流程。跑通本身比选型更重要。2. 项目搭建从HTML到TensorFlow.js引入2.1 准备一个最精简的项目结构这里我采用最朴素的方案一个HTML文件、一个JavaScript文件、一个放用户图片的目录外加可选的一个本地静态服务器。你不需要Vue、React、Webpack等任何构建工具因为这会引入新的复杂度违背第一个AI程序的初衷。项目结构可以长这样image-classifier/ ├── index.html ├── app.js └── images/ ├── cat.jpg └── dog.jpg看起来很简单但这里其实有一个很容易被忽视的坑如果你直接双击打开index.html用file://协议访问页面大概率会在加载模型时遇到跨域问题因为TensorFlow.js加载远程或本地的权重文件时涉及跨域请求。最好的做法是起一个本地静态服务器。你可以用Python自带的模块一条命令就行python3 -m http.server 8080然后在浏览器访问http://localhost:8080。如果你不想装Python也可以用VS Code的Live Server插件或者随便一个能起静态服务的工具。不要省这一步它会帮你避开很多莫名其妙的问题。2.2 通过CDN引入TensorFlow.jsTensorFlow.js的引入方式我建议在第一版程序中优先用CDN。原因很简单不用关心npm和打包配置页面加载即引入完毕。具体做法是在HTML的head里加入脚本script srchttps://cdn.jsdelivr.net/npm/tensorflow/tfjs4.20.0/dist/tf.min.js/script注意这里我锁定了版本号。在实际开发中除非你有明确理由否则我建议把版本号固定下来。因为TensorFlow.js的API有过调整比如某些函数被重命名或废弃。今天可以跑通的代码几个月后可能因为包版本升级而GitHub issue满天飞。你做完项目后可以再升版本但第一版跑通锁定版本是性价比最高的选择。当然如果是正式的、需要长期维护的应用我建议用npm方式引入并配好构建工具。但那是后话第一个程序不必上这么重。2.3 页面结构与上传控件的设计我们的目标是通过页面让用户上传一张图片点击按钮后程序自动完成分类。所以HTML里需要一个文件选择控件、一个预览区域、一个按钮以及一个展示结果的区域。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title浏览器端图像分类/title script srchttps://cdn.jsdelivr.net/npm/tensorflow/tfjs4.20.0/dist/tf.min.js/script style body { font-family: system-ui, -apple-system, sans-serif; max-width: 800px; margin: 0 auto; padding: 2rem; } .preview-box { margin: 1rem 0; border: 2px dashed #aaa; padding: 1rem; text-align: center; min-height: 200px; } .preview-box img { max-width: 100%; max-height: 300px; } #result { margin-top: 1rem; padding: 1rem; background: #f5f5f5; border-radius: 8px; } /style /head body h1图片分类演示TensorFlow.js MobileNet/h1 input typefile idimageInput acceptimage/* div classpreview-box img idpreviewImg alt预览区 styledisplay:none; p请选择一张图片/p /div button idclassifyBtn disabled开始分类/button div idstatus/div div idresult/div script srcapp.js/script /body /html你可能注意到了页面里其实没有太多机关核心还是集中在app.js。记住UI越简单越有助于你把注意力放在推理逻辑上。等流程走通了样式怎么美化都是后话。3. 核心推理链路从像素到分类结果3.1 加载模型一步到位还是按需加载现在进入程序的核心部分。首先要在app.js里加载MobileNet模型。TensorFlow.js提供了非常简洁的APIlet model; async function loadModel() { const statusEl document.getElementById(status); statusEl.textContent 模型加载中请稍候...; model await tf.loadLayersModel( https://storage.googleapis.com/tfjs-models/tfjs/mobilenet_v1_0.25_224/model.json ); statusEl.textContent 模型加载完成可以开始分类了; document.getElementById(classifyBtn).disabled false; } loadModel();这个URL是官方托管的MobileNet v1模型输入尺寸是224x224宽度因子是0.25属于轻量配置。加载好的model对象就是我们用来推理的大脑。这里多说一句tf.loadLayersModel加载的是Layer模型格式包含model.json结构和对应的权重分片文件。如果你发现某些模型地址加载失败通常是.json路径不对或者权重分片缺失。我们直接用官方这个链接就是为了最大程度保证稳定性。另外还有一个细节值得注意如果你想在用户点击上传前就先把模型加载好最好在页面初始化时就调用loadModel()。这样用户真正点击分类按钮时模型已经在内存里了分类响应会很快。3.2 把DOM里的图片变成张量浏览器里的图片在DOM层面是一个img元素而TensorFlow.js需要的输入是一个3D或4D的Tensor。中间的转换靠一个非常方便的APIconst tfImg tf.browser.fromPixels(imageElement);这个函数会把HTMLImageElement直接解码成RGB格式的3D张量形状为[height, width, 3]三通道对应红绿蓝。它接收的对象可以是img、video、canvas。不理解的话可以类比成从Pixels这个函数在拍照这个HTML元素然后把照片数据以数字矩阵的形式存下来。但在喂给模型之前这个张量还不能直接用因为MobileNet的输入有硬性规定必须是224x224大小且像素值必须经过归一化。3.3 预处理尺寸缩放与归一化很多第一次写图像分类的开发者最容易翻车的地方就是这里。你直接把一张原始尺寸的图片塞给模型报错不说就算运气好跑通了分类结果也可能是乱的。MobileNet要求的输入是224x224的RGB图像像素值范围是[-1, 1]归一化公式一般是(pixel / 127.5) - 1。TensorFlow.js里面有专门的处理方法通过tf.image.resizeBilinear来缩放尺寸再用数学运算做归一化async function classifyImage(imageElement) { // 从图片元素转换为张量 let tensor tf.browser.fromPixels(imageElement); // 缩放到224x224这里用双线性插值保留更多图像特征 tensor tf.image.resizeBilinear(tensor, [224, 224]); // 增加batch维度从 [224,224,3] 变成 [1,224,224,3] tensor tensor.expandDims(0); // 归一化像素值到 [-1, 1] tensor tensor.div(127.5).sub(1); return tensor; }我在这里特别说明一下各步骤的原因resizeBilinear选择双线性插值而不是最近邻插值是因为在处理自然图片时双线性插值能保留更多平滑的过渡细节分类效果更好。如果你的图片是像素艺术或图标可以试试resizeNearestNeighbor效果可能更清晰。expandDims(0)模型训练时输入是批量的batch所以即使你只预测一张图片也要在第一个维度上补一个1表示这一批只有一张图。这是一个非常容易忘记的操作漏掉它你会看到类似Shape mismatch的报错。div(127.5).sub(1)把0-255的像素值转到-1到1的区间。注意有的模型归一化方式是(pixel / 255)而不是这种对称归一化具体要看你加载的模型的文档说明。MobileNet v1采用的是后一种。3.4 推理与结果解码预处理完成后调用模型进行推理就很简单了const prediction await model.predict(tensor);这行代码的执行过程简单来说就是Tensor在你的GPU上流动经过一系列卷积、池化、全连接层的运算最后输出一个形状为[1, 1000]的张量。这个1对应batch size1000对应ImageNet数据集的1000个类别。每个位置的值表示这张图片属于该类别的原始得分。但你肯定不想看这1000个数字你需要的是最可能的几个类别及置信度。所以要做Softmax归一化和Top-K提取。TensorFlow.js跟TensorFlow一样支持softmax函数const softmax tf.softmax(prediction); const topK await getTopK(softmax, 5);这里getTopK是我自己写的一个工具函数用来从概率分布中取出概率最高的前几项async function getTopK(logits, k) { // logits 的 shape 是 [1, 1000]转成1维数组 const values logits.dataSync(); const probs Array.from(values); const sorted probs .map((prob, index) ({ prob, index })) .sort((a, b) b.prob - a.prob) .slice(0, k); return sorted; }然后跟ImageNet类别名称做映射。官方模型里没有直接带类别标签你需要引入一个imagenetClasses.js文件网上有现成的映射表把类别索引转成可读的文本const className IMAGENET_CLASSES[item.index]; const probability item.prob.toFixed(2);到这一步识别出图片是什么的核心流程就完整了。把结果显示在页面DOM上document.getElementById(result).innerHTML h2分类结果/h2 ul listrong${className}/strong — 置信度 ${probability}/li /ul ;这里我还强烈建议你做一个细节优化每次推理结束后记得把不再使用的中间张量手动dispose()掉。TensorFlow.js不像Python端有自动垃圾回收的便利浏览器里的GPU显存是需要你主动释放的。如果不释放长时间使用或多次分类后页面会越来越卡最后直接报Context lost之类的错误。3.5 完整的app.js走一遍把上面这些片段拼起来app.js的完整结构大致如下let model; const imageInput document.getElementById(imageInput); const previewImg document.getElementById(previewImg); const classifyBtn document.getElementById(classifyBtn); const statusEl document.getElementById(status); const resultEl document.getElementById(result); async function loadModel() { statusEl.textContent 模型加载中请稍候...; model await tf.loadLayersModel( https://storage.googleapis.com/tfjs-models/tfjs/mobilenet_v1_0.25_224/model.json ); statusEl.textContent 模型加载完成; classifyBtn.disabled false; } imageInput.addEventListener(change, (event) { const file event.target.files[0]; if (!file) return; const url URL.createObjectURL(file); previewImg.src url; previewImg.style.display block; }); classifyBtn.addEventListener(click, async () { if (!model) return; resultEl.innerHTML 正在分类...; try { const tensor preprocessImage(previewImg); const prediction await model.predict(tensor); const softmax tf.softmax(prediction); const topK await getTopK(softmax, 3); // 清理张量 tensor.dispose(); prediction.dispose(); softmax.dispose(); displayResult(topK); } catch (error) { console.error(error); resultEl.innerHTML 分类出错了请查看控制台。; } }); function preprocessImage(imgElement) { let tensor tf.browser.fromPixels(imgElement); tensor tf.image.resizeBilinear(tensor, [224, 224]); tensor tensor.expandDims(0); return tensor.div(127.5).sub(1); } loadModel();注意我这里把preprocessImage单独抽出来了方便维护。你的代码可能更长些但核心结构大致就是这样。第一个能跑通、能出结果的程序其实没有你想象的那么复杂。4. 跑通之后我踩过的那些坑标题说的是第一个AI程序但我可以很负责任地告诉你第一次跑通的高兴不会持续太久——因为你马上会遇到各种看起来莫名其妙的问题。我把高频踩坑点放在这里你遇到时可以直接照方抓药。4.1 加载模型时的CORS问题这几乎是每个用TensorFlow.js的人都会碰到的第一道坎。我在本地测试时直接用file://协议打开页面然后控制台报错说加载model.json跨域被拒绝。报错信息大概是Access to fetch at ...model.json from origin null has been blocked by CORS policy原因很简单JSON和权重文件是通过fetch请求取得的fetch要求服务端返回Access-Control-Allow-Origin响应头。官方托管的地址没问题但你从file://协议访问时请求的origin是null很多服务器会拒绝。所以解决办法就是前面说的起一个本地静态服务器通过http://localhost访问页面。还有另一种情况你把自己的模型文件放在某个静态托管里但报的CORS错误说明托管平台没有开放跨域访问。比如某些对象存储服务默认对公开读不添加CORS头你需要到服务后台配置。这块内容在不同服务商那里各有差别等你做第二个、第三个程序时自然会接触第一版用官方CDN地址最省心。4.2 张量形状报错到底哪里对不上我第一次遇到的报错是Error: Error in conv2d: input must be rank 4, but got rank 3这个错误就是因为我把fromPixels得到的3D张量直接拿去预测忘了加batch维度。另一类常见报错是Shape mismatch通常是224x224缩放尺寸跟模型输入对不上。比如有的MobileNet版本输入是192而不是224那就得改成192。排查这类问题的方法很简单打印每个阶段张量的shape属性。在关键节点养成立即验证shape的习惯是调试深度学习代码最重要的基本功之一。比如console.log(tensor.shape); // 期望 [1,224,224,3]同时还要注意一个容易忽视的点HTML里的图片在onload完成之前fromPixels根本拿不到数据。如果你在图片还没加载完时就调用分类函数不仅shape会异常像素值也会是0填充的黑图分类结果自然完全没意义。所以聪明做法是在classifyBtn里先判断previewImg.complete或者等待img.onload后再执行分类逻辑。4.3 浏览器卡顿与显存泄漏这是一个不是报错但比报错更麻烦的问题。第一次跑通程序后你可能会来回测试几十张图片然后发现页面越来越卡最后一操作就崩溃。原因通常就是张量没有及时释放。TensorFlow.js里每个张量都占据GPU内存而你通过predict、softmax这些操作不断生成新张量。我不止一次见过这种帖子的代码每次预测新建一堆Tensor结束之后不清理。一开始没事20次后直接GG。我的习惯是在每个推理流程最后统一清理中间张量就如同前面代码所示。你也可以用tf.tidy来把所有中间运算包起来让TensorFlow.js自动管理依赖张量的内存async function classifyImage(imageElement) { const topK await tf.tidy(() { let tensor tf.browser.fromPixels(imageElement); tensor tf.image.resizeBilinear(tensor, [224, 224]); tensor tensor.expandDims(0); const prediction model.predict(tensor.div(127.5).sub(1)); const softmax tf.softmax(prediction); return getTopK(softmax, 3); }); return topK; }tf.tidy这个概念你可以理解为一个自动收垃圾的临时房间在这个函数内部创建并用到最后的上千个中间Tensor在函数结束时会自动被回收只有你return出去的张量保留。用上它之后内存泄漏问题大幅减少。注意tf.tidy里面不能有dataSync()这个同步操作否则会报错。如果你需要把数据转成JavaScript数组可以在tidy外使用dataSync()或arraySync()。4.4 加载慢模型文件太大怎么办MobileNet v1的权重文件加载在网速一般的情况下可能要十几秒。用户打开页面等十秒体验肯定不好。有几个优化方向使用更小尺寸的模型官方提供宽度因子0.25、0.5、0.75、1.0等多个版本0.25版本的精度略降但体积大幅缩小充分适合浏览器demo。模型预热与缓存首次加载成功后浏览器HTTP缓存会保存权重文件后续打开就不用再次下载。显示加载进度通过tf.io的进度回调把你臝loading的状态做成进度条让用户知道不是卡死了。第一个程序阶段我的建议是直接在页面顶部写清楚首次加载需要几秒然后给一个明确的模型加载中提示。能做到让用户知道等待是有意义的就已经合格了。5. 从图片到摄像头让AI看见真实世界当你跑通了图片分类下一步最自然的想法就是能不能让我在摄像头前面比个手势或者指着一件东西它就直接告诉我这是什么答案是当然可以。这也是实时推理、在线学习等浏览器端AI应用的基础。5.1 把视频帧接到推理管线里原理非常简单把video元素当作图像的来源。HTML里加一个video标签通过navigator.mediaDevices.getUserMedia打开摄像头然后在每个动画帧里抽取当前画面做分类。const video document.getElementById(camera); async function startCamera() { const stream await navigator.mediaDevices.getUserMedia({ video: true }); video.srcObject stream; await video.play(); }之后你可以用requestAnimationFrame创建一个循环function loop() { if (video.readyState video.HAVE_ENOUGH_DATA) { const result await classifyImage(video); // 复用之前的方法 displayResult(result); } requestAnimationFrame(loop); }这里有个关键细节tf.browser.fromPixels是可以直接接受video元素的而且不需要先把视频画到Canvas上TensorFlow.js内部会处理这个问题。但要注意视频镜头采集到的视频帧通常是720p或者1080p但模型的输入依然要resize到224x224对应到你的预处理函数里参数不用改。不过实时识别在性能上的压力比单张图片大得多。MobileNet v1 0.25版本在普通笔记本上跑到一二十帧每秒问题不大但如果你想更高帧率我们考虑用Web Worker把推理放到另一个线程避免阻塞UI。不过这个优化有代价编辑跨度变大、代码结构复杂。我的建议是先让摄像头能出结果再考虑用不用Worker。5.2 单次预测的防抖避免结果乱跳摄像头模式的另一个痛点在于每一帧都出一个结果置信度还一直在变用户看的时候会眼花缭乱。比如一个标签在狗和猫之间反复横跳观感极差。我实际用的方案是结果防抖维护一个连续N帧分类结果一致才展示的计数器。只有结果稳定下来才更新显示不然就显示上一次的结果。这是所有实时AI小应用都要过的体验关。代码逻辑不复杂let stableCount 0; let stableLabel ; function shouldDisplay(newLabel) { if (newLabel stableLabel) { stableCount; } else { stableCount 1; stableLabel newLabel; } return stableCount 5; // 连续5帧一致才通过 }不同应用可以调节这个N的值。动作识别类的N可以小一点如果你做一个物品分类展示N可以设到8-10减少抖动。5.3 结合其他模型从分类到多任务跑通MobileNet图像分类后你会很自然地问一个问题我能不能识别自己训练的特殊物体比如识别安全帽、识别特定包装、识别某种植物。严格来说通用图像分类模型没法直接满足需要迁移学习。TensorFlow.js也有专门的transfer learning方案可以让模型在线微调训练部分特定类别。这个方向做起来很有意思但已经不是第一个AI程序的范畴了。如果你还想继续深入我建议按这个路线来学会用tf.data和自定义张量处理数据流了解迁移学习的基本概念冻结层、微调、数据增强在Node.js里用TensorFlow或TensorFlow.js把数据预处理和训练跑通最后再把训练好的模型转成浏览器可加载的格式6. 进阶优化让浏览器AI更顺滑既然已经能跑了不妨把质量再往上抬一抬。下面这几个优化方向全部是基于我在各类浏览器设备上实测的经验值得你在第一版程序稳定后逐个尝试。6.1 使用Web Worker释放主线程运行TensorFlow.js推理时即使底层用了GPU一部分调度、同步、数据传输仍会占用主线程导致页面滚动卡滞。解决方法是把推理放在Web Worker里让主线程只负责渲染画面和接收结果。写法上需要一点处理在Worker内引入TensorFlow.js脚本主线程通过postMessage发送图片数据Worker内部完成模型加载、预处理和推理后再把结果传回来。这个方案搬起来有一定代码量所以我更建议在实时摄像头场景或者需要背景做其他高成本运算的场景下采用。6.2 动态切换模型按设备能力降级不同电脑的性能差异非常大。同样是口袋里一台电脑桌面独显跑224x224的MobileNet毫无压力但核显或者低端笔记本就可能掉帧。我在实际项目中采用的策略是先尝试加载大一点的模型或者更高分辨率的变体在WebGL后端被卡死或帧率过低时自动降级到更小的模型。区分设备能力的简单方法是看tf.engine().backend是否返回WebGL再看跑分帧率。更粗糙一点的方法是直接判断用户代理的GPU信息或用navigator.hardwareConcurrency判断CPU核心数。这个方向比较容易做过头我建议先做固定小模型跑通再考虑自适应。6.3 输入尺寸策略224还是160真实场景中不是所有分辨率越高的模型就越好。MobileNet的输入大小除了224还有192、160、128、96等版本。输入越小每帧计算量越小识别精度略有下降但速度明显提升。如果你做的是实时摄像头应用我建议直接尝试192或160。做单张图片分类时则保持224。这个取舍没有绝对标准跟你做的应用场景强相关。你在实际开发时不妨把模型输入尺寸作为配置项用A/B测试对比速度和用户满意度。这种小输入尺寸降级的思路跟压缩图片加载一个道理图片资源不是越大越好符合使用场景才是最优解。对前端AI来说这个道理同样适用。7. 一段值得复制的代码实践从模型加载到结果展示每次写完这样一篇文章我都会建议读者把完整代码保存一份反复打磨。下面我整理了一个完整可运行的版本你可以直接拷贝到本地跑起来再看效果。let model; let isClassifying false; const els { imageInput: document.getElementById(imageInput), previewImg: document.getElementById(previewImg), classifyBtn: document.getElementById(classifyBtn), status: document.getElementById(status), result: document.getElementById(result), }; const IMAGENET_CLASSES { 0: 狗, 1: 猫, // ... 完整映射请参照 imagenet_classes.js }; async function init() { els.status.textContent 正在加载模型...; model await tf.loadLayersModel( https://storage.googleapis.com/tfjs-models/tfjs/mobilenet_v1_0.25_224/model.json ); els.status.textContent 模型就绪请选择图片; els.classifyBtn.disabled false; } els.imageInput.addEventListener(change, async (e) { const file e.target.files[0]; if (!file) return; const url URL.createObjectURL(file); els.previewImg.onload () { URL.revokeObjectURL(url); }; els.previewImg.src url; els.previewImg.style.display block; els.result.innerHTML ; }); els.classifyBtn.addEventListener(click, async () { if (!model || isClassifying) return; isClassifying true; els.classifyBtn.disabled true; els.result.innerHTML 分类中...; const topK await tf.tidy(() { const tensor preprocess(els.previewImg); const logits model.predict(tensor); const probs tf.softmax(logits); return getTopK(probs, 3); }); els.result.innerHTML renderResult(topK); isClassifying false; els.classifyBtn.disabled false; }); function preprocess(imgElement) { const tensor tf.browser.fromPixels(imgElement); const resized tf.image.resizeBilinear(tensor, [224, 224]); const batched resized.expandDims(0); return batched.div(127.5).sub(1); } function getTopK(tensor, k) { const values tensor.arraySync()[0]; return values .map((prob, index) ({ prob, index })) .sort((a, b) b.prob - a.prob) .slice(0, k); } function renderResult(topK) { const rows topK.map(item { const label IMAGENET_CLASSES[item.index] || 类别${item.index}; const pct (item.prob * 100).toFixed(1); return li${label} — ${pct}%/li; }).join(); return ul${rows}/ul; } init();这份代码我在注释上尽量踩掉了所有新手容易踩的坑tidy清理张量、objectURL释放、分类按钮锁定防抖。你拿它跑通后再去改成自己的项目心里会踏实很多。后面你再接触更多AI程序时会发现这个加载模型 → 预处理输入 → 预测 → 后处理输出的模式会反复出现几乎所有推理程序都是这个套路。把这套模式烙进脑子里往后看任何前端AI项目你都会觉得似曾相识。我自己的体会是做第一个AI程序真正重要的不是你选了什么模型、用了什么框架而是你亲手把输入 → 模型 → 输出这条链路走通了一次。一旦走通AI在你眼里就不再神秘而是变成了一个有输入、输出、可调试的本领。剩下的只是一步步把它做得更大、更快、更有用。