Как я создавал расширение для поиска дубликатов и похожих фоток в 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


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

Комментарии (4)


  1. DaneSoul
    22.06.2025 11:32

    Если не ограничиваться рамками плагина для браузера, то уже много лет весьма эффективный поиск дубликатов реализован в кросс платформенном портабельном менеджере изображений XnViewMP.
    Причем там можно искать не только точные дубликаты (даже разного разрешения), но и похожие изображения, отличающиеся незначительными деталями.


    1. ZonD80 Автор
      22.06.2025 11:32

      Спачибо за наводку, я посмотрю


  1. hypocrites
    22.06.2025 11:32

    >muh privacy

    Так из-за этого маразма с приватностью у вас первая проблема, где CORS и нельзя. Давно пора отменить эту принудиловку и сделать всё опционально, а не поддерживать.


  1. BOSTONBOY
    22.06.2025 11:32

    Picasa так умеет