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

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

Для этого нам понадобится: мультимодальная модель которая умеет описывать изображение, векторная БД для хранения и поиска по текстовому запросу, и embedding модель для генерации эмбеддингов из текста для векторной БД. Фактически мы просто "опишем" каждое фото в определенной папке и запишем информацию в векторную БД, для последующего поиска. Сам фото архив физически не будет никак изменен. Даже если у вас огромный фото архив на медленных HDD, мы не будем производить никаких изменений в файловой системе, тем самым нет риска как-то испортить сам архив. "Описание" можно производить бесконечное количество раз.

Примерная схема
Примерная схема

Знакомьтесь, ImageBrowserAI

Для начала, необходимо сделать более менее удобный интерфейс по типу браузера, но для изображений. И фоточки посмотреть, и дерево папок вывести. Интерфейс в процессе разработки неоднократно менялся. В итоге, самым удобным вариантом оказалось окно разделенное на три части: слева дерево папок, посередине - превью изображений, справа - описание. Там же: вывод полного текущего пути, и поле для поиска по текстовому описанию, с указанием "порога поиска". Что такое "порог поиска" - об этом, дальше.

ImageBrowserAI, готовый вариант

Вводные данные

Для реализации текстового поиска по фото необходимо:

  • Установить LM Studio (или любой другой сервер позволяющий загрузить ИИ модели через OpenAI API)

  • Скачать (в LM Studio) мультимодальную модель Qwen3-VL 30B, и embedding модель nomic-embed-text-v2-moe-GGUF.

  • Скачать, распаковать, и запустить Qdrant-x86_64-pc-windows-msvc.zip. Всего один файл qdrant.exe. Для работы создает файлы и папки там же где находится.

Как настраивать LM Studio в качестве сервера ИИ моделей можно прочитать в статье Открываем RAG и интернет для LM Studio (см. "Включаем сервер моделей в LM Studio").

Реализация

Итак. Устанавливаем через NuGet пакет OpenAI. Создаем клиент descriptionOpenAiClient.

Создаем клиент
var DescriptionApiKey = new System.ClientModel.ApiKeyCredential("ApiKey");
var descriptionOpenAiClient = new OpenAIClient(DescriptionApiKey, new OpenAIClientOptions
{
    //такой эндпоинт у локально установленного LM Studio
    Endpoint = new Uri("http://localhost:1234/v1/"),
    //таймаут
    NetworkTimeout = TimeSpan.FromSeconds(30)
});

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

Отправляем файл для описания
// byte[] image - массив байт изображения в формате jpeg
var imageContent = ChatMessageContentPart.CreateImagePart(new BinaryData(image), "image/jpeg");
var textContent = ChatMessageContentPart.CreateTextPart("Please describe image shortests in russian");

var chatMessages = new[]
{
    new UserChatMessage(imageContent, textContent)
};

var response = await descriptionOpenAiClient.GetChatClient("qwen/qwen3-vl-30b").CompleteChatAsync(chatMessages);
return response.Value.Content[0].Text;

Таким образом мы формируем запрос в виде массива изображения и промпта, в котором собственно и просим модель описать фото в таком формате, который нам необходим. В нашем случае что то типа такого: "Please describe image shortests in russian".

Но прежде чем передать модели содержимое фото, необходимо его уменьшить, иначе модель может такое не переварить. Достаточно уменьшить до 256, ну или 512 пикселей для лучшего распознавания текста на фото. Уменьшать лучше с учетом масштабирования.

Векторная БД. Qdrant

Описание, выданное моделью, необходимо сохранить в векторную БД. Для работы с ней необходим клиент. Устанавливаем через NuGet пакет Qdrant.Client.

Подключаемся к БД через компонент, создаем коллекцию, в которой будем хранить описание фото.

Создаем коллекцию
// подключаемся к БД
var qdrantClient = new QdrantClient(address: new Uri("http://localhost:6333"), apiKey: "-");

// создаем коллекцию если еще не создана
qdrantClient.CreateCollectionAsync(
        collectionName: "collection_name",
        vectorsConfig: new VectorParams { Size = 768, Distance = Distance.Dot },
        // Дополнительные параметры конфигурации индекса
        hnswConfig: new HnswConfigDiff
        {
            M = 64,             // Количество связей на узел (увеличивает точность и память)
            EfConstruct = 100,  // Размер списка соседей во время построения индекса (влияет на качество индексации)
            OnDisk = true       // Сохранение индекса на диск для экономии RAM при больших объемах
        }
 );

