正品库拍照PWA应用的实现与性能优化|得物技术
一、背景与难点
背景
目前得物ERP主要鉴别流程,是通过鉴别师鉴别提需到仓库,仓库库工去进行商品补图拍照,现有正品库59%的人力投入在线下商品借取/归还业务的操作端,目前,线下借取的方式会占用商品资源,同时在使用用途上,每借出10件会出现1次拍照留档,因此会有大量的线上阅图量在日常鉴别和学习中发生;正品库可通过图库搭建,提升图库质量,大大节约线下用工和物流成本支出。
但目前库内存量10~20W件,待进行拍照同步到正品库中,且目前仍不断有新品入库,现有的补图流程效率约每天30件,难以满足快速正品库建立的需要, 主要有以下问题:
※ 补图图片上传途径繁琐
仓端接收到补图任务后,需使用ERP网页端完成图片拍摄&上传操作,流程繁琐,操作冗余。
※ 留档图拍摄上传质量压缩
新品图片&补图图片上传ERP后,图片质量压缩,部分留档图因不清晰需重新拍摄,浪费作业人力。
※ 鉴别借还操作途径单一
鉴别借用&归还只能于PC端操作,不利于鉴别在库内现场进行借用&归还。
※ 正品流转效率问题
在图库建立前有很多鉴别是需要借用到实物的,借用之后的登记、归还等流程会大大影响流传效率,同时存在异地仓库借阅的情况,成本和周期更高。
优化前后整体方案对比
综合来说,其实相当于整体的操作都需要在手持设备上完成(包括上传、拍摄、通知等),这减少了过程操作繁多而导致的效率问题和图片质量问题。
难点
在Web端上,去实现一个自定义的相机拍摄能力是相对简单的,实现一个获取视频流转化为图片的能力也不复杂的。我们的初版应用的拍摄标准是1280x1280的图片,但鉴别师希望有更高的分辨率,能够得到原相机一模一样的拍摄结果,所以必须需要提高分辨率,按照手机原相机的分辨率去加工处理图片。以仓库的 iPhoneX 为例:若需分辨率达到超高清范畴的4032 * 3024,库工需要连续拍摄几十次甚至上百次的各个模板位的图片,才能完成一件正品的存档工作。
综合难点
※ 分辨率激增带来的内存压力
- 内存占用暴增,单个从6.4M左右跃升到48.8M,增长7.6倍。
- 超高清分辨率需要更多的GPU内存和计算资源。
- 高分辨率与流畅体验难以兼顾。
※ PWA内存分配限制
- 多层内存限制:拿iPhoneX为例,从3GB系统内存到300~500MB的实际可用内存,层层削减。若除去一些基础的开销(比如js引擎、WebKit开销等开销)后则更少,更容易达到系统限制的内存红线,进而产生卡顿、失败、被强制回收,降频等情况。
- Webkit严格限制,浏览器对单个标签页内存使用有硬性上限。
※ 视频流与图像处理的资源竞争
- 视频流和图像处理同时占用大量内存。
- GPU资源竞争,视频解码和Canvas绘制争夺GPU资源。
※ 移动设备性能差异化
- 硬件碎片化:不同设备内存和性能差异巨大。
- 兼容性问题:需要为不同性能的设备提供不同策略,保障任务的进行。
※ 浏览器内存管理的不可控性
- 内存分配不可预测:系统会根据整机的内存压力动态调整分配。自身web应用无法参与调控。
- GC时机不可控:垃圾回收可能在关键时刻触发,影响作业流程。
- 进程终止风险:极端情况下浏览器自己会终止页面,reload。
二、实现方案
整体技术实现
我们整体的技术实现基于 WebRTC 和 HTML5 Canvas 以及Web worker。
※ WebRTC
navigator.mediaDevices.getUserMedia 是 WebRTC API 的一部分,用于访问用户设备的摄像头和麦克风。它可以请求用户授权以获取视频或音频流,并将实时媒体流绑定到 标签上。
※ HTML5 的 video
用于显示摄像头捕捉到的实时视频流。
※ Canvas
通过 canvas 元素,可以从 标签的当前帧中捕获图像(拍照),并将其转换为图片格式(如 PNG 或 JPEG)。
※ WebWorker
通过允许在后台线程中运行脚本,避免阻塞主线程(UI 线程),从而解决复杂计算导致的页面卡顿问题。
整体架构
整体方案简要
- 在pwa页面中开启摄像头
- 获取视频流: CameraStreamManager管理相机流,提供video元素
- 等待帧稳定
- 通过视频流,创建ImageBitmap
- Worker处理: 将ImageBitmap传递给Worker进行处理
- 策略选择,根据设备情况做策略选择
- Worker中使用chunked、chunkedConvert等策略分块处理大图像
- 生成结果: 返回ObjectUrl(内存中的文件或二进制数据)
- 更新UI: 更新预览和上传队列
- 资源回收
- 结束或下一步
其中的实现细节内更多偏向于资源的精细化管理、回收释放、重试机制、容错机制等。
最核心的准则是:性能优先,稳定保底。
产品使用流程
操作流程里的核心是针对此前在电脑和手机中反复切换拍摄、录入、上传等复杂的操作,转变为在手持设备中一站式完成补图、拍摄、上传和通知等。
操作时序
三、性能优化
性能优化思维导图
为什么需要性能优化
- 页面卡顿
- 低端机型无法顺畅拍照
- 图片转化慢,手机热..
- 高频出现图像转化失败
- 突破内存峰值,系统回收内存降频等,程序reload
- ...
首先看下此前的策略中的性能表现,首先我们用的的是超高分辨率的约束配置条件:
const videoConstraints = useRef({ video: { facingMode: 'environment', width: { min: 1280, ideal: 4032, max: 4032 }, height: { min: 720, ideal: 3024, max: 3024 }, frameRate: { ideal: 30, // 适当降低可以降低视频缓冲区的内存占用,我们先按照这样的场景来看。 min: 15 }, advanced: [ { focusMode: "continuous" }, ] } as MediaTrackConstraints,});
如果单独拍摄一张图内存,粗略计算为如下(主要以iPhoneX的情况做解析):
// 视频流约束const iphoneXStreamConfig = { width: 4032, height: 3024, frameRate: 24, format: 'RGBA' // 4字节/像素};
// 单帧内存计算const frameMemoryCalculation = { // 单帧大小 pixelCount: 4032 * 3024, // = 12,192,768 像素 bytesPerFrame: 4032 * 3024 * 4, // = 48,771,072 字节 mbPerFrame: (4032 * 3024 * 4) / (1024 * 1024), // ≈ 46.51 MB};
// 实际运行时内存占用const runtimeMemoryUsage = { // 视频流缓冲区 (至少3-4帧) streamBuffer: { frameCount: 4, totalBytes: 48771072 * 4, // ≈ 186.04 MB description: '视频流缓冲区(4帧)' }, // 处理管道内存 processingPipeline: { captureBuffer: 46.51, // 一帧的大小 processingBuffer: 46.51, // 处理缓冲 encoderBuffer: 46.51 * 0.5, // 编码缓冲(约半帧) totalMB: 46.51 * 2.5, // ≈ 116.28 MB description: '视频处理管道内存' }, // 总体内存 total: { peakMemoryMB: 186.04 + 116.28, // ≈ 302.32 MB stableMemoryMB: 186.04 + 93.02, // ≈ 279.06 MB description: '预估总内存占用' }};
单张图的内存占用
按照上文的视频约束条件,单帧大小:约 46.51MB,实际单张内存需要76.7M左右(15 + 15 + 46.5 + 0.2 「objectURL引用」),三五张图大概就会达到内存限制红线,这样的内存占用对移动设备来说太大了,实际上,在项目上线初期,业务使用也反馈:拍照几张手机发热严重,页面经常卡死。
PWA相机应用内存占用情况
在移动端中,特别是ios,内存限制是动态的,依赖多个因素,如:设备物理内存总量,设备当前可用内存,后台的软件运行情况。上文可以看出至少有300M是固定支出的,还需增加一些WebRtc视频帧缓冲累积的占用、浏览器内存缓存解码帧的堆积。
在iPhone的WeKit的内核浏览器下,官方内存限制虽是1.5G,实际上可能在是800-1200M左右,在实际的测试场景下,甚至还要低很多。
拍摄过程内存变化
秒数是为了更直观的观察区分内存数据的变化。
有些并不能立即回收canvas对象,需要等之前的二进制blob文件被回收后才可进行,这无疑是在慢慢增加内存的压力。
内存压力趋势分析
基于上文的单独内存占用和相机应用的内存占用(按照1.5G的分配),可以粗略分析出:
这些大部分都是官方的数据计算和累积,在实际操作中,如果操作过快,差不多会在第三、四张时开始出现问题了。因为变量比较多,比如充电或发热情况;而连续作业时候的情况又各不同,但是整体规律是差不多的。上文分析的是5张开始危险,实际情况则是第三张就已经出现问题了。
不仅如此,在拍摄作业流程中,还有CPU的热节流风险,如内存85%使用率超过30秒,cpu会降频至70%或更低的性能。
这其中的主要消耗是:视频流处理(35-45%) + Canvas处理(25-35%) 及4032×3024这类大分辨率导致的计算密集型操作。
做了哪些优化
- canvas主线程绘制更改为离屏渲染绘制
- 视频流管理、前置设备参数预热
- 分辨率管理
- 引入Webworker线程单独绘制
- 优化设备检测策略
- 异步上传管理
- 产品兜底,页面reload,缓存历史数据
- 内存分配模型
方案选择与实现
实现原相机拍摄的最初的一版,是通过把canvas内容转为base64后,同步上传图片,最初通过一些低端机的测试情况来看,最主要的问题是图片比较大,生成的base64的code自然也比较大,在数据体积上会增大33%左右。 因为是移动设备,这么大的图片上传的速度又相对缓慢,导致操作的过程需要等待和加载。
在这样的场景下为什么要异步上传呢,如果拍摄的快些,页面会变得很卡顿。由于大量的字符串涌入到页面中,再加上cavans转化这么大的image到base64 code又会比较消耗内存,所以整体有丢帧卡顿的表现。进而考虑替换为blobUrl。
toDataURL 和 toBlob对比
如上所示,我们最终选择了性能更好的canvas to Blob并使用二进制的形式。
更快的回显
更快的转化
更小的内存占用
在运用了 Blob 后, 通过埋点等操作,页面渲染和流畅度虽然有所缓解,但会在比较高频的情况下出现图片转化失败,而且也是间隔性的,如上文所示,我们根据渲染和一些实际案例分析过后,发现问题还是存在于内存峰值和CPU资源。
canvas.convertToBlob失败主要是因为内存的限制问题,特别是在处理大图像时。编码同一图像可能在资源充足时成功,资源紧张时失败,这也就解释了为什么是间隔性的出现转化失败。
因为有大量的绘制需在主线程完成,但由于JS的单线程问题,严重影响了页面的操作和后续的渲染, 使得库工的作业流程被迫等待。因此,我们引入了WebWorker以及OffscreenCanvas,开启新线程专一用来做绘制。当然Webworker中的内存的管理也是比较复杂的,同样会占据大量内存,也有数据通信成本,但是相较于用户体验,我们不得不做一定程度的平衡和取舍。
Web Worker + OffscreenCanvas 架构
- 主线程不阻塞:图像处理在Worker中进行,UI保持响应
- 更好的性能:OffscreenCanvas在独立线程中渲染
- 内存隔离:Worker独立内存空间,避免主线程内存压力
好处就是可以多张并发,降低内存泄漏风险,劣势是开发复杂度增加,调试困难, 数据传输开销(ImageBitmap需要转移所有权)。
相机资源的动态管理与释放
我们知道每个机器的分辨率与他们对WebRtc相关能力的支持是不同的。比如iPhoneX 的最大分辨率支持是:4032 * 3024,其他的机器则会不同,所以固定的分辨率配置是行不通的,需要在进入相机后检查设备支持情况等。以及视频通道的保留操作和暂时性暂停,也对操作流程产生着很大积极影响。在继续服用的场景下仅暂停数据传输,保持活跃连接,在下一张拍摄的时候复用连接,而非重新进行初始化、连接和检查等操作。
ImageBitmap 直接创建策略
在绘制中,如果 imageData 是普通的 Image 或 Canvas,每次 drawImage 都可能涉及格式转换和内存拷贝,无疑增大了内存支出。引入 ImageBitmap,因其是专门为高性能图像作处理设计,数据存储在 GPU 内存中,最重要的是:它支持内存的复制转义,可以交到Webworker中去处理,可以在主线程和 Worker 之间零拷贝传输,在worker中直接使用,无需解码。
直接从视频流创建ImageBitmap,跳过Canvas中间步骤。
...let imageBitmap: ImageBitmap | null = null;// 判断是否为视频元素,如果是则尝试直接创建ImageBitmap// 支持img 和 vedioif ((source instanceof HTMLVideoElement || source instanceof HTMLImageElement) && supportsImageBitmap) { try { console.log('尝试直接从视频元素创建ImageBitmap'); // 直接从视频元素创建ImageBitmap,跳过Canvas中间步骤 if (source instanceof HTMLVideoElement) { imageBitmap = await createImageBitmap( source, 0, 0, sourceWidth, sourceHeight ); } else { // 支持img imageBitmap = await createImageBitmap(source); } console.log('直接创建ImageBitmap成功!!'); } catch (directError) { console.warn('这直接从视频创建ImageBitmap失败,回退到Canvas:', directError); // 失败后将通过下面的Canvas方式创建 imageBitmap = null; } } ...
createImageBitmap 实际上是:
- 创建一个位图引用
- 可能直接使用视频解码器的输出缓冲区
- 在支持的平台上,直接使用GPU内存中的纹理
- 最重要的是:不涉及实际的像素绘制操作、高效的跨线程传输(支持通过结构化克隆算法高效传输避免了序列化/反序列化开销,能高效传送到Worker)
※ 综合表现
- 性能最优: 避免Canvas绘制的中间步骤。
- 内存效率: 直接从视频帧创建位图,占用更低。
- 硬件加速: 可利用GPU加速。
Worker中的图像处理策略
在web端,主线程和Worker间的数据传输有三种方式,结构化克隆和Transferable对象,ShareArrayBuffer(共享内存访问,支持度有问题),整体上使用Transferable对象的形式,可降低内存消耗。接下来,我们简单介绍这里用到的两种执行策略。
※ chunked策略(chunked processing分块处理)
主要源于内存控制,避免图像过大导致的内存溢出。将大图像分割成多个小块,使用一个小的临时画布逐块处理后绘制到最终画布,通过"分而治之"的策略显著降低内存峰值使用,避免大图像处理时的内存溢出问题。
劣势是处理时间增加,算法复杂度高。
chunked策略流程示意
class ChunkedProcessStrategy extends ImageProcessStrategy { readonly name = 'chunked'; protected async doProcess(imageData: ImageBitmap, options: ProcessOptions): Promise<Blob> { const { width, height, quality } = options; const optimalChunkSize = ResourceManager.calculateOptimalChunkSize(width, height); const chunkConfig: ChunkConfig = { size: optimalChunkSize, cols: Math.ceil(width / optimalChunkSize), rows: Math.ceil(height / optimalChunkSize), }; const { canvas: finalCanvas, ctx: finalCtx } = ResourceManager.createCanvas(width, height); const { canvas: tempCanvas, ctx: tempCtx } = ResourceManager.createCanvas(optimalChunkSize, optimalChunkSize); try { for (let row = 0; row < chunkConfig.rows; row++) { for (let col = 0; col < chunkConfig.cols; col++) { await this.processChunk( imageData, tempCanvas, tempCtx, finalCtx, row, col, chunkConfig, width, height ); await new Promise(resolve => setTimeout(resolve, 0)); } } return await finalCanvas.convertToBlob({ type: 'image/jpeg', quality, }); } finally { ResourceManager.releaseResources(tempCanvas, tempCtx); ResourceManager.releaseResources(finalCanvas, finalCtx); } } private async processChunk( imageData: ImageBitmap, tempCanvas: OffscreenCanvas, tempCtx: OffscreenCanvasRenderingContext2D, finalCtx: OffscreenCanvasRenderingContext2D, row: number, col: number, chunkConfig: ChunkConfig, width: number, height: number ): Promise<void> { const x = col * chunkConfig.size; const y = row * chunkConfig.size; const chunkWidth = Math.min(chunkConfig.size, width - x); const chunkHeight = Math.min(chunkConfig.size, height - y); tempCtx.clearRect(0, 0, chunkConfig.size, chunkConfig.size); tempCtx.drawImage( imageData, x, y, chunkWidth, chunkHeight, 0, 0, chunkWidth, chunkHeight ); finalCtx.drawImage( tempCanvas, 0, 0, chunkWidth, chunkHeight, x, y, chunkWidth, chunkHeight ); }} ...
主要针对中等性能的机型,适用于直接转化可能失败的情形。
※ chunkedConvert策略(分块处理转化)
将大图像分块后,每块独立转换为压缩的Blob存储,最后再将所有Blob重新解码,同时合并到最终画布,通过"分块压缩存储 + 最终合并"的策略实现极致的内存控制,但代价是处理时间翻倍,属于时间换内存的策略。
chunkedConvert策略流程示意
// 分块转化 最终返回class ChunkedProcessStrategy extends ImageProcessStrategy { readonly name = 'chunked'; protected async doProcess(imageData: ImageBitmap, options: ProcessOptions): Promise<Blob> { const { width, height, quality } = options; const optimalChunkSize = ResourceManager.calculateOptimalChunkSize(width, height); const chunkConfig: ChunkConfig = { size: optimalChunkSize, cols: Math.ceil(width / optimalChunkSize), rows: Math.ceil(height / optimalChunkSize), }; const { canvas: finalCanvas, ctx: finalCtx } = ResourceManager.createCanvas(width, height); const { canvas: tempCanvas, ctx: tempCtx } = ResourceManager.createCanvas(optimalChunkSize, optimalChunkSize); try { for (let row = 0; row < chunkConfig.rows; row++) { for (let col = 0; col < chunkConfig.cols; col++) { await this.processChunk( imageData, tempCanvas, tempCtx, finalCtx, row, col, chunkConfig, width, height ); // 给GC机会 await new Promise(resolve => setTimeout(resolve, 0)); } } return await finalCanvas.convertToBlob({ type: 'image/jpeg', quality, }); } finally { ResourceManager.releaseResources(tempCanvas, tempCtx); ResourceManager.releaseResources(finalCanvas, finalCtx); } } private async processChunk( imageData: ImageBitmap, tempCanvas: OffscreenCanvas, tempCtx: OffscreenCanvasRenderingContext2D, finalCtx: OffscreenCanvasRenderingContext2D, row: number, col: number, chunkConfig: ChunkConfig, width: number, height: number ): Promise<void> { const x = col * chunkConfig.size; const y = row * chunkConfig.size; const chunkWidth = Math.min(chunkConfig.size, width - x); const chunkHeight = Math.min(chunkConfig.size, height - y); tempCtx.clearRect(0, 0, chunkConfig.size, chunkConfig.size); tempCtx.drawImage( imageData, x, y, chunkWidth, chunkHeight, 0, 0, chunkWidth, chunkHeight ); finalCtx.drawImage( tempCanvas, 0, 0, chunkWidth, chunkHeight, x, y, chunkWidth, chunkHeight ); }}
......
class ChunkedConvertStrategy extends ImageProcessStrategy { readonly name = 'chunkedConvert'; protected async doProcess(imageData: ImageBitmap, options: ProcessOptions): Promise<Blob> { const { width, height, quality } = options; const config = WorkerConfig.getInstance(); const chunks: Array<{ blob: Blob; x: number; y: number; width: number; height: number; }> = []; // 分块处理 for (let y = 0; y < height; y += config.chunkSize) { for (let x = 0; x < width; x += config.chunkSize) { const chunkWidth = Math.min(config.chunkSize, width - x); const chunkHeight = Math.min(config.chunkSize, height - y); const chunk = await this.processSingleChunk( imageData, x, y, chunkWidth, chunkHeight, quality ); chunks.push({ ...chunk, x, y, width: chunkWidth, height: chunkHeight }); await new Promise(resolve => setTimeout(resolve, 0)); } } // 合并块 return chunks.length === 1 ? chunks[0].blob : await this.mergeChunks(chunks, width, height, quality); } private async processSingleChunk( imageData: ImageBitmap, x: number, y: number, width: number, height: number, quality: number ): Promise<{ blob: Blob }> { const { canvas, ctx } = ResourceManager.createCanvas(width, height); try { ctx.drawImage(imageData, x, y, width, height, 0, 0, width, height); const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality, }); return { blob }; } finally { ResourceManager.releaseResources(canvas, ctx); } } private async mergeChunks( chunks: Array<{ blob: Blob; x: number; y: number; width: number; height: number }>, width: number, height: number, quality: number ): Promise<Blob> { const { canvas: finalCanvas, ctx: finalCtx } = ResourceManager.createCanvas(width, height); try { for (const chunk of chunks) { const imgBitmap = await createImageBitmap(chunk.blob); try { finalCtx.drawImage( imgBitmap, 0, 0, chunk.width, chunk.height, chunk.x, chunk.y, chunk.width, chunk.height ); } finally { imgBitmap.close(); } await new Promise(resolve => setTimeout(resolve, 0)); } return await finalCanvas.convertToBlob({ type: 'image/jpeg', quality, }); } finally { ResourceManager.releaseResources(finalCanvas, finalCtx); } }}
会有更小的峰值,适配与更低端的机型和极大图像。不会内存溢出,但是也会降低转化效率。在可用与效率方面,选择了可用。
其中整体方案里还有一些其他的策略,如Direct直接转化、边转化边绘制等,会根据不同的机型进行选择。目前,重点保障低端机型,因为中高端机器在使用过程中没有性能上的卡点。
优化后对比
首先,我们明确了这几个主要策略:
- Web Worker架构 - 主线程内存压力分散
- ImageBitmap直接传输 - 减少内存拷贝
- 绘制分块处理 - 降低内存峰值
- 资源管理优化 - Canvas复用和及时释放
最重要策略:增加很多管理器和优化方式降低内存的峰值,即那一瞬间的值。
同时,将可以在后台做转化和运算的操作,投入到web worker中去做,降低主线程的内存压力。
优化后单图内存占用情况
优化后PWA相机应用内存占用
优化后的效果
※ 内存优化结果
- 单张图片处理峰值减少33% - 从123.2MB降至82.2MB。
- 单张图片持久占用减少61% - 从76.7MB降至30.2MB。
- PWA应用整体内存优化16-26% - 根据图片数量不同。
- 内存压力等级显著降低,如从3-4张开始有明显警示压力,到操作快速秒级拍摄速率时才出现(实际操作过程中大概10-15秒一张,因需要摆放和根据模版与提醒进行拍摄)。
※ 用户体验
- 最终在高清图片的绘制作业流程中,由原来的3张图告警到一次性可以拍摄50张图的情况,大大降低了失败风险。提升了作业的流畅度。
- 用户体验改善,消除UI阻塞,响应时间减半。
四、业务结果
通过几轮的策略优化,整个pwa应用已可以相对顺畅、高效的绘制原相机标准的正品图,已完全达到鉴别师高清图的要求,同时不会有操作流的中断。
- 目前日均的拍摄件数提升 330%,达成预期目标。
- 将每件的人力投入成本降低 41.18%。
- 目前通过PWA项目快速搭建了图库项目,Q2拍照数据占比72.5%,预期后面比例会逐步升高,图库流转效率提高到了20%,超出业务预期。
五、规划和展望
在技术的实现上,许多时候要去做用空间换时间或用时间换空间的策略方案,本质上还是根据我们当前的业务场景和诉求,追求当下收益。有些时候可能不止局限在实现上,需要从实际需求出发,不应该只停留在工具的层面,而深入到业务里剖析挖掘其潜在的业务价值,做更深远的思考,从工具思维转向价值发现与传递的方向上。
未来我们还会思考:
-
前置对设备的综合能力评估,更精细化的拆分低、中、高端设备和适配策略,收集更多的实际处理时间和内存峰值、CPU 性能指标等,用于不断优化策略选择算法。
-
根据类目做区分(比如鞋服、奢品),这些在鉴别的时候图片质量有不同的品质要求的分类。后续可能会进行更加具有定制化属性的方案,针对鉴别打标,针对当前业务中图片拍摄重试场景下的AI图像识别,针对重复拍摄场景做优化,进一步提高效率。
-
针对目前 10 到 15 秒的拍摄时间,能进一步压缩问题,思考更加智能的拍摄能力。根据设备的真实情况,或基于色温分析的光线评估,提高图像质量和降低重复率。基于正品特征进行构图优化,在设备上做实时拍摄指导,不只以单一模板和示例进行人工检查,而是进一步标准化,降低人力参与度。
-
针对于商研侧业务和前置拍照流程,将拍照H5的方案也纳入采卖商品入库流程,同时支持鉴别师对于图库的验收,加快图库的验收入库效率,缩短库内的拍照数据积压周期。
往期回顾
5.从CPU冒烟到丝滑体验:算法SRE性能优化实战全揭秘|得物技术
文 / 维克
关注得物技术,每周更新技术干货
要是觉得文章对你有帮助的话,欢迎评论转发点赞~
未经得物技术许可严禁转载,否则依法追究法律责任。