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

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

Для этого нам понадобится: мультимодальная модель которая умеет описывать изображение, векторная БД для хранения и поиска по текстовому запросу, и 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

Железо

Сама программа ничего не нагружает, ее можно запускать на любом железе. Вся нагрузка - на сервер LLM моделей (там где установлен LM Studio) и Qdrant. В моем случае это ПК на базе процессора AMD Ryzen™ AI 9 HX 370 и минимум 64Гб ОЗУ.

Обработка одного фото размером 3,75 МБ (считывание фото с SSD диска, уменьшение размера до 256 пикселей, описание с промптом "Please describe image shortests in russian.", сохранение в Qdrant), занимает примерно 3,5 секунды.

Если уменьшать размер до 512, либо указать в промпте описывать фото более подробно - обработка будет медленнее. Кроме того скорость обработки зависит и от скорости чтения с диска, а также от скорости работы сети если сервер LLM модели запущен на другом ПК.

Но никто не запрещает использовать для описания фото более слабую модель Qwen3-VL 8B (файл GGUF от 6 до 9 Гб), вместо Qwen3-VL 30B (файл GGUF от 19 до 33 Гб) указанной в статье. Либо использовать мультимодальную модель совершенно другого производителя. Для этого и предусмотрены настройки в программе.

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

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

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

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

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

Face detection

Погодите, еще не всё. Благодаря https://github.com/FaceONNX/ появился поиск по лицу: добавил кнопку поиска, и меню "Найти по лицу" при нажатии правой кнопкой мыши на фото.

Устанавливаем через NuGet пакеты FaceONNX и SixLabors.ImageSharp. Создаем новый класс для детекции лиц. Данный класс генерирует список эмбеддингов каждого лица найденного на фото.

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

using FaceONNX;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using Image = SixLabors.ImageSharp.Image;

public static class FaceRecognizer
{
    /// <summary>
    /// Ищем лица и возвращаем список embedding
    /// </summary>
    public static List<float[]> GetFaceEmbedding(Bitmap bitmap)
    {
        var result = new List<float[]>();

        using var faceEmbedder = new FaceEmbedder();
        using var faceDetector = new FaceDetector();
        using var faceLandmarksExtractor = new Face68LandmarksExtractor();

        using var theImage = ToImageSharpRgb24(bitmap);

        var array = GetImageFloatArray(theImage);
        var rectangles = faceDetector.Forward(array);

        foreach (var rectangle in rectangles)
        {
            // определяем границы лица
            var rectangleBox = rectangle.Box;

            if (!rectangleBox.IsEmpty)
            {
                // определяем искажения
                var points = faceLandmarksExtractor.Forward(array, rectangleBox);
                var angle = points.RotationAngle;

                // выравниваем
                var aligned = FaceProcessingExtensions.Align(array, rectangleBox, angle);
                result.Add(faceEmbedder.Forward(aligned));
            }
        }

        return result;
    }

    public static List<float[]> GetFaceEmbedding(string imagePath)
    {
        using var bitmap = new Bitmap(imagePath);
        return GetFaceEmbedding(bitmap);
    }

    static Image<Rgb24> ToImageSharpRgb24(Bitmap bitmap)
    {
        using (var ms = new MemoryStream())
        {
            // Сохраняем GDI+ Bitmap в поток в формате BMP
            bitmap.Save(ms, System.Drawing.Imaging.ImageFormat.Bmp);
            ms.Position = 0; // Сбрасываем позицию

            // Загружаем поток в объект ImageSharp и возвращаем его
            // Важно: вызывающий код отвечает за Dispose() этого нового объекта ImageSharp!
            return Image.Load<Rgb24>(ms);
        }
    }

    static float[][,] GetImageFloatArray(Image<Rgb24> image)
    {
        var array = new[]
        {
                new float [image.Height,image.Width],
                new float [image.Height,image.Width],
                new float [image.Height,image.Width]
            };

        image.ProcessPixelRows(pixelAccessor =>
        {
            for (var y = 0; y < pixelAccessor.Height; y++)
            {
                var row = pixelAccessor.GetRowSpan(y);
                for (var x = 0; x < pixelAccessor.Width; x++)
                {
                    array[2][y, x] = row[x].R / 255.0F;
                    array[1][y, x] = row[x].G / 255.0F;
                    array[0][y, x] = row[x].B / 255.0F;
                }
            }
        });

        return array;
    }
}

