Как я создавал расширение для поиска дубликатов и похожих фоток в 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 ожидает публикации.
Результат и выводы
В итоге получилось расширение, которое:
Работает полностью автономно — не требует серверов или установки ПО
Эффективно захватывает изображения через хитрую систему скриншотов
Быстро сравнивает фотографии с помощью быстрых алгоритмов хеширования
Находит разные типы дубликатов: точные копии, обрезанные версии, слегка измененные фото
Ключевые технические открытия:
Нейросети пока не готовы для браузерных расширений — они слишком тяжелые
Классические алгоритмы хеширования все еще достаточно хороши, если их умело комбинировать
Пакетная обработка скриншотов позволяет эффективно получать изображения из защищенных веб-приложений
Что не получилось:
Полноценное распознавание лиц (слишком тяжелые модели)
Семантическое сравнение изображений (нужны большие нейросети, которые еще надо как-то запихнуть в браузер)
Создание этого расширения показало, что веб-технологии еще не готовы для серьезных задач компьютерного зрения. Но креативный подход и понимание ограничений позволяют создать вполне работоспособные решения уже сегодня.
Ссылка на расширение 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
Если у вас есть вопросы о технических деталях или идеи по улучшению алгоритмов — пишите в комментариях!