Что это за магическое значение параметра Size = 768? Это размер вектора который выдает embedding модель. У каждой модели она разная. Необходимо этот параметр искать в карточке модели. Если указать размер в настройках коллекции не такой, какую выдает модель - сохранить описание в БД не получится.

Теперь можно сохранить описание фото.

Сохраняем описание в Qdrant
// генерируем эмбеддинги из текстового описания
var vectors = await GenerateVectorAsync("описание фото");

// формируем структуру для сохранения
var point = new PointStruct
{
    // уникальный uid записи
    Id = new PointId { Uuid = GenerateGuidFromString(imagePath).ToString() },
    // вектора
    Vectors = vectors.ToArray(),
    // а это - полезная нагрузка
    // здесь мы указываем имя файла, текстовое описание, и т.д.
    Payload =
    {
        ["image_path"] = imagePath,
        ["description"] = description,
        ["file_size"] = fileInfo.Length,
        ["creation_time"] = fileInfo.CreationTimeUtc.ToString("o"),
        ["last_write_time"] = fileInfo.LastWriteTimeUtc.ToString("o")
    }
};

// сохраняем
await qdrantClient.UpsertAsync("collection_name", new[] { point });

Может так случиться, что описание одного файла производится параллельно, и в БД может вставиться две или более записи об одном файле. Что бы этого избежать, необходимо уникальный идентификатор Uuid формировать на основе полного имени файла. Для этого здесь используется функция GenerateGuidFromString.

Что бы получить информацию из Qdrant по имени файла, достаточно указать имя файла, которое мы сохраняли в полезной нагрузке (Payload) с именем поля image_path.

Получаем информацию по имени файла
// ищем по имени файла
var points = await qdrantClient.ScrollAsync(
    collectionName: "collection_name",
    filter: new Filter
    {
        Must = { new Condition { Field = new FieldCondition { Key = "image_path", Match = new Match { Text = fullImagePath } } } }
    },
    limit: 1 // Нам нужен только один результат
);

var firstPoint = points.Result.FirstOrDefault();

// вся информация о файле
filename = firstPoint.Payload["image_path"].StringValue,
description = firstPoint.Payload["description"].StringValue,
creation = firstPoint.Payload["creation_time"].StringValue,
lastWrite = firstPoint.Payload["last_write_time"].StringValue,
size = firstPoint.Payload["file_size"].IntegerValue

Ну а теперь, поиск по текстовому описанию пользователя.

Поиск по текстовому запросу
// генерируем эмбеддинги из текста
var data = (await GenerateVectorAsync("поле с ромашками")).ToArray();

// поиск
var points = await qdrantClient.QueryAsync(
    collectionName: "collection_name",
    query: data,
    scoreThreshold: 0.2f //порог
);

Когда мы ищем в векторной БД какую то информацию, нам возвращается результат, близкий к запросу. Чем ближе результат к пользовательскому запросу, тем больше у него Score - некий индикатор "близости" результата к запросу. Чем больше - тем лучше. Результат ниже 0.2f можно проигнорировать. Параметр scoreThreshold - как раз тот самый "порог поиска", ниже которого векторная БД не будет выдавать результат, т.к. он нам не нужен.

Дополнительно, для ускорения поиска в Qdrant желательно добавить индекс.

Создание индекса
await qdrantClient.CreatePayloadIndexAsync(
      collectionName: "collection_name",
      fieldName: "image_path"
);

Весь функционал для удобства находится в классе ImageProcessor.

ImageProcessor.cs
//https://github.com/virex-84

using OpenAI;
using OpenAI.Chat;
using Qdrant.Client;
using Qdrant.Client.Grpc;
using System.Drawing.Imaging;
using System.Security.Cryptography;
using System.Text;

namespace ImageBrowserAI
{
    /// <summary>
    /// Класс для поиска, описания и сохранения фото в Qdrant
    /// </summary>
    public class ImageProcessor
    {
        public string Prompt { get; set; } = "Please describe the following image.";