Т.к. у одного фото может быть несколько лиц, необходимо переделать конфигурацию коллекции в Qdrant. Сформируем мультивекторную именованную коллекцию: в "text-vector" будет привычный для нас embedding текста, а в "face-vector" - несколько эмбеддингов (на каждое лицо).

Создание коллекции
await _qdrantClient.CreateCollectionAsync(
  collectionName: "collection_name",
  vectorsConfig: new VectorParamsMap
  {
      Map = {
          {
              "text-vector",
              new VectorParams
              {
                  Size = 768,
                  Distance = Distance.Dot
              }
          },
          {
              "face-vector",
              new VectorParams
              {
                  Size = 512,
                  Distance = Distance.Cosine,
                  MultivectorConfig = new MultiVectorConfig
                  {
                      Comparator = MultiVectorComparator.MaxSim
                  }
              }
          }
      }
  },
  // Дополнительные параметры конфигурации индекса
  hnswConfig: new HnswConfigDiff
  {
      M = 64,             // Количество связей на узел (увеличивает точность и память)
      EfConstruct = 100,  // Размер списка соседей во время построения индекса (влияет на качество индексации)
      OnDisk = true       // Сохранение индекса на диск для экономии RAM при больших объемах
  }
);

Изменилось и сохранение коллекции.

Сохранение коллекции
private async Task SaveDescriptionToQDrantAsync(string imagePath, string description, List<float[]> faces, FileInfo fileInfo)
{
    var textVectors = await GenerateVectorAsync(description);

    // Создаём словарь векторов
    var vectorsMap = new Dictionary<string, Vector>
    {
        ["text-vector"] = textVectors.ToArray()
    };

    // Добавляем multivector для лиц
    if (faces != null && faces.Count > 0)
    {
        // Для multivector нужно использовать MultiDenseVector
        var multiVector = new Vector();
        multiVector.MultiDense = new MultiDenseVector();

        foreach (var face in faces)
        {
            var denseVector = new DenseVector();
            denseVector.Data.AddRange(face);
            multiVector.MultiDense.Vectors.Add(denseVector);
        }

        // Добавляем мультивектор
        vectorsMap["face-vector"] = multiVector;
    }

    var point = new PointStruct
    {
        Id = new PointId { Uuid = GenerateGuidFromString(imagePath).ToString() },

        // простой поиск
        //Vectors = vectors.ToArray(),

        // поиск по двум векторам: тексту и изображению
        //Vectors = new Dictionary<string, float[]>
        //{
        //    ["text-vector"] = textVectors.ToArray(),
        //    ["face-vector"] = faces[0].ToArray(),
        //},
        
        // поиск по мультивектору, когда в одном из векторов - массив эмбеддингов
        Vectors = vectorsMap,
        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.QDrantDescriptionCollectionName, new[] { point });
}

Ну а теперь, самое интересное: поиск по лицу. Сначала ищем идентификаторы записей через SearchAsync, а затем вытягиваем нагрузку и эмбеддинги лиц через RetrieveAsync.

Поиск по лицу
public async Task<List<SearchResult>?> SearchByFaces(List<float[]> faceVectors, float? scoreThreshold)
{
    if (faceVectors == null) return null;

    // Словарь для хранения лучшего Score по каждому ID
    var scoresByPointId = new Dictionary<string, float>();
    var allPointIds = new List<PointId>();

    foreach (var faceVector in faceVectors)
    {
        var points = await _qdrantClient.SearchAsync(
            collectionName: _options.QDrantDescriptionCollectionName,
            vector: faceVector,
            vectorName: "face-vector",
            scoreThreshold: scoreThreshold
        );

        foreach (var point in points)
        {
            var id = point.Id.Uuid;

            // Сохраняем лучший Score для каждого ID
            if (!scoresByPointId.TryGetValue(id, out var existingScore) || point.Score > existingScore)
            {
                scoresByPointId[id] = point.Score;
            }

            allPointIds.Add(point.Id);
        }
    }

    if (!allPointIds.Any())
        return null;

    // Убираем дубли ID
    var uniquePointIds = allPointIds
        .DistinctBy(p => p.Uuid)
        .ToList();

    // Получаем полные данные
    var pointsWithData = await _qdrantClient.RetrieveAsync(
        collectionName: _options.QDrantDescriptionCollectionName,
        ids: uniquePointIds,
        withPayload: true,
        withVectors: true
    );

    if (pointsWithData.Any())
    {
        return pointsWithData.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 = scoresByPointId[x.Id.Uuid],  // ← Score из словаря
            faces = ExtractFaceVectors(x)
        })
        .OrderByDescending(x => x.score)  // Сортировка по Score
        .ToList();
    }

    return null;
}

