Случалось ли вам когда-нибудь тонуть в море неупорядоченных фотографий? Лично я не раз с этим сталкивалась!

Исследуя возможности генеративного искусственного интеллекта, я экспериментировала с приложением для поиска фотографий и успешно нашла необходимые изображения. И вдруг меня осенило: что, если в папке окажутся тысячи фотографий?

Вопрос далеко не тривиальный — представьте, сколько фото своих пушистых питомцев ежедневно делают ваши друзья. Это открытие подтолкнуло меня изучить, можно ли работать с большими коллекциями изображений без перегрузки системных ресурсов.

Функции оригинального приложения: незамысловатое начало

Приложение начинало работу с двух ключевых функций:

  1. Загрузка всех изображений из выбранной папки.
  1. Поиск фотографий по контенту. Отправка изображений на бэкенд для фильтрации и возврата наиболее точных совпадений.

Вот сокращенный код, на котором все это работает:

// Загрузка всех изображений из выбранной папки
async handleFolderSelect() {
    const dirHandle = await window.showDirectoryPicker();
    const images = [];

    // Загрузка всех изображений сразу
    for await (const entry of dirHandle.values()) {
        if (entry.kind === 'file' && entry.name.match(/\.(jpg|jpeg|png|gif)$/i)) {
            const file = await entry.getFile();
            const blobUrl = URL.createObjectURL(file);
            images.push({
                path: blobUrl,
                name: entry.name,
                handle: entry
            });
        }
    }
    this.images = images;
    this.allImages = images;
}
// Поиск изображений по содержанию
async searchImages() {
    // Создание объекта FormData для отправки всех изображений
    const formData = new FormData();
    formData.append('query', this.searchQuery);
    
    // Добавление всех текущих изображений в запрос
    for (const image of this.images) {
        if (image.handle) {
            // Получение фактического файла из handle
            const file = await image.handle.getFile();
            formData.append('images[]', file, image.name);
        }
    }

    const response = await fetch('/search', {
        method: 'POST', 
        body: formData
    });
    
    // Обновление изображений результатами поиска
    this.images = ...
}

Это приложение прекрасно функционирует с небольшими папками, однако при работе с тысячами фотографий возникают сбои.

Проблемы с большими папками

Работа с папкой, содержащей более 1000 изображений, — непростая задача. 

Вот основные проблемы:

  • Снижение производительности. Загрузка всех изображений одновременно может замедлить работу всех систем, от браузера до сервера.
  • Перегрузка памяти. Создание URL-адресов blob-объектов для 1000 изображений и отправка их всех на сервер одним запросом может привести к перегрузке памяти системы.
  • Ухудшение пользовательского опыта. Длительное время ожидания и неотзывчивое приложение вызывают разочарование у пользователей, которым нужна обратная связь и интерактивность даже при работе с большими массивами данных.

Очевидно, что требовался оптимизированный подход.

Пакетная обработка: более интеллектуальная стратегия

Первый шаг к масштабируемости — пакетная обработка.

Преимущества пакетной обработки:

  • Повышение уровня управления памятью: меньшие партии означают меньшее количество ресурсов, потребляемых одновременно.
  • Эффективность работы сети: небольшие HTTP-запросы реже прерываются по времени, и их легче повторить в случае неудачи.
  • Отзывчивый сервер: обработка небольших пакетов снижает риск возникновения ошибок, связанных с нехваткой памяти (OOM).

Обработка изображений небольшими партиями — скажем, от 50 до 100 за раз, — позволяет более эффективно управлять ресурсами.

Вот как это было реализовано:

async searchImages() {
    const BATCH_SIZE = 50;
    let allResults = [];
    for (let i = 0; i < this.allImages.length; i += BATCH_SIZE) {
        const batch = this.allImages.slice(i, i + BATCH_SIZE);
        const formData = new FormData();
        formData.append('query', this.searchQuery);
        for (const image of batch) {
            const file = await image.handle.getFile();
            formData.append('images[]', file, image.name);
        }
        const response = await fetch('/search', { method: 'POST', body: formData });
        const data = await response.json();
        allResults = allResults.concat(data.results);
    }
    this.images = allResults.sort((a, b) => b.score - a.score);
}

