Как я создавал расширение для поиска дубликатов и похожих фоток в 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
Если у вас есть вопросы о технических деталях или идеи по улучшению алгоритмов — пишите в комментариях!
Комментарии (4)
hypocrites
22.06.2025 11:32>muh privacy
Так из-за этого маразма с приватностью у вас первая проблема, где CORS и нельзя. Давно пора отменить эту принудиловку и сделать всё опционально, а не поддерживать.
DaneSoul
Если не ограничиваться рамками плагина для браузера, то уже много лет весьма эффективный поиск дубликатов реализован в кросс платформенном портабельном менеджере изображений XnViewMP.
Причем там можно искать не только точные дубликаты (даже разного разрешения), но и похожие изображения, отличающиеся незначительными деталями.
ZonD80 Автор
Спачибо за наводку, я посмотрю