Проект на гитхабе обновлён. Готовое приложение из-за FaceONNX.dll потяжелело на 168 Мб.

Найти по лицу. А еще около слова "Поиск" есть значок для поиска по лицу с произвольного файла.
Найти по лицу. А еще около слова "Поиск" есть значок для поиска по лицу с произвольного файла.
Результата поиска
Результата поиска

Поскольку в FaceONNX.dll зашит движок и модель для распознания лиц, само распознавание лица происходит программой, поэтому для ее работы повышаю��ся минимальные требования. А вот какие требования - нигде не нашел. Гугл ИИ пишет что это минимум Intel Core i5 или AMD Ryzen 3, от 8 Гб ОЗУ, либо видеокарта минимум NVIDIA Pascal (GeForce GT 1010 и выше). Но думаю требования чуть ниже того, что предлагает гугл.

Видео

Поэкспериментировав с описанием фото, родилась идея: а почему бы не описать видео? Модель Qwen3-VL 30B конечно умеет обрабатывать не только фото, но и видео. Однако обработка происходит очень медленно, и файлы больших размеров могут прервать инференс ошибкой.

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

Разрезать видео будем по определенному процентажу: чем больше процент, тем больше кадров, но и дольше обработка. Опытным путем было определено что 2% достаточно.

Дополнительно, в главном методе где собственно происходит получение описания изображений от модели (ImageProcessor.ProcessImageAsync), увеличим таймаут для последовательности кадров: в нашем случае 30 секунд * кол-во кадров. Иначе мы можем не дождаться инференса даже 5 кадров из видео.

Для разрезания видео на кадры будем использовать следующие NuGet пакеты: FFMediaToolkit, FFmpeg.LGPL и Sdcb.FFmpeg.runtime.windows-x64.

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

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

using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
using FFMediaToolkit.Decoding;
using FFMediaToolkit.Graphics;

namespace ImageBrowserAI
{
    /// <summary>
    /// Извлекает кадры из видео по процентажу. Работает in-proc через FFMediaToolkit.
    /// </summary>
    public sealed class VideoTools : IDisposable
    {
        private readonly int _targetWidth;
        private readonly int _targetHeight;
        private readonly bool _preserveAspect;
        private bool _disposed;

        /// <param name="targetWidth">Желаемая ширина результата. 0 — авто.</param>
        /// <param name="targetHeight">Желаемая высота результата. 0 — авто.</param>
        /// <param name="preserveAspect">Сохранять пропорции при масштабировании.</param>
        public VideoTools(int targetWidth = 0, int targetHeight = 0, bool preserveAspect = true)
        {
            _targetWidth = targetWidth;
            _targetHeight = targetHeight;
            _preserveAspect = preserveAspect;
        }

        /// <summary>
        /// Извлекает кадры по процентажу.
        /// 100% — почти все кадры видео; 10% — примерно каждый 10-й кадр.
        /// </summary>
        /// <param name="videoPath">Путь к видеофайлу.</param>
        /// <param name="percentage">Доля кадров от общего числа (0; 100].</param>
        /// <param name="ct">Токен отмены.</param>
        public Task<List<Bitmap>> ExtractFramesAsync(string videoPath, double percentage, CancellationToken ct = default)
        {
            if (_disposed) throw new ObjectDisposedException(nameof(VideoTools));
            if (string.IsNullOrWhiteSpace(videoPath)) throw new ArgumentNullException(nameof(videoPath));
            if (percentage <= 0 || percentage > 100) throw new ArgumentOutOfRangeException(nameof(percentage));

            return Task.Run(() => ExtractFramesCore(videoPath, percentage, ct), ct);
        }