Пакетная обработка обеспечивает столь необходимый контроль над использованием памяти и нагрузкой на сервер, но, похоже, не решает некоторые проблемы пользовательского интерфейса:

Улучшается ли пользовательский опыт? Обеспечивается ли немедленная обратная связь?

Чрезвычайно медленная загрузка при большом количестве фотографий — скриншот автора

Прогрессивная загрузка: мгновенная обратная связь

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

Преимущества прогрессивной загрузки

  • Улучшение пользовательского опыта: результаты появляются постепенно, что позволяет пользователям оставаться вовлеченными.
  • Низкое потребление памяти: в каждый момент времени загружается небольшое количество изображений.
  • Отзывчивый интерфейс: обновления происходят динамически, что позволяет избежать длительных задержек.

Вот как это работает:

async searchImages() {
    const CHUNK_SIZE = 10;
    this.isLoading = true;
    this.images = [];
    try {
        for (let i = 0; i < this.allImages.length; i += CHUNK_SIZE) {
            const chunk = this.allImages.slice(i, i + CHUNK_SIZE);
            const results = await this.processImageChunk(chunk);
            this.images = [...this.images, ...results].sort((a, b) => b.score - a.score);
            await new Promise(resolve => setTimeout(resolve, 0)); // Let UI update
        }
    } finally {
        this.isLoading = false;
    }
}

Этот метод позволяет найти оптимальный баланс между производительностью и удовлетворенностью пользователей, особенно при работе с большими коллекциями.

Изображения загружались быстрее, чем я успевала сделать скриншот процесса загрузки

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

Как будет работать приложение, когда количество результатов поиска будет исчисляться тысячами?

Можно ли обеспечить плавное взаимодействие пользовательского интерфейса с таким большим набором данных?

Как наиболее эффективно отобразить тысячи изображений, не перегружая браузер?

Отображение на фронтенде: бесконечная прокрутка

Когда дело доходит до отображения больших коллекций изображений в веб-интерфейсе, ленивая загрузка в сочетании с бесконечной прокруткой предлагает элегантное решение. Загружая изображения только тогда, когда они вот-вот появятся в области просмотра, можно сэкономить память и повысить производительность.

Вот как я использовала Intersection Observer для ленивой загрузки:

mounted() {
    this.observer = new IntersectionObserver(
        (entries) => {
            entries.forEach(async entry => {
                if (entry.isIntersecting) {
                    const image = entry.target.__vue_data__;
                    if (image && !image.path) {
                        const file = await image.handle.getFile();
                        image.path = URL.createObjectURL(file);
                    }
                }
            });
        },
        { root: null, rootMargin: '50px', threshold: 0.1 }
    );
}

beforeUnmount() {
    // Удаление всех наблюдателей
    this.unobserveImages();
    // Очистка blob-URL
    this.clearBlobUrls();
    // Отключение наблюдателя
    this.observer.disconnect();
}

Эта реализация, основанная на сочетании методов HTML и JavaScript, позволяет:

  • загружать изображения только тогда, когда они становятся видимыми в области просмотра;
  • использовать заполнители для незагруженных изображений;
  • предварительно загружать изображения немного раньше, чем они становятся видимыми;
  • правильно обработать очистку компонентов.

При такой настройке изображения загружаются вовремя, пока пользователь прокручивает галерею, обеспечивая плавный просмотр.

Выбор подхода

Вот краткое описание наиболее подходящих вариантов использования:

Пакетная обработка идеальна, когда:

  • точность поиска является приоритетом;
  • работа ведется с небольшими коллекциями (<500 изображений);
  • необходима точная сортировка результатов;
  • обработка на стороне сервера имеет большее значение.

Прогрессивная загрузка подходит для:

  • работы с очень большими коллекциями (1000+ изображений);
  • обеспечения немедленной обратной связи;
  • создания интерфейса, ориентированного на просмотр.

Бесконечная прокрутка отлично справится с такими задачами, как:

  • автоматическая обработка ленивой загрузки по мере прокрутки;
  • обеспечение бесперебойной работы.