        private readonly Options _options;
        private readonly SemaphoreSlim _semaphore;
        private readonly QdrantClient _qdrantClient;
        private readonly OpenAIClient _descriptionOpenAiClient;
        private readonly OpenAIClient _embeddOpenAiClient;

        public event ImageEventHandler ProcessImageComplete;
        public delegate Task ImageEventHandler(object? sender, string filename);

        public event EventHandler<ScanProgressEventHandler> ScanProgressEvent;
        public class ScanProgressEventHandler : EventArgs
        {
            public int progress { get; }
            public int max { get; }
            public ScanProgressEventHandler(int progress, int max) { this.progress = progress; this.max = max; }
        }

        /// <summary>
        /// Результат поиска
        /// </summary>
        public class SearchResult
        {
            public string filename { get; set; }
            public string description { get; set; }
            public long size { get; set; }
            public string creation { get; set; }
            public string lastWrite { get; set; }
            public float score { get; set; }
        }

        /// <summary>
        /// Настройки
        /// </summary>
        public class Options
        {
            public string DescriptionModelUri { get; set; } = "";
            public string DescriptionModelName { get; set; } = "";
            public string DescriptionModelApiKey { get; set; } = "";
            public double DescriptionModelTimeOutSec { get; set; } = 10;

            public string EmbeddModelUri { get; set; } = "";
            public string EmbeddModelName { get; set; } = "";
            public string EmbeddModelApiKey { get; set; } = "";
            public double EmbeddModelTimeOutSec { get; set; } = 10;
            public Size MaxImageSize { get; set; } = new Size(256, 256);

            public Uri QDrantUri { get; set; } = new Uri("http://localhost:6333");
            public string QDrantApiKey { get; set; } = "";
            public string QDrantCollectionName { get; set; } = "";
            public ulong QDrantVectorSize { get; set; } = 768;
        }

        public ImageProcessor(int maxConcurrency, Options options, string prompt)
        {
            _options = options ?? throw new ArgumentNullException(nameof(options));
            _semaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency);
            Prompt = prompt ?? Prompt;

            // Initialize QDrant client
            _qdrantClient = new QdrantClient(address: _options.QDrantUri, apiKey: _options.QDrantApiKey);

            _qdrantClient.CreateCollectionAsync(
                    _options.QDrantCollectionName,
                    new VectorParams { Size = _options.QDrantVectorSize, Distance = Distance.Dot },
                    // Дополнительные параметры конфигурации индекса
                    hnswConfig: new HnswConfigDiff
                    {
                        M = 64,             // Количество связей на узел (увеличивает точность и память)
                        EfConstruct = 100,  // Размер списка соседей во время построения индекса (влияет на качество индексации)
                        OnDisk = true       // Сохранение индекса на диск для экономии RAM при больших объемах
                    }
                );

            // Initialize OpenAI client
            var DescriptionApiKey = new System.ClientModel.ApiKeyCredential(_options.DescriptionModelApiKey);
            _descriptionOpenAiClient = new OpenAIClient(DescriptionApiKey, new OpenAIClientOptions
            {
                Endpoint = new Uri(_options.DescriptionModelUri),
                NetworkTimeout = TimeSpan.FromSeconds(_options.DescriptionModelTimeOutSec)
            });

            var EmbeddApiKey = new System.ClientModel.ApiKeyCredential(_options.EmbeddModelApiKey);
            _embeddOpenAiClient = new OpenAIClient(EmbeddApiKey, new OpenAIClientOptions
            {
                Endpoint = new Uri(_options.EmbeddModelUri),
                NetworkTimeout = TimeSpan.FromSeconds(_options.EmbeddModelTimeOutSec)
            });
        }

        /// <summary>
        /// Создаем индекс
        /// </summary>
        public async Task CreateIndex(string fieldName)
        {
            await _qdrantClient.CreatePayloadIndexAsync(
                collectionName: _options.QDrantCollectionName,
                fieldName: fieldName
            /*
             * https://qdrant.tech/documentation/concepts/indexing/
            schemaType: PayloadSchemaType.Text, //полнотекстовый поиск
            indexParams: new PayloadIndexParams
            {
                IntegerIndexParams = new()
                {
                    Lookup = true, //поддерживает прямой поиск с помощью фильтров Match.
                    Range = false
                }
            }
            */
            );
        }