        private List<Bitmap> ExtractFramesCore(string videoPath, double percentage, CancellationToken ct)
        {
            var frames = new List<Bitmap>();

            // Открываем только видео-поток, декодируем в BGR24 — удобно для Bitmap.
            var mediaOptions = new MediaOptions
            {
                StreamsToLoad = MediaMode.Video,
                VideoPixelFormat = ImagePixelFormat.Bgr24
            };

            using var media = MediaFile.Open(videoPath, mediaOptions);

            var vinfo = media.Video.Info;
            var duration = vinfo.Duration;

            if (duration <= TimeSpan.Zero)
                throw new InvalidOperationException("Не удалось определить длительность видео.");

            // Оценка общего числа кадров.
            // 1) берём точное, если доступно (FrameCount/NumberOfFrames); 
            // 2) иначе считаем duration * fps (если библиотека дала fps); 
            // 3) в крайнем случае — fallback 30 fps.
            int totalFrames = TryGetFrameCount(vinfo);
            double fps = TryGetFps(vinfo);
            if (totalFrames <= 0)
            {
                if (fps <= 1e-6) fps = 30.0;
                totalFrames = (int)Math.Max(1, Math.Round(duration.TotalSeconds * fps));
            }
            if (fps <= 1e-6)
            {
                // вычисляем из totalFrames и duration, чтобы равномерно бежать по таймлайну
                fps = totalFrames / Math.Max(0.001, duration.TotalSeconds);
            }

            // Каждые N‑й кадр.
            int everyNth = percentage >= 100.0 ? 1 : Math.Max(1, (int)Math.Round(100.0 / percentage));
            int estimatedOut = Math.Max(1, totalFrames / everyNth);
            frames.Capacity = estimatedOut;

            // Размер исходного кадра:
            int srcW = vinfo.FrameSize.Width;
            int srcH = vinfo.FrameSize.Height;

            // Целевой размер (не увеличиваем исходник).
            GetTargetSize(srcW, srcH, _targetWidth, _targetHeight, _preserveAspect, out int dstW, out int dstH);

            // Основной цикл: адресуемся по индексам кадров через таймкод.
            for (int i = 0; i < totalFrames; i += everyNth)
            {
                ct.ThrowIfCancellationRequested();

                // Таймкод для i‑го кадра. Через fps — даёт равномерный проход по кадрам.
                var ts = TimeSpan.FromSeconds(i / fps);

                // Выделяем Bitmap под исходный кадр (оригинальный размер).
                using var srcBitmap = new Bitmap(srcW, srcH, PixelFormat.Format24bppRgb);
                var rect = new Rectangle(0, 0, srcW, srcH);
                var data = srcBitmap.LockBits(rect, ImageLockMode.WriteOnly, srcBitmap.PixelFormat);

                bool ok = media.Video.TryGetFrame(ts, data.Scan0, data.Stride);
                srcBitmap.UnlockBits(data);

                if (!ok) continue;

                // Масштабируем при необходимости (вниз), чтобы снизить нагрузку/память.
                Bitmap finalBmp;
                if (dstW < srcW || dstH < srcH)
                    finalBmp = ResizeBilinear(srcBitmap, dstW, dstH);
                else
                    finalBmp = (Bitmap)srcBitmap.Clone();

                frames.Add(finalBmp);
            }

            return frames;
        }

        // Пытаемся получить точный счётчик кадров из VideoStreamInfo
        private static int TryGetFrameCount(VideoStreamInfo info)
        {
            // В разных версиях FFMediaToolkit поле могло называться по‑разному.
            // Пробуем самые распространённые варианты через pattern matching.
            try
            {
                // Если есть NumberOfFrames
                var prop = typeof(VideoStreamInfo).GetProperty("NumberOfFrames")
                           ?? typeof(VideoStreamInfo).GetProperty("FrameCount")
                           ?? typeof(VideoStreamInfo).GetProperty("FramesCount");
                if (prop != null && prop.PropertyType == typeof(int))
                {
                    int value = (int)(prop.GetValue(info) ?? 0);
                    if (value > 0) return value;
                }
            }
            catch { /* игнорируем, пойдём по оценке */ }

            return 0; // не удалось — посчитаем от duration*fps
        }

        // Оцениваем fps из VideoStreamInfo, если доступно
        private static double TryGetFps(VideoStreamInfo info)
        {
            try
            {
                // Попытки: FrameRate (double) или AvgFrameRate (Rational)
                var pr = typeof(VideoStreamInfo).GetProperty("FrameRate");
                if (pr != null)
                {
                    var val = pr.GetValue(info);
                    if (val is double d && d > 0) return d;
                    // иногда FrameRate может быть Rational-подобным
                    var numProp = val?.GetType().GetProperty("Numerator") ?? val?.GetType().GetProperty("Num");
                    var denProp = val?.GetType().GetProperty("Denominator") ?? val?.GetType().GetProperty("Den");
                    if (numProp != null && denProp != null)
                    {
                        double num = Convert.ToDouble(numProp.GetValue(val) ?? 0);
                        double den = Convert.ToDouble(denProp.GetValue(val) ?? 1);
                        if (den > 0 && num > 0) return num / den;
                    }
                }

                var pr2 = typeof(VideoStreamInfo).GetProperty("AvgFrameRate") ?? typeof(VideoStreamInfo).GetProperty("AverageFrameRate");
                if (pr2 != null)
                {
                    var val = pr2.GetValue(info);
                    var numProp = val?.GetType().GetProperty("Numerator") ?? val?.GetType().GetProperty("Num");
                    var denProp = val?.GetType().GetProperty("Denominator") ?? val?.GetType().GetProperty("Den");
                    if (numProp != null && denProp != null)
                    {
                        double num = Convert.ToDouble(numProp.GetValue(val) ?? 0);
                        double den = Convert.ToDouble(denProp.GetValue(val) ?? 1);
                        if (den > 0 && num > 0) return num / den;
                    }
                }
            }
            catch { /* ок, используем fallback */ }

            return 0;
        }

        private static void GetTargetSize(int srcW, int srcH, int reqW, int reqH, bool keepAspect, out int dstW, out int dstH)
        {
            if (reqW <= 0 && reqH <= 0)
            {
                dstW = srcW; dstH = srcH; return;
            }

            if (!keepAspect)
            {
                dstW = reqW > 0 ? reqW : srcW;
                dstH = reqH > 0 ? reqH : srcH;
                // не увеличиваем
                dstW = Math.Min(dstW, srcW);
                dstH = Math.Min(dstH, srcH);
                return;
            }

            // сохраняем пропорции
            double scaleW = reqW > 0 ? (double)reqW / srcW : double.PositiveInfinity;
            double scaleH = reqH > 0 ? (double)reqH / srcH : double.PositiveInfinity;
            double scale = Math.Min(scaleW, scaleH);
            if (double.IsInfinity(scale)) scale = Math.Min(scaleW, scaleH == double.PositiveInfinity ? scaleW : scaleH);
            if (double.IsInfinity(scale) || scale <= 0) { dstW = srcW; dstH = srcH; return; }

            // только уменьшение
            scale = Math.Min(1.0, scale);

            dstW = Math.Max(1, (int)Math.Round(srcW * scale));
            dstH = Math.Max(1, (int)Math.Round(srcH * scale));
        }

        // Быстрое и бережное масштабирование вниз (HighSpeed + Bilinear)
        private static Bitmap ResizeBilinear(Bitmap src, int w, int h)
        {
            if (src.Width == w && src.Height == h) return (Bitmap)src.Clone();

            var dst = new Bitmap(w, h, PixelFormat.Format24bppRgb);
            using var g = Graphics.FromImage(dst);
            g.CompositingMode = CompositingMode.SourceCopy;
            g.CompositingQuality = CompositingQuality.HighSpeed; // меньше нагрузка
            g.InterpolationMode = InterpolationMode.Bilinear;    // достаточно качественно
            g.SmoothingMode = SmoothingMode.None;
            g.PixelOffsetMode = PixelOffsetMode.Half;
            g.DrawImage(src, new Rectangle(0, 0, w, h), new Rectangle(0, 0, src.Width, src.Height), GraphicsUnit.Pixel);
            return dst;
        }

        private Image GetPreviewCore(string videoPath, int maxW, int maxH, TimeSpan? preferAt, CancellationToken ct = default)
        {
            var mediaOptions = new MediaOptions
            {
                StreamsToLoad = MediaMode.Video,
                VideoPixelFormat = ImagePixelFormat.Bgr24
            };

            using var media = MediaFile.Open(videoPath, mediaOptions);
            var vinfo = media.Video.Info;
            var duration = vinfo.Duration;
            int srcW = vinfo.FrameSize.Width;
            int srcH = vinfo.FrameSize.Height;

            // Кандидаты на позицию превью: указанный момент, затем ~2с, 1.5с, 1с, 0.5с, 0с.
            var times = new List<TimeSpan>();
            if (preferAt.HasValue) times.Add(Clamp(preferAt.Value, TimeSpan.Zero, SafeEnd(duration)));
            times.Add(Clamp(TimeSpan.FromSeconds(Math.Min(2, Math.Max(0.2, duration.TotalSeconds * 0.05))), TimeSpan.Zero, SafeEnd(duration)));
            times.Add(Clamp(TimeSpan.FromSeconds(1.5), TimeSpan.Zero, SafeEnd(duration)));
            times.Add(Clamp(TimeSpan.FromSeconds(1.0), TimeSpan.Zero, SafeEnd(duration)));
            times.Add(Clamp(TimeSpan.FromSeconds(0.5), TimeSpan.Zero, SafeEnd(duration)));
            times.Add(TimeSpan.Zero);

            // Вычисляем целевой размер
            GetTargetSize(srcW, srcH, maxW, maxH, true, out int dstW, out int dstH);

            foreach (var ts in times)
            {
                ct.ThrowIfCancellationRequested();

                using var src = new Bitmap(srcW, srcH, PixelFormat.Format24bppRgb);
                var rect = new Rectangle(0, 0, srcW, srcH);
                var data = src.LockBits(rect, ImageLockMode.WriteOnly, src.PixelFormat);
                bool ok = false;
                try
                {
                    ok = media.Video.TryGetFrame(ts, data.Scan0, data.Stride);
                }
                finally
                {
                    src.UnlockBits(data);
                }

                if (ok)
                {
                    // Быстрое уменьшение (только вниз)
                    if (dstW < srcW || dstH < srcH)
                        return ResizeFast(src, dstW, dstH);
                    return (Image)src.Clone();
                }
            }

            // На всякий случай: если все попытки не дали кадр — бросаем исключение
            throw new InvalidOperationException("Не удалось получить превью-кадр из видео.");
        }

        // Быстрый превью-кадр: ищет первый «нормальный» кадр в окне 0–3с с шагом 0.25с
        public Image GetPreviewSmart(
            string videoPath,
            int maxWidth = 480,
            int maxHeight = 270,
            double windowSeconds = 3.0,
            double stepSeconds = 0.25,
            int sampleStride = 8)
        {
            if (_disposed) throw new ObjectDisposedException(nameof(VideoTools));
            if (string.IsNullOrWhiteSpace(videoPath)) throw new ArgumentNullException(nameof(videoPath));
            return GetPreviewSmartCore(videoPath, maxWidth, maxHeight, windowSeconds, stepSeconds, sampleStride, CancellationToken.None);
        }

        public Task<Image> GetPreviewSmartAsync(
            string videoPath,
            int maxWidth = 480,
            int maxHeight = 270,
            double windowSeconds = 3.0,
            double stepSeconds = 0.25,
            int sampleStride = 8,
            CancellationToken ct = default)
        {
            if (_disposed) throw new ObjectDisposedException(nameof(VideoTools));
            if (string.IsNullOrWhiteSpace(videoPath)) throw new ArgumentNullException(nameof(videoPath));
            return Task.Run(() => GetPreviewSmartCore(videoPath, maxWidth, maxHeight, windowSeconds, stepSeconds, sampleStride, ct), ct);
        }

        private Image GetPreviewSmartCore(
            string videoPath,
            int maxW,
            int maxH,
            double windowSeconds,
            double stepSeconds,
            int sampleStride,
            CancellationToken ct)
        {
            var mediaOptions = new MediaOptions
            {
                StreamsToLoad = MediaMode.Video,
                VideoPixelFormat = ImagePixelFormat.Bgr24
            };

            using var media = MediaFile.Open(videoPath, mediaOptions);
            var vinfo = media.Video.Info;
            var duration = vinfo.Duration;

            int srcW = vinfo.FrameSize.Width;
            int srcH = vinfo.FrameSize.Height;

            // Вычисляем целевой размер (только уменьшение, с сохранением пропорций)
            GetTargetSize(srcW, srcH, maxW, maxH, true, out int dstW, out int dstH);

            // Сканируем окно [0; min(3с, конец ролика)]
            var end = SafeEnd(duration);
            var scanEnd = TimeSpan.FromSeconds(Math.Min(windowSeconds, Math.Max(0, end.TotalSeconds)));
            var step = TimeSpan.FromSeconds(Math.Max(0.05, stepSeconds)); // не даём слишком мелкий шаг

            // Начинаем не строго с нуля, а с 0,2с — часто первые кадры бывают чёрными
            var start = TimeSpan.FromSeconds(Math.Min(0.2, scanEnd.TotalSeconds));

            for (var ts = start; ts <= scanEnd; ts += step)
            {
                ct.ThrowIfCancellationRequested();

                using var src = new Bitmap(srcW, srcH, PixelFormat.Format24bppRgb);
                var rect = new Rectangle(0, 0, srcW, srcH);
                var data = src.LockBits(rect, ImageLockMode.WriteOnly, src.PixelFormat);

                bool ok;
                try
                {
                    ok = media.Video.TryGetFrame(ts, data.Scan0, data.Stride);
                }
                finally
                {
                    src.UnlockBits(data);
                }

                if (!ok) continue;

                // Быстрая проверка: кадр не слишком тёмный/светлый и не «монотонный»
                if (IsUsableFrame(src, sampleStride, out _))
                {
                    if (dstW < srcW || dstH < srcH)
                        return ResizeFast(src, dstW, dstH);

                    return (Image)src.Clone(); // не апскейлим
                }
            }

            // Фолбэк: используем обычный превью-алгоритм (2с, 1.5с, 1с, 0.5с, 0с)
            return GetPreviewCore(videoPath, maxW, maxH, preferAt: null);
        }

        // Быстрая оценка «содержательности» кадра.
        // Возвращает true, если кадр не слишком тёмный/светлый и есть текстура/детали.
        private static unsafe bool IsUsableFrame(Bitmap bmp, int strideStep, out double meanLuma)
        {
            // Пороговые значения можно подстроить под ваш контент:
            const byte darkThr = 22;     // кадр темнее — считаем «тёмным»
            const byte brightThr = 233;  // кадр светлее — считаем «пересвеченным»
            const double maxMonoRatio = 0.90; // доля чёрных/белых пикселей — выше считаем «монотонным»
            const double minEdgeAvg = 2.0;    // средняя «резкость» (|dY|) по сэмплам — ниже считаем слишком гладким

            var data = bmp.LockBits(new Rectangle(0, 0, bmp.Width, bmp.Height),
                                    ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb);
            try
            {
                int w = bmp.Width;
                int h = bmp.Height;
                int stride = data.Stride;
                byte* basePtr = (byte*)data.Scan0;

                long sumY = 0;
                long sumEdge = 0;
                int edgePairs = 0;
                int samples = 0;
                int black = 0, white = 0;

                // Сэмплируем равномерной сеткой (каждый strideStep пикселей)
                for (int y = strideStep / 2; y < h; y += strideStep)
                {
                    byte* row = basePtr + y * stride;
                    int prevY = -1;

                    for (int x = strideStep / 2; x < w; x += strideStep)
                    {
                        byte* p = row + x * 3;      // BGR24
                        int B = p[0], G = p[1], R = p[2];

                        // Быстрая яркость (Rec.601)
                        int Y = (299 * R + 587 * G + 114 * B + 500) / 1000; // 0..255

                        sumY += Y;
                        samples++;

                        if (Y <= darkThr) black++;
                        if (Y >= brightThr) white++;

                        if (prevY >= 0)
                        {
                            sumEdge += Math.Abs(Y - prevY);
                            edgePairs++;
                        }
                        prevY = Y;
                    }
                }

                if (samples == 0) { meanLuma = 0; return false; }

                meanLuma = (double)sumY / samples;
                double blackRatio = (double)black / samples;
                double whiteRatio = (double)white / samples;
                double edgeAvg = edgePairs > 0 ? (double)sumEdge / edgePairs : 0.0;

                if (meanLuma <= darkThr) return false;
                if (meanLuma >= brightThr) return false;
                if (blackRatio >= maxMonoRatio || whiteRatio >= maxMonoRatio) return false;
                if (edgeAvg < minEdgeAvg) return false;

                return true;
            }
            finally
            {
                bmp.UnlockBits(data);
            }
        }

        private static Bitmap ResizeFast(Bitmap src, int w, int h)
        {
            if (src.Width == w && src.Height == h) return (Bitmap)src.Clone();
            var dst = new Bitmap(w, h, PixelFormat.Format24bppRgb);
            using var g = Graphics.FromImage(dst);
            g.CompositingMode = CompositingMode.SourceCopy;
            g.CompositingQuality = CompositingQuality.HighSpeed; // быстрее
            g.InterpolationMode = InterpolationMode.Bilinear;    // достаточно качественно
            g.SmoothingMode = SmoothingMode.None;
            g.PixelOffsetMode = PixelOffsetMode.Half;
            g.DrawImage(src, new Rectangle(0, 0, w, h), new Rectangle(0, 0, src.Width, src.Height), GraphicsUnit.Pixel);
            return dst;
        }

        // Helpers
        private static TimeSpan SafeEnd(TimeSpan duration)
        {
            // Небольшой "зазор", чтобы не запрашивать кадр ровно на EOF
            return duration <= TimeSpan.Zero
                ? TimeSpan.Zero
                : duration - TimeSpan.FromMilliseconds(10);
        }

        private static TimeSpan Clamp(TimeSpan value, TimeSpan min, TimeSpan max)
        {
            if (value < min) return min;
            if (value > max) return max;
            return value;
        }

        public void Dispose()
        {
            _disposed = true;
        }
    }
}

А так мы передадим кадры из видео для описания.

Передача кадров модели
// промпт
string VideoPrompt = "These images are sequential frames from a single video, arranged in chronological order. Describe what happens as ONE continuous action in 1-2 sentences. Focus on the progression of events, not individual frames. Output in Russian.";

// List<byte[]> images - массивы байт изображений в формате jpeg
// формируем части пользовательского запроса
List<ChatMessageContentPart> parts = new List<ChatMessageContentPart>();
// текстовая часть
parts.Add(ChatMessageContentPart.CreateTextPart(VideoPrompt));
// изображения
foreach (var imagePart in images)
{
    parts.Add(ChatMessageContentPart.CreateImagePart(new BinaryData(imagePart), "image/jpeg"));
}

var chatMessages = new[]
{
    new UserChatMessage(parts)
};

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

Изменения так же выложил на гитхаб.

Описание видео mp4
Описание видео mp4

Есть небольшая проблема с лицами на видео. FaceONNX преспокойно может обнаружить на видео даже самое небольшое миниатюрное лицо (эмбеддинг), которое при поиске будет находить почти все остальные "нормальные" (т.е. полученные из большого изображения) лица, что не корректно. Поэтому для видео был добавлен метод FaceRecognizer.UpdateFacesWithQuality и параметр embeddingQualityThreshold (Порог "качества" лица). Метод FaceRecognizer.GetEmbeddingQuality получает "качество", или другими словами плотность эмбеддинга лица. Чем выше плотность - тем лучше. Но проблема для видео с небольшим разрешением остается: небольшого размера кадр с лицом будет выдавать плохого качества эмбеддинги лиц. Этот момент нужно еще доработать.

Теперь точно всё.