Отметим, что в выборе стратегий работы с большими коллекциями файлов нет ничего правильного или неправильного. Эти подходы не являются взаимоисключающими — их можно комбинировать, чтобы использовать сильные стороны каждой стратегии.

Преимущества оптимизации фронтенда

Стратегический подход, ориентированный на фронтенд, дает значительные преимущества по сравнению с массовой загрузкой всего на бэкенд. Ограничение объема данных, отправляемых на сервер, позволяет:

  • сократить сетевой трафик;
  • снизить нагрузку на сервер;
  • повысить контроль над использованием памяти на обеих сторонах;
  • ускорить первоначальную загрузку;
  • расширить возможности масштабируемости.

Эта оптимизация особенно важна для приложений с большим количеством изображений, где размер файлов может значительно повлиять на производительность.

Окончательный результат

Финальная оптимизированная версия выглядит следующим образом:

async handleFolderSelect() {
    try {
        const dirHandle = await window.showDirectoryPicker();
        this.isLoading = true;
        this.currentFolder = dirHandle.name;
        this.allImages = [];
        this.images = [];

        // Очистка существующих URL blob'ов и удаление кэша изображений
        this.clearBlobUrls();
        this.unobserveImages();

        // Постепенная обработка файлов
        for await (const entry of dirHandle.values()) {
            if (entry.kind === 'file' && entry.name.match(/\.(jpg|jpeg|png|gif)$/i)) {
                // Первоначальное сохранение handle без создания URL blob'а
                const imageData = {
                    name: entry.name,
                    handle: entry,
                    path: null // Будет создано по запросу
                };
                this.allImages.push(imageData);
            }
        }

        // Загрузка первой партии изображений
        const initialBatch = this.allImages.slice(0, 50);
        for (const imageData of initialBatch) {
            const file = await imageData.handle.getFile();
            imageData.path = URL.createObjectURL(file);
        }

        // Обновление дисплея
        this.images = this.allImages;

        // Ожидание обновления DOM
        await this.$nextTick();

        // Начинаем наблюдать за изображениями
        this.observeImages();

        // Сохранение handle каталога для последующего доступа
        this.$root.dirHandle = dirHandle;

    } catch (error) {
        console.error('Error:', error);
        alert('Error accessing folder:' + error.message);
    } finally {
        this.isLoading = false;
    }
},

async searchImages() {
    const BATCH_SIZE = 50;
    this.isLoading = true;

    try {
        let allResults = [];
        const totalBatches = Math.ceil(this.allImages.length / BATCH_SIZE);

        // Обработка изображений партиями
        for (let i = 0; i < this.allImages.length; i += BATCH_SIZE) {
            const currentBatch = Math.floor(i / BATCH_SIZE) + 1;
            this.progress = `Processing batch ${currentBatch}/${totalBatches}`;

            console.log('*** progress = ', this.progress);
            const batch = this.allImages.slice(i, i + BATCH_SIZE);
            const formData = new FormData();
            formData.append('query', this.searchQuery);

            for (const image of batch) {
                if (image.handle) {
                    const file = await image.handle.getFile();
                    formData.append('images[]', file, image.name);
                }
            }

            const batchResults = await fetch('/search', {
                method: 'POST',
                body: formData
            }).then(r => r.json());

            const curResults = batchResults.results.map(result => {
                const existingImage = this.allImages.find(img => img.name === result.name);
                const path = existingImage ? existingImage.path : URL.createObjectURL(new Blob([result.image_data], { type: 'image/jpeg' }));
                return {
                    path: path,
                    name: result.name,
                    score: result.score,
                    handle: existingImage ? existingImage.handle : null
                };
            });

            // Показ промежуточных результатов во время продолжения обработки
            allResults = [...allResults, ...curResults];
            this.images = allResults.sort((a, b) => b.score - a.score);
        }

    } catch (error) {
        console.error('Search error:', error);
        alert(error.message);
    } finally {
        this.isLoading = false;
    }
},

Заключительные мысли

Это исследование при создании приложения для поиска изображений научило меня многому!

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

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Jenny Ouyang: Quick Search of Hidden Photos With Optimization Approaches

Предыдущая статьяКак сделать зернистый градиент на CSS
Следующая статьяcin.ignore() на C++