        /// <summary>
        /// Сканирует одну директорию параллельно, не блокируя вызывающий поток.
        /// </summary>
        public void Scan(string path)
        {
            if (string.IsNullOrEmpty(path))
                throw new ArgumentException("Path cannot be null or empty", nameof(path));

            if (!Directory.Exists(path))
                throw new DirectoryNotFoundException($"Directory not found: {path}");

            // Запускаем обработку в фоновом режиме ("fire-and-forget")
            _ = Task.Run(async () =>
            {
                await _semaphore.WaitAsync();
                try
                {
                    var imageFiles = Directory.GetFiles(path, "*.*", SearchOption.TopDirectoryOnly)
                        .Where(file => IsImageFile(file))
                        .ToList();

                    var tasks = imageFiles.Select(file => ProcessImageAsync(file)).ToArray();
                    await Task.WhenAll(tasks);
                }
                finally
                {
                    _semaphore.Release();
                }
            });
        }

        /// <summary>
        /// Рекурсивно сканирует директорию и все поддиректории, обрабатывая изображения последовательно.
        /// </summary>
        public async Task Scan2(string path, bool rescan, CancellationToken token)
        {
            if (string.IsNullOrEmpty(path))
                throw new ArgumentException("Path cannot be null or empty", nameof(path));

            if (!Directory.Exists(path))
                throw new DirectoryNotFoundException($"Directory not found: {path}");

            // 1. Получаем плоский список всех изображений один раз
            var allImageFiles = Directory.EnumerateFiles(path, "*.*", SearchOption.AllDirectories)
                .Where(file => IsImageFile(file))
                .ToList();

            int totalFiles = allImageFiles.Count;
            int processedFiles = 0;

            // 2. Сообщаем о начале и общем количестве
            ScanProgressEvent?.Invoke(this, new ScanProgressEventHandler(0, totalFiles));

            // 3. Последовательно обрабатываем каждый файл
            foreach (var imageFile in allImageFiles)
            {
                token.ThrowIfCancellationRequested();

                try
                {
                    await ProcessImageAsync(imageFile, rescan);
                }
                catch
                {
                    // Пробрасываем исключение дальше, чтобы сохранить стек вызовов
                    throw;
                }

                processedFiles++;
                // 4. Обновляем прогресс
                ScanProgressEvent?.Invoke(this, new ScanProgressEventHandler(processedFiles, totalFiles));
            }
        }

        /// <summary>
        /// Загружает фото в визуальную модель, сохраняет полученное описание в Qdrant
        /// </summary>
        private async Task ProcessImageAsync(string imagePath, bool rewrite = false)
        {
            if (string.IsNullOrEmpty(imagePath)) return;

            try
            {
                // Проверяем, существует ли уже описание
                if (!rewrite)
                {
                    var existingDesc = await GetDescription(imagePath);
                    if (existingDesc != null) return;
                }

                // Уменьшаем избражение
                var fileInfo = new FileInfo(imagePath);
                using var resizedImage = ResizeImage(imagePath, _options.MaxImageSize);
                using var stream = new MemoryStream();
                resizedImage.Save(stream, ImageFormat.Jpeg);
                var imageBytes = stream.ToArray();

                // Получаем описание
                var description = await GetImageDescriptionAsync(imageBytes);

                // Сохраняем описание
                await SaveDescriptionToQDrantAsync(imagePath, description, fileInfo);

                ProcessImageComplete?.Invoke(this, imagePath);
            }
            catch (Exception ex)
            {
                // Логируем ошибку, но продолжаем обработку других изображений
                // Console.WriteLine($"Error processing image {imagePath}: {ex.Message}");
                ProcessImageComplete?.Invoke(this, imagePath);
            }
        }

        /// <summary>
        /// Уменьшение изображения
        /// </summary>
        private Bitmap ResizeImage(string imagePath, Size maxSize)
        {
            using var originalImage = System.Drawing.Image.FromFile(imagePath);
            var ratioX = (double)maxSize.Width / originalImage.Width;
            var ratioY = (double)maxSize.Height / originalImage.Height;
            var ratio = Math.Min(ratioX, ratioY);

            var newWidth = (int)(originalImage.Width * ratio);
            var newHeight = (int)(originalImage.Height * ratio);

            var resizedImage = new Bitmap(newWidth, newHeight);
            using (var graphics = Graphics.FromImage(resizedImage))
            {
                graphics.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
                graphics.DrawImage(originalImage, 0, 0, newWidth, newHeight);
            }
            return resizedImage;
        }

