Я сделал это за Google

от автора

Как я создавал расширение для поиска дубликатов и похожих фоток в Google Photos: хитрые скриншоты, собственные хеши и почему нейросети не помещаются в браузер

Введение

Google Photos — отличный сервис для хранения фотографий, но у него есть одна проблема: он не умеет находить дубликаты. Вернее может, но 100% одинаковые — даже разные EXIF данные — и все — давай, до свидания! За годы использования в моей библиотеке накопились тысячи похожих фотографий, и удалять их вручную — задача на десятки часов.

Особенно, когда тебя предупреждают, что 80% места занято — купи еще!

Я решил создать расширение для Chrome, которое автоматически найдет дубликаты. Казалось бы, простая задача: скачать фотографии, сравнить их с помощью нейросети, готово! Но оказалось, что браузерные расширения — это совершенно особый мир со своими ограничениями, и привычные подходы здесь не работают.


Проблема №1: Google Photos не дает скачать фотографии

Первая неожиданность: в Google Photos нельзя просто взять и скачать изображение. Все фотографии отображаются как CSS background-image. Можно вытащить ID изображения, можно сконсруировать ссылку на оригинал и даже засунуть оригинал в img src, но из-за CORS Canvas с него создать не получится — значит нет вам никаких данных о пикселях.

Пришлось придумать хитрый способ: делать скриншоты самих элементов на странице. Ведь все равно сравниваем мы не полные фотографии, а их миниатюры

«Гениальный» алгоритм скриншотов

Основная идея: если мы не можем скачать изображение, то сделаем скриншот той области экрана, где оно отображается.

Пакетная обработка

Для оптимизации я реализовал пакетную обработку скриншотов:

async processBatchScreenshots(sessionId, photoBatch, startIndex, layout) {     const container = document.getElementById('pc-screenshot-container');          // Создаем невидимую область с фотографиями     const screenshotSlots = [];     for (let i = 0; i < photoBatch.length; i++) {         const photo = photoBatch[i];         const slot = this.createScreenshotSlot(photo, startIndex + i, layout);         container.appendChild(slot.element);         screenshotSlots.push(slot);     }      // Загружаем все изображения одновременно     const loadPromises = screenshotSlots.map(slot =>         this.loadImageInSlot(slot).catch(error => {             slot.loadFailed = true;             return null;         })     );     await Promise.all(loadPromises);      // Делаем один скриншот всей области     const batchScreenshot = await this.captureBatchScreenshot();      // Вырезаем отдельные изображения из общего скриншота     for (let i = 0; i < screenshotSlots.length; i++) {         const slot = screenshotSlots[i];         if (!slot.loadFailed) {             const croppedImage = await this.cropImageFromBatch(batchScreenshot, slot.bounds);             await this.uploadImageToSession(sessionId, photo.id, croppedImage);         }     } } 

Хитрости позиционирования

Самое сложное — точно вычислить координаты каждого изображения в скриншоте:

createScreenshotSlot(photo, index, layout) {     const slotSize = layout.slotSize; // Учитывает devicePixelRatio     const spacing = layout.spacing;          // Вычисляем позицию в сетке     const positionInBatch = index % layout.batchSize;     const row = Math.floor(positionInBatch / layout.imagesPerRow);     const col = positionInBatch % layout.imagesPerRow;      const left = 10 + col * (slotSize + spacing);     const top = 10 + row * (slotSize + spacing);      // Возвращаем точные координаты для обрезки     return {         element: slot,         bounds: {             x: left,             y: top,             width: slotSize,             height: slotSize,             devicePixelRatio: layout.devicePixelRatio         }     }; } 

Пришлось учесть множество нюансов:

  • devicePixelRatio для экранов с высокой плотностью пикселей

  • Асинхронную загрузку изображений

  • Разные форматы URL в Google Photos

  • Границы и отступы элементов

Проблема №2: Где запустить алгоритм сравнения?

Изначально я планировал использовать готовую нейросеть для сравнения изображений. Начал с Python-сервера. Сервер работал отлично, но у него был критический недостаток: Приватность. Вернее, ее отсутствие. Все фотографии загружались бы на мой сервер для сравнения, я мог бы обучать на них свою собственную нейронку, но приватность — превыше всего.

Попробовал найти JavaScript-версии популярных моделей, чтобы запустить их в браузере:

  • ResNet — слишком большая

  • MobileNet — все еще большая + TensorFlow.js с eval, который запрещен в расширениях

  • EfficientNet — да тоже большая

Максимальный размер расширения Chrome — около 100MB. Этого катастрофически мало для современных нейросетей компьютерного зрения.

Решение 1: Собственные алгоритмы хеширования

Раз нейросети не помещаются, пришлось переписать на собственные алгоритмы. Я реализовал несколько методов хеширования изображений на основании питоньего https://pypi.org/project/ImageHash/:

Average Hash (aHash)

computeAverageHash(imageData) {     // Уменьшаем до 8x8 в оттенках серого     const grayPixels = this.convertToGrayscale8x8(imageData);          // Вычисляем среднее значение     const average = grayPixels.reduce((a, b) => a + b) / grayPixels.length;          // Создаем битовую строку     let hash = '';     for (let i = 0; i < grayPixels.length; i++) {         hash += grayPixels[i] > average ? '1' : '0';     }          return hash; } 

Difference Hash (dHash)

computeDifferenceHash(imageData) {     // Уменьшаем до 9x8 для сравнения соседних пикселей     const grayPixels = this.convertToGrayscale9x8(imageData);          let hash = '';     for (let row = 0; row < 8; row++) {         for (let col = 0; col < 8; col++) {             const left = grayPixels[row * 9 + col];             const right = grayPixels[row * 9 + col + 1];             hash += left > right ? '1' : '0';         }     }          return hash; } 

Perceptual Hash (pHash) с DCT

Самый сложный алгоритм — перцептивный хеш с дискретным косинусным преобразованием:

computePerceptualHash(imageData) {     const size = 32;     const grayPixels = this.convertToGrayscale(imageData, size);          // Применяем 2D DCT     const dctMatrix = this.computeDCT(grayPixels, size);          // Берем только низкие частоты (8x8)     const lowFreqs = this.extractLowFrequencies(dctMatrix, 8);          // Сравниваем с медианой     const median = this.calculateMedian(lowFreqs);          let hash = '';     for (let i = 0; i < lowFreqs.length; i++) {         hash += lowFreqs[i] > median ? '1' : '0';     }          return hash; }  computeDCT(pixels, size) {     const dct = new Array(size * size).fill(0);          for (let u = 0; u < size; u++) {         for (let v = 0; v < size; v++) {             let sum = 0;             for (let i = 0; i < size; i++) {                 for (let j = 0; j < size; j++) {                     sum += pixels[i * size + j] *                             Math.cos(((2 * i + 1) * u * Math.PI) / (2 * size)) *                            Math.cos(((2 * j + 1) * v * Math.PI) / (2 * size));                 }             }                          const cu = u === 0 ? 1 / Math.sqrt(2) : 1;             const cv = v === 0 ? 1 / Math.sqrt(2) : 1;             dct[u * size + v] = (1 / 4) * cu * cv * sum;         }     }          return dct; } 

Комбинированное сравнение

Для максимальной точности я объединил несколько методов и поставил им веса:

compareImages(fingerprint1, fingerprint2) {     const maxHashLength = 64; // Длина хеша          // Вычисляем схожесть для каждого типа хеша     const aHashSimilarity = 1 - (this.hammingDistance(fingerprint1.aHash, fingerprint2.aHash) / maxHashLength);     const dHashSimilarity = 1 - (this.hammingDistance(fingerprint1.dHash, fingerprint2.dHash) / maxHashLength);     const pHashSimilarity = 1 - (this.hammingDistance(fingerprint1.pHash, fingerprint2.pHash) / maxHashLength);          // Сравниваем цветовые гистограммы     const histogramSimilarity = this.compareHistograms(fingerprint1.colorHistogram, fingerprint2.colorHistogram);          // Взвешенная комбинация     const weights = {         aHash: 0.2,    // Точные дубликаты         dHash: 0.2,    // Обрезанные изображения         pHash: 0.3,    // Измененные изображения         histogram: 0.15, // Цветовое сходство         aspectRatio: 0.05 // Пропорции     };          const totalSimilarity =          aHashSimilarity * weights.aHash +         dHashSimilarity * weights.dHash +         pHashSimilarity * weights.pHash +         histogramSimilarity * weights.histogram;          return Math.max(0, Math.min(1, totalSimilarity)); } 

Эволюция архитектуры

Этап 1: Python

# Первоначальная версия на Python @app.route('/analyze', methods=['POST']) def analyze_photos():     photos = request.json['photos']          # Использовали ImageHash и PIL     from imagehash import phash, dhash, average_hash          hashes = []     for photo in photos:         img = Image.open(io.BytesIO(base64.b64decode(photo['data'])))         hashes.append({             'phash': str(phash(img)),             'dhash': str(dhash(img)),             'ahash': str(average_hash(img))         })          return find_similar_groups(hashes) 

Этап 2: Полностью клиентское решение

В итоге я понял, что можно обойтись вообще без сервера — весь алгоритм работает в браузере:

// Создаем сессию прямо в расширении class FrontendSessionManager {     constructor() {         this.imageMatcher = new ImageMatcher();         this.sessions = {};     }      async analyzeSession(sessionId, similarityThreshold = 75) {         const session = this.sessions[sessionId];         const comparisons = [];                  // Сравниваем все пары изображений         for (let i = 0; i < session.images.length; i++) {             for (let j = i + 1; j < session.images.length; j++) {                 const similarity = this.imageMatcher.compareImages(                     session.imageHashes[session.images[i].id],                     session.imageHashes[session.images[j].id]                 );                                  if (similarity.overall >= similarityThreshold / 100) {                     comparisons.push({                         image1: session.images[i].id,                         image2: session.images[j].id,                         similarity: similarity.overall                     });                 }             }         }                  return this.groupSimilarImages(comparisons);     } } 

Этап 3: Добавление AI, ведь это модно

И все же я нашел, как можно использовать легковесный AI. Можно фильтровать получившиеся группы похожих фотографий и выбирать те, где больше улыбок. Ведь нам всем нравятся улыбающиеся лица. Нашлись также 2 модельки, которые удалось включить в расширение от face-api.js

  • tiny_face_detector_model — легковесная модель для детекции лиц

  • face_expression_model — модель для анализа эмоций на лицах

// Модели поставляются в виде шардов и манифестов /models/   ├── tiny_face_detector_model-shard1   ├── tiny_face_detector_model-weights_manifest.json   ├── face_expression_model-shard1   └── face_expression_model-weights_manifest.json 

Они весят меньше мегабайта и прекрасно работают!

async analyzeGroupForFaces(imageItems) {     const faceAnalysis = [];          for (const item of imageItems) {         const canvas = await this.createCanvasFromImageData(item.imageData);         const detections = await faceapi             .detectAllFaces(canvas, new faceapi.TinyFaceDetectorOptions())             .withFaceExpressions();                  // Анализируем качество лиц: количество, размер, эмоции         const faceQuality = this.calculateFaceQuality(detections);         faceAnalysis.push({ item, faceQuality, detections });     }          // Сортируем по качеству лиц и выбираем лучшие     return faceAnalysis.sort((a, b) => b.faceQuality - a.faceQuality); } 

Все заработало и собралось, одобрено гуглом и уже есть в Chrome Extension Store. Edge Store ожидает публикации.

Результат и выводы

В итоге получилось расширение, которое:

  1. Работает полностью автономно — не требует серверов или установки ПО

  2. Эффективно захватывает изображения через хитрую систему скриншотов

  3. Быстро сравнивает фотографии с помощью быстрых алгоритмов хеширования

  4. Находит разные типы дубликатов: точные копии, обрезанные версии, слегка измененные фото

Ключевые технические открытия:

  • Нейросети пока не готовы для браузерных расширений — они слишком тяжелые

  • Классические алгоритмы хеширования все еще достаточно хороши, если их умело комбинировать

  • Пакетная обработка скриншотов позволяет эффективно получать изображения из защищенных веб-приложений

Что не получилось:

  • Полноценное распознавание лиц (слишком тяжелые модели)

  • Семантическое сравнение изображений (нужны большие нейросети, которые еще надо как-то запихнуть в браузер)

Создание этого расширения показало, что веб-технологии еще не готовы для серьезных задач компьютерного зрения. Но креативный подход и понимание ограничений позволяют создать вполне работоспособные решения уже сегодня.

Ссылка на расширение Google Photos Duplicate Remover: https://chromewebstore.google.com/detail/google-photos-duplicate-r/baafhiocpgpaahonnkhkhbkggbhmefld

Open-source библиотека для сравнения изображений на чистом JS: https://github.com/ZonD80/image-matcher-js

Для хабровчан 100% скидка первым 100 пользователям по промокоду HABR


Если у вас есть вопросы о технических деталях или идеи по улучшению алгоритмов — пишите в комментариях!


ссылка на оригинал статьи https://habr.com/ru/articles/920706/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *