从图片重复上传的烦恼到AI巡检

113 阅读10分钟

起因:被图片重复问题折磨的日子

说起这个项目的起因,还得从我们团队遇到的一个头疼问题说起。

我们的项目越做越大,参与的人也越来越多。刚开始还好,大家都比较熟悉,知道哪些图片已经用过了。但是随着团队扩张,新人不断加入,问题就来了。

经常有这样的情况:小王上传了一个icon,过两天小李又上传了一个几乎一模一样的。更夸张的是,有时候同一张图片会被上传好几次,只是文件名不一样。

我们粗略统计了一下,目前项目拢共有1.4w张图片,其中单单一个左箭头的icon就重复了100多张。

我们试过的那些"土办法"

一开始我们想了各种办法来解决这个问题:

方法一:靠文件名
让大家上传图片的时候起个有意义的名字,比如login-button-icon.png。但是你懂的,每个人的命名习惯不一样,loginBtn.pnglogin_icon.png登录按钮.png...结果还是一团糟。

方法二:人工检查
安排专人定期检查重复图片。但是上万张图片,靠眼睛看?累死人不说,效果也不好。而且压缩过的图片、改过尺寸的图片,人眼很难判断是不是同一张。

方法三:MD5对比
写了个脚本计算所有图片的MD5值,找出完全相同的文件。这个方法能找出一些重复,但是局限性很大。同一张图片如果被压缩了、改了格式、调了尺寸,MD5就完全不一样了。

试了一圈下来,发现这些方法都不够智能。我们需要的是能够"看懂"图片内容的工具,而不是简单的文件对比。

解决方案:让AI来帮我们"看图"

既然人工不行,文件对比也不行,那就让AI来帮忙吧。我们的想法很简单:能不能让机器像人一样"看懂"图片的内容,然后告诉我们哪些图片是相似的?

经过一番调研,我们决定用CLIP模型来做图片特征提取,用Faiss来做相似度检索。

为什么选择CLIP和Faiss?

关于CLIP的选择

CLIP是OpenAI开源的一个模型,最大的特点是能够同时理解图片和文字。对我们来说,最重要的是它能把图片转换成一个512维的向量,这个向量包含了图片的"语义信息"。

简单来说,如果两张图片看起来很像,那么它们的向量也会很相似。而且CLIP很聪明,即使图片被压缩了、改了尺寸、换了格式,只要内容相似,向量就会相似。

我们试过几个不同的模型,最后选择了Xenova/clip-vit-base-patch16,主要是因为:

  • 可以在Node.js服务器上运行,部署简单
  • 准确率够用,基本能识别出我们需要的相似图片
  • 速度还可以,处理一张图片大概200毫秒

关于Faiss的选择

有了图片的向量表示,下一步就是怎么快速找到相似的向量。如果只有几十张图片,暴力对比也行。但我们有3000多张图片,而且还在不断增加,就需要一个专门的向量数据库了。

Faiss是Facebook开源的向量检索库,专门用来解决这种问题。它的优点是:

  • 速度快:几毫秒就能从百万个向量中找到最相似的
  • 内存省:用了很多优化技巧,不会把内存撑爆
  • 好扩展:以后图片更多了,也能应付

整体架构

graph TB
    A[图片上传前检查] --> B[CLIP特征提取]
    B --> C[Faiss向量检索]
    C --> D[相似度计算]
    D --> E{相似度 > 阈值?}
    E -->|是| F[提示已存在相似图片]
    E -->|否| G[允许上传]
    
    H[现有图片库] --> I[批量特征提取]
    I --> J[构建向量索引]
    J --> C

具体怎么实现的

核心思路

整个系统的工作流程其实挺简单的:

  1. 把所有图片都用CLIP模型转换成向量
  2. 把这些向量存到Faiss数据库里
  3. 当有新图片要上传时,也转换成向量
  4. 在数据库里搜索相似的向量
  5. 如果找到了,就提示用户"已经有类似的图片了"

代码实现的关键部分

图片特征提取的适配器

class TransformerEmbeddings extends Embeddings {
  constructor(extractor) {
    super();
    this.extractor = extractor;
  }

  async embedQuery(imageSource) {
    let imageBuffer;
    
    // 支持多种图片源
    if (imageSource.startsWith('http')) {
      // 处理网络图片(图床URL)
      const response = await fetch(imageSource);
      const arrayBuffer = await response.arrayBuffer();
      imageBuffer = Buffer.from(arrayBuffer);
    } else {
      // 处理本地图片
      imageBuffer = await fs.readFile(imageSource);
    }

    // 图片预处理:确保格式一致性
    const processedBuffer = await sharp(imageBuffer)
      .flatten({ background: { r: 255, g: 255, b: 255 } })
      .toFormat('jpeg')
      .toBuffer();

    // 转换为模型输入格式
    const rawImage = await RawImage.fromBlob(new Blob([processedBuffer]));
    
    // 特征提取
    const result = await this.extractor(rawImage, {
      pooling: 'mean',
      normalize: true,
    });

    return Array.from(result.data);
  }
}

这个类的作用是把CLIP模型包装一下,让它能和LangChain的Faiss组件配合使用。

几个要注意的地方:

  • 支持多种图片来源:既能处理本地文件,也能处理网络上的图片URL
  • 图片预处理:用Sharp库把图片统一转换成JPEG格式,避免格式不一致的问题

重复图片检测器

class ImageDuplicateFinder {
  constructor() {
    this.vectorStore = null;
  }

  // 批量检测重复图片
  async findDuplicates(imagePaths, threshold = 0.95) {
    const duplicateGroups = [];
    
    for (let i = 0; i < imagePaths.length; i++) {
      const queryPath = imagePaths[i];
      const similarImages = await this.findSimilarImages(
        queryPath, 
        threshold, 
        10
      );
      
      if (similarImages.length > 0) {
        duplicateGroups.push({
          original: queryPath,
          duplicates: similarImages,
          potentialSavings: this.calculateSavings(similarImages)
        });
      }
    }
    
    return duplicateGroups;
  }
}

实际使用效果

上传前的智能提醒

// 集成到上传流程中
async function checkBeforeUpload(imageFile) {
  const finder = new ImageDuplicateFinder();
  await finder.loadDatabase('faiss_index');
  
  // 检查是否存在相似图片
  const similarImages = await finder.findSimilarImages(
    imageFile.path, 
    0.90  // 90%相似度阈值
  );
  
  if (similarImages.length > 0) {
    return {
      shouldUpload: false,
      message: `发现${similarImages.length}张相似图片`,
      alternatives: similarImages.map(img => ({
        url: img.metadata.url,
        similarity: img.similarity,
        size: img.metadata.size
      }))
    };
  }
  
  return { shouldUpload: true };
}

现在当开发者要上传图片时,系统会自动检查是否已经有类似的图片了。如果有,就会提示:"嘿,已经有3张相似的图片了,要不要用现有的?"

批量清理重复图片

// 定期清理重复图片
async function cleanupDuplicates() {
  const finder = new ImageDuplicateFinder();
  const allImages = await getAllProjectImages();
  
  const duplicateReport = await finder.findDuplicates(allImages);
  
  // 生成清理建议
  const cleanupSuggestions = duplicateReport.map(group => ({
    keepImage: group.original,
    removeImages: group.duplicates,
    spaceSaved: group.potentialSavings.space,
    costSaved: group.potentialSavings.cost
  }));
  
  // 输出清理报告
  console.log(`发现${duplicateReport.length}组重复图片`);
  console.log(`预计节省空间:${totalSpaceSaved}MB`);
  console.log(`预计节省成本:$${totalCostSaved}/月`);
  
  return cleanupSuggestions;
}

我们还写了个脚本,定期扫描所有图片,生成重复图片的清理建议。

遇到的一些坑和优化

相似度阈值很难调

不同类型的图片,相似度阈值应该不一样。比如icon要求很严格,0.95以上才算相似;但是照片就可以宽松一点,0.85就行了。

我们最后做了个自适应的策略:

const thresholds = {
  'icon': 0.95,        // 图标要求严格
  'photo': 0.85,       // 照片可以宽松点
  'background': 0.80   // 背景图更宽松
};

下一步计划:自动化UI巡检

有了图片相似度检索的基础,我们想到了一个更有意思的应用场景:自动检查线上页面是否和设计稿一致

你知道的,设计师给了设计稿,开发按照设计稿写代码,但最终上线的页面和设计稿总是会有些差异。有时候是开发理解有偏差,有时候是浏览器兼容性问题,有时候是数据问题导致的布局异常。

以前这种问题只能靠人工巡检,或者等用户反馈。现在我们想用技术手段来自动化这个过程。

基本思路

graph TB
    A[定时任务调度] --> B[页面截图服务]
    B --> C[截图预处理]
    C --> D[特征提取]
    
    E[设计稿库] --> F[设计稿特征提取]
    F --> G[基准向量库]
    
    D --> H[相似度对比]
    G --> H
    H --> I[差异检测]
    I --> J[生成巡检报告]
    J --> K[告警通知]

具体怎么实现

定时截图功能

class PageScreenshotService {
  constructor() {
    this.browser = null;
  }

  async initBrowser() {
    this.browser = await puppeteer.launch({
      headless: true,
      args: ['--no-sandbox', '--disable-setuid-sandbox']
    });
  }

  async capturePageScreenshot(url, options = {}) {
    const page = await this.browser.newPage();
    
    try {
      // 设置视口大小
      await page.setViewport({
        width: options.width || 1920,
        height: options.height || 1080
      });

      // 访问页面
      await page.goto(url, { 
        waitUntil: 'networkidle2',
        timeout: 30000 
      });

      // 等待页面完全加载
      await page.waitForTimeout(2000);

      // 截图
      const screenshot = await page.screenshot({
        fullPage: options.fullPage || false,
        type: 'png'
      });

      return screenshot;
    } finally {
      await page.close();
    }
  }
}

用Puppeteer定时访问线上页面,截图保存下来。

智能对比分析