        /// <summary>
        /// Передаем визуальной модели изображение, получаем описание
        /// </summary>
        private async Task<string> GetImageDescriptionAsync(byte[] image)
        {
            var imageContent = ChatMessageContentPart.CreateImagePart(new BinaryData(image), "image/jpeg");
            var textContent = ChatMessageContentPart.CreateTextPart(Prompt);

            var chatMessages = new[]
            {
                new UserChatMessage(imageContent, textContent)
            };

            var response = await _descriptionOpenAiClient.GetChatClient(_options.DescriptionModelName).CompleteChatAsync(chatMessages);
            return response.Value.Content[0].Text;
        }

        /// <summary>
        /// Сохраняем описание в Qdrant
        /// </summary>
        private async Task SaveDescriptionToQDrantAsync(string imagePath, string description, FileInfo fileInfo)
        {
            var vectors = await GenerateVectorAsync(description);

            var point = new PointStruct
            {
                Id = new PointId { Uuid = GenerateGuidFromString(imagePath).ToString() },
                Vectors = vectors.ToArray(),
                Payload =
                {
                    ["image_path"] = imagePath,
                    ["description"] = description,
                    ["file_size"] = fileInfo.Length,
                    ["creation_time"] = fileInfo.CreationTimeUtc.ToString("o"),
                    ["last_write_time"] = fileInfo.LastWriteTimeUtc.ToString("o")
                }
            };

            await _qdrantClient.UpsertAsync(_options.QDrantCollectionName, new[] { point });
        }

        /// <summary>
        /// Генерация уникального идентификатора на основе имени файла
        /// </summary>
        public static Guid GenerateGuidFromString(string input)
        {
            if (string.IsNullOrEmpty(input))
                throw new ArgumentNullException(nameof(input));

            byte[] bytes = Encoding.UTF8.GetBytes(input);
            byte[] hash;
            using (SHA1 sha1 = SHA1.Create())
            {
                hash = sha1.ComputeHash(bytes);
            }

            byte[] guidBytes = new byte[16];
            Array.Copy(hash, 0, guidBytes, 0, 16);
            guidBytes[6] = (byte)((guidBytes[6] & 0x0F) | 0x50);
            guidBytes[8] = (byte)((guidBytes[8] & 0x3F) | 0x80);

            return new Guid(guidBytes);
        }

        /// <summary>
        /// Генерация эмбеддингов из текста
        /// </summary>
        private async Task<ReadOnlyMemory<float>> GenerateVectorAsync(string text)
        {
            var response = await _embeddOpenAiClient.GetEmbeddingClient(_options.EmbeddModelName).GenerateEmbeddingAsync(text);
            return response.Value.ToFloats();
        }

        /// <summary>
        /// Определение является ли файл изображением
        /// </summary>
        private static bool IsImageFile(string filePath)
        {
            var extension = Path.GetExtension(filePath)?.ToLowerInvariant();
            return extension switch
            {
                ".bmp" or ".jpg" or ".jpeg" or ".png" or ".gif" or ".tiff" or ".tif" or ".ico" => true,
                _ => false
            };
        }

        /// <summary>
        /// Получаем описание из Qdrant по полному пути файла
        /// </summary>
        public async Task<SearchResult?> GetDescription(string fullImagePath)
        {
            if (string.IsNullOrEmpty(fullImagePath) || !File.Exists(fullImagePath))
                return null;

            try
            {
                var points = await _qdrantClient.ScrollAsync(
                    collectionName: _options.QDrantCollectionName,
                    filter: new Filter
                    {
                        Must = { new Condition { Field = new FieldCondition { Key = "image_path", Match = new Match { Text = fullImagePath } } } }
                    },
                    limit: 1 // Нам нужен только один результат
                );

                var firstPoint = points.Result.FirstOrDefault();
                if (firstPoint != null)
                {
                    return new SearchResult()
                    {
                        filename = firstPoint.Payload["image_path"].StringValue,
                        description = firstPoint.Payload["description"].StringValue,
                        creation = firstPoint.Payload["creation_time"].StringValue,
                        lastWrite = firstPoint.Payload["last_write_time"].StringValue,
                        size = firstPoint.Payload["file_size"].IntegerValue
                    };
                }

                return null;
            }
            catch (Exception ex)
            {
                Console.WriteLine($"Error retrieving description for {fullImagePath}: {ex.Message}");
                return null;
            }
        }

        /// <summary>
        /// Поиск в Qdrant по текствому описанию
        /// </summary>
        public async Task<List<SearchResult>?> Search(string text, float? scoreThreshold)
        {
            var data = (await GenerateVectorAsync(text)).ToArray();

            var points = await _qdrantClient.QueryAsync(
                collectionName: _options.QDrantCollectionName,
                query: data,
                searchParams: new SearchParams
                {
                    // HNSW для поиск 
                    // Чем выше значение, тем точнее поиск (лучше recall), но медленнее. 
                    // Должен быть больше, чем M * 2. 
                    // Хорошие стартовые значения: 64, 100, 128.
                    // HnswEf = 100,

                    Acorn = new AcornSearchParams
                    {
                        Enable = true,
                        MaxSelectivity = 0.7
                    }
                },
                scoreThreshold: scoreThreshold
            );

            if (points.Any())
            {
                return points.Select(x => new SearchResult()
                {
                    filename = x.Payload["image_path"].StringValue,
                    description = x.Payload["description"].StringValue,
                    creation = x.Payload["creation_time"].StringValue,
                    lastWrite = x.Payload["last_write_time"].StringValue,
                    size = x.Payload["file_size"].IntegerValue,
                    score = x.Score
                }).ToList();
            }

            return null;
        }
    }
}

Вернемся к ImageBrowserAI

Программа состоит из 3 вкладок: Изображения, Сканировать, Настройки.

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

Для поиска среди фото, для наглядности добавил цветовую индикацию: чем ближе "похожесть" (Score) к 0.4f и выше - тем зеленее. Чем хуже "похожесть", в плоть до 0 - тем ближе к красному цвету. Регулировать поиск можно специально добавленным параметром "Порог поиска" обрезающим результаты ниже этого порога.

Изображения
Изображения

Во вкладке "Сканировать" - можно выбрать конкретную папку и сканировать всё что в ней находится, в том числе и во вложенных подпапках.

Сканировать
Сканировать

С настройками и так все понятно. Чем больше размер для visual модели - тем лучше распознавание (особенно для текста), но медленнее обработка одного фото. Размер вектора - нужно узнавать у Embedding модели. Все настройки для подключения к моделям и Qdrant указаны для локально развернутого LM Studio и Qdrant. Кнопка "Применить" работает только для до закрытия программы. Сохранения настроек не реализовывал. От промпта зависит то как будет обрабатывать фото визуальная модель: очень кратко и быстро, или очень подробно но медленно. Да, тут наверно не хватает параметра "Температура".

Если указать "таймаут (сек)" для визуальной модели слишком маленький, то при первой ("холодной") загрузке модели в LM Studio, компонент OpenAIClient может прервать подключение, если модель грузится дольше чем этот таймаут. К примеру модель размером 19.64 GB у меня в ОЗУ загружается как раз к 30 секундам.

Настройки
Настройки

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

А вот так ведет себя LM Studio при сканировании фото.

LM Studio

Что еще можно сделать

Конечно это самая простая наивная реализация текстового поиска среди фото. Никто не мешает расширить точность поиска с помощью классификации фото: жанр, стиль, объект, тема (природа, еда, люди и т.д.). Достаточно сформировать промпт для визуальной модели, дополнительно генерирующий в шаблонном виде класс. И этот класс можно добавить в полезную нагрузку (Payload), либо добавить новые коллекции, в которые можно включать определенные фото. В полезную нагрузку можно сохранять небольшое превью фото для удобства, если не хочется лишний раз считывать найденное изображение с медленного HDD.

Еще можно попробовать реализовать поиск по лицу, если предусмотреть извлечение и сохранение "отпечатка" лица в векторную БД. Что для этого использовать: OpenCL или отдельную face detect модель - решать вам.

Проект и релиз можно забрать здесь: https://github.com/virex-84/ImageBrowserAI

А на сегодня всё.

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