class UIComparisonEngine {
  constructor() {
    this.finder = new ImageDuplicateFinder();
    this.thresholds = {
      identical: 0.98,      // 几乎相同
      similar: 0.90,        // 相似
      different: 0.70       // 有差异但可接受
    };
  }

  async compareWithDesign(screenshotBuffer, designImagePath) {
    // 1. 提取截图特征
    const screenshotFeatures = await this.finder.extractImageFeatures(
      screenshotBuffer
    );

    // 2. 提取设计稿特征
    const designFeatures = await this.finder.extractImageFeatures(
      designImagePath
    );

    // 3. 计算相似度
    const similarity = this.calculateSimilarity(
      screenshotFeatures, 
      designFeatures
    );

    return {
      similarity,
      status: this.getComparisonStatus(similarity)
    };
  }

  getComparisonStatus(similarity) {
    if (similarity >= this.thresholds.identical) {
      return 'IDENTICAL';
    } else if (similarity >= this.thresholds.similar) {
      return 'SIMILAR';
    } else if (similarity >= this.thresholds.different) {
      return 'MINOR_DIFFERENCES';
    } else {
      return 'MAJOR_DIFFERENCES';
    }
  }
}

把截图和设计稿都转换成向量,然后计算相似度。

自动生成巡检报告

class InspectionReportGenerator {
  async generateReport(inspectionResults) {
    const report = {
      timestamp: new Date(),
      summary: this.generateSummary(inspectionResults),
      details: inspectionResults.map(result => ({
        page: result.url,
        status: result.comparisonResult.status,
        similarity: result.comparisonResult.similarity,
        issues: this.extractIssues(result)
      }))
    };

    // 生成HTML报告
    const htmlReport = await this.renderHTMLReport(report);
    
    // 如果发现问题,发送告警
    if (report.summary.pagesWithIssues > 0) {
      await this.sendSlackNotification({
        channel: '#ui-alerts',
        message: `🚨 UI巡检发现${report.summary.pagesWithIssues}个页面存在问题`
      });
    }

    return report;
  }
}

把检测结果整理成报告,如果发现问题就发送告警。

定时任务调度

class InspectionScheduler {
  start() {
    // 每日凌晨2点执行全量巡检
    cron.schedule('0 2 * * *', async () => {
      console.log('开始执行每日UI巡检...');
      await this.runFullInspection();
    });

    // 每小时执行关键页面巡检
    cron.schedule('0 * * * *', async () => {
      console.log('开始执行关键页面巡检...');
      await this.runCriticalPagesInspection();
    });
  }
}

用cron定时执行巡检任务。

为啥会想做这个呢

这就像后台有接口监控一样,前端页面巡检其实更加重要。因为前端页面的异常来源很复杂:

  • 接口层面:API异常、数据格式变化、超时等导致页面显示错误
  • 前端逻辑:JavaScript报错、状态管理问题、组件渲染异常
  • 权限控制:用户权限变化导致页面功能缺失或显示异常
  • UI层面:CSS样式问题、响应式布局异常、浏览器兼容性
  • 极端情况:直接白屏、页面崩溃

而且前端问题往往是用户最直接感受到的,一个小的显示异常可能就会影响用户体验,甚至导致业务损失。所以自动化的前端页面巡检确实很有必要,否则只有接到用户真实投诉,才知道页面的问题,总会慢人一步,所以前端巡检也是我们接下来团队要做的事情。

开发过程中踩过的坑

模型加载时间长的问题

CLIP模型第一次加载要30秒左右。我们做了个模型预热机制:

// 服务启动时就加载模型
class ModelManager {
  static instance = null;
  
  static async getInstance() {
    if (!this.instance) {
      console.log('正在加载CLIP模型...');
      this.instance = await pipeline('image-feature-extraction', 'Xenova/clip-vit-base-patch16');
      console.log('模型加载完成');
    }
    return this.instance;
  }
}

总结

重复图片减少了,对于cdn的负担也降低了,而且这个项目让我们对AI在实际业务中的应用有了更深的理解:

  1. AI不是万能的:模型的准确率、处理速度、资源消耗都需要平衡
  2. 数据质量很重要:图片的预处理、格式统一对结果影响很大

下一步的计划

  • 把UI巡检功能做出来,解决线上页面和设计稿不一致的问题
  • 根据用户反馈继续优化检测精度
  • 支持更多的图片格式
  • 支持移动端页面的检测
  • 做一个更友好的管理界面
  • 提供API给其他系统使用

写在最后

更重要的是,我们意识到前端工程师不应该只是AI的一个壳。我们需要培养AI思维,主动思考如何用AI技术解决实际问题。

这个项目就是我们的AI试验田。通过实际动手,我们积累了从问题识别、技术选型到工程落地的完整经验。这些经验比单纯学习AI理论更有价值,因为它们来自真实的业务场景。

随着AI技术的快速发展,前端工程师如果只停留在传统的页面开发上,很可能会被时代抛弃。我们需要提前布局,在日常工作中主动寻找AI的应用场景,把AI当作解决问题的工具,而不是高不可攀的技术。

只有这样,当下一个技术浪潮来临时,我们才能从容应对,而不是被动跟随。