«Крошка сын к отцу пришел и спросила кроха...»
Ну не сын на самом деле, а дочка, но пришла и спросила: «Паааап, у подруги тут ДР, вытащи мне из фотоархива все фото где мы с ней вместе». Да легко!
Но тут выяснилось, что и не так то легко. Дело в том, что еще в 22-м году, по понятным причинам, я перенес фотоархив с Google Photos, где распознавание лиц было уже тогда, на Яндекс Диск, где его нет до сих пор.
Но «тыж программист» (хоть и бывший, но бывших программистов не бывает!) да к тому же с интересом изучающий современный креативный подход Vibe Coding. На одной чаше весов было монотонное перелистывание тысяч фотографий, на другой — поразвлечься с Дипсиком и написать тулзу, которая распознает лица на моих фотографиях в Яндекс Диске. И понеслась...
В результате первого совещания с ИИ было выдвинуто предложение написать программу на JS, которая вытянет фотографии с Yandex Disk, распознает их в Yandex Vision (по тем же причинам что и переезд фотобанка в 22-м году зарубежные сервисы не рассматривал) и сохранит информацию в Yandex DB. Сказано - сделано. Буквально полчаса - и первый рабочий прототип готов.
Но тут выяснилось что полученная программа просто определяет наличие лиц на фото. Но не распознает их. На вопрос к Дипсику "с чего это вдруг" он ни капли не сомневаясь ответил "да не вопрос, просто добавь в вызове Yandex Vision FACE_RECOGNITION к FACE_DETECTION и будет тебе счастье". Да вот только выяснилось что FACE_RECOGNITION яндекс не умеет, только detection.
Дальше был мучительный перебор различных вариантов решений (к слову, я в этой теме не разбираюсь вот совсем - так что приходилось полностью полагаться на то, что мне предлагал уважаемый DeepSeek). Из вариантов которые я перебрал:
Полностью локальное решение на tenzorFlow
Опять-таки локальное решение на openCV
Полностью локальное решение на ONNX
По ряду причин каждое из них не взлетело. Будь у меня больше опыта в теме, уверен, можно было бы заставить работать (например тот же tenzorflow). Но после пары дней экспериментов (увы, блицкриг, который мне обещала заработавшая через полчаса первая версия, не случился) я остановился на варианте:
Определение лиц на фото с использованием Yandex Vision
Построение embedding-а для лиц с использованием ONNX + InsightFace
Примерно в это же время я (опять-таки, в качестве своих экспериментов с подходом к Vibe Coding) сменил дипсик на Claude Code. В итоге получился следущий код:
ВАЖНО! Данный код создан для быстрого решения задачи, по сути дела прототип в котором я просто эксперементировал с технологиями. Он на 90% создан ИИ. Ни в коем случае не используете его в Production - только как некоторый пример того как это может работать и для собственных идей.
Основные классы:
yandex-vision-face-detector.js - класс, который распознает лица при помощи Yandex Vision. Тут в принципе все просто. Важно только помнить что Vision принимает изображения до 4 мб, сжатие изображения я вставил в вызывающем коде, чтобы потом сразу из сжатого изображения вырезать лица по тем координатам, что возвращает YandexVisionFaceDetector.detectFaces
// yandex-vision-face-detector.js const axios = require('axios'); class YandexVisionFaceDetector { constructor(apiKey, folderId) { this.apiKey = apiKey; this.folderId = folderId; this.baseURL = 'https://vision.api.cloud.yandex.net/vision/v1'; } /** * Детекция лиц через Yandex Vision API */ async detectFaces(imageBuffer) { try { const base64Image = imageBuffer.toString('base64'); const response = await axios.post( `${this.baseURL}/batchAnalyze`, { folderId: this.folderId, analyze_specs: [{ content: base64Image, features: [{ type: "FACE_DETECTION" }] }] }, { headers: { 'Authorization': `Api-Key ${this.apiKey}`, 'Content-Type': 'application/json' }, timeout: 30000 } ); return this.parseVisionResponse(response.data); } catch (error) { console.error('❌ Ошибка Yandex Vision API:', error.response?.data || error.message); throw new Error(`Yandex Vision API error: ${error.message}`); } } /** * Парсинг ответа от Yandex Vision */ parseVisionResponse(visionData) { const faces = visionData.results?.[0]?.results?.[0]?.faceDetection?.faces; if (!faces || faces.length === 0) { return []; } return faces.map((face, index) => { const vertices = face.boundingBox.vertices; const xCoords = vertices.map(v => v.x); const yCoords = vertices.map(v => v.y); const bbox = [ Math.min(...xCoords), Math.min(...yCoords), Math.max(...xCoords), Math.max(...yCoords) ]; return { bbox: bbox, confidence: 1.0, landmarks: face.landmarks, attributes: face.attributes }; }); } } module.exports = YandexVisionFaceDetector;
onnx-face-embedding-service.js - класс, который строит вектор по изображению лица с использованием ONNX + InsightFace. Для его работы надо только заранее скачать файл insightface.onnx, например тут и положить его в папку ./models.
const ort = require('onnxruntime-node'); const sharp = require('sharp'); const path = require('path'); class OnnxFaceEmbeddingService { constructor() { this.session = null; this.inputSize = 112; // Стандартный размер для InsightFace моделей } /** * Инициализация сервиса и загрузка модели * @param {string} modelsPath - Путь к папке с моделями */ async initialize(modelsPath) { try { const modelPath = path.join(modelsPath, 'insightface.onnx'); // Создаем сессию ONNX Runtime this.session = await ort.InferenceSession.create(modelPath, { executionProviders: ['cpu'], // Можно использовать 'cuda' если есть GPU graphOptimizationLevel: 'all' }); console.log('ONNX модель успешно загружена:', modelPath); console.log('Входные тензоры:', this.session.inputNames); console.log('Выходные тензоры:', this.session.outputNames); return true; } catch (error) { console.error('Ошибка при загрузке модели:', error); throw new Error(`Не удалось загрузить модель: ${error.message}`); } } /** * Извлечение области лица из изображения * @param {Buffer} inputBuffer - Исходное изображение * @param {Array} bbox - Массив из 4 чисел [x1, y1, x2, y2] - координаты верхнего левого и нижнего правого углов * @param {number} padding - Отступ вокруг лица (по умолчанию 0.1) * @returns {Promise<Buffer>} - Вырезанное изображение лица */ async extractFaceRegion(inputBuffer, bbox, padding = 0.1) { const [x1, y1, x2, y2] = bbox; const width = x2 - x1; const height = y2 - y1; // Добавляем отступ const x = Math.max(0, Math.floor(x1 - width * padding)); const y = Math.max(0, Math.floor(y1 - height * padding)); const paddedWidth = Math.floor(width * (1 + 2 * padding)); const paddedHeight = Math.floor(height * (1 + 2 * padding)); // Вырезаем область лица const faceBuffer = await sharp(inputBuffer) .extract({ left: x, top: y, width: paddedWidth, height: paddedHeight }) .jpeg() .toBuffer(); return faceBuffer; } /** * Получение embedding для одного изображения лица * @param {Buffer} faceBuffer - Изображение лица (уже вырезанное) * @returns {Array} - Нормализованный embedding */ async getFaceEmbedding(faceBuffer) { if (!this.session) { throw new Error('Модель не инициализирована. Вызовите initialize() сначала.'); } try { // Изменяем размер и получаем raw данные const faceImage = await sharp(faceBuffer) .resize(this.inputSize, this.inputSize, { fit: 'fill', kernel: sharp.kernel.lanczos3 }) .raw() .toBuffer(); // Конвертируем в Float32Array и нормализуем const pixels = new Uint8Array(faceImage); const float32Data = new Float32Array(3 * this.inputSize * this.inputSize); // Преобразуем RGB в формат для модели // Формат: CHW (Channels, Height, Width) for (let i = 0; i < this.inputSize * this.inputSize; i++) { // R канал float32Data[i] = (pixels[i * 3] - 127.5) / 127.5; // G канал float32Data[this.inputSize * this.inputSize + i] = (pixels[i * 3 + 1] - 127.5) / 127.5; // B канал float32Data[2 * this.inputSize * this.inputSize + i] = (pixels[i * 3 + 2] - 127.5) / 127.5; } // Создаем входной тензор const inputTensor = new ort.Tensor( 'float32', float32Data, [1, 3, this.inputSize, this.inputSize] ); // Запускаем inference const feeds = {}; feeds[this.session.inputNames[0]] = inputTensor; const results = await this.session.run(feeds); // Получаем embedding из выходного тензора const outputTensor = results[this.session.outputNames[0]]; const embedding = Array.from(outputTensor.data); // Нормализуем embedding (L2 нормализация) const norm = Math.sqrt(embedding.reduce((sum, val) => sum + val * val, 0)); const normalizedEmbedding = embedding.map(val => val / norm); return normalizedEmbedding; } catch (error) { console.error('Ошибка при генерации embedding:', error); throw error; } } /** * Вычисление косинусного сходства между двумя embeddings * @param {Array} embedding1 - Первый embedding * @param {Array} embedding2 - Второй embedding * @returns {number} - Косинусное сходство (от -1 до 1) */ cosineSimilarity(embedding1, embedding2) { if (embedding1.length !== embedding2.length) { throw new Error('Embeddings должны иметь одинаковую длину'); } let dotProduct = 0; let norm1 = 0; let norm2 = 0; for (let i = 0; i < embedding1.length; i++) { dotProduct += embedding1[i] * embedding2[i]; norm1 += embedding1[i] * embedding1[i]; norm2 += embedding2[i] * embedding2[i]; } return dotProduct / (Math.sqrt(norm1) * Math.sqrt(norm2)); } /** * Освобождение ресурсов */ async dispose() { if (this.session) { await this.session.release(); this.session = null; console.log('ONNX сессия освобождена'); } } } module.exports = OnnxFaceEmbeddingService;
yandex-disk-service.js - сервис который получает файлы с Yandex Disk - тут все просто - базовые методы: получить список файлов, скачать файл
// yandex-disk-service.js const axios = require('axios'); class YandexDiskService { constructor(oauthToken) { this.oauthToken = oauthToken; this.diskBaseURL = 'https://cloud-api.yandex.net/v1/disk'; } /** * Получение списка файлов с Яндекс.Диска * @param {string} path - Путь к папке на Яндекс.Диске * @param {number} limit - Максимальное количество файлов * @returns {Promise<Array>} - Список файлов */ async getFilesList(path = '/', limit = 1000) { try { const response = await axios.get(`${this.diskBaseURL}/resources`, { headers: { 'Authorization': `OAuth ${this.oauthToken}` }, params: { path: path, limit: limit, media_type: 'image' } }); return response.data._embedded.items; } catch (error) { console.error('❌ Ошибка получения списка файлов:', error.response?.data || error.message); return []; } } /** * Скачивание файла с Яндекс.Диска * @param {string} filePath - Путь к файлу на Яндекс.Диске * @returns {Promise<Object|null>} - Объект с buffer, contentType и size или null при ошибке */ async downloadFile(filePath) { try { // Получаем ссылку для скачивания const response = await axios.get(`${this.diskBaseURL}/resources/download`, { headers: { 'Authorization': `OAuth ${this.oauthToken}` }, params: { path: filePath } }); // Скачиваем файл по полученной ссылке const downloadResponse = await axios.get(response.data.href, { responseType: 'arraybuffer' }); return { buffer: downloadResponse.data, contentType: downloadResponse.headers['content-type'], size: downloadResponse.data.length }; } catch (error) { console.error('❌ Ошибка скачивания файла:', error.response?.data || error.message); return null; } } /** * Копирование файла на Яндекс.Диске * @param {string} sourcePath - Путь к исходному файлу * @param {string} targetPath - Путь к целевому файлу * @param {boolean} overwrite - Перезаписывать ли существующий файл (по умолчанию false) * @returns {Promise<boolean>} - true если файл скопирован, false если файл уже существует */ async copyFile(sourcePath, targetPath, overwrite = false) { try { await axios.post( `${this.diskBaseURL}/resources/copy`, null, { headers: { 'Authorization': `OAuth ${this.oauthToken}` }, params: { from: sourcePath, path: targetPath, overwrite: overwrite } } ); return true; } catch (error) { if (error.response?.status === 409) { // Файл уже существует return false; } throw error; } } /** * Создание папки на Яндекс.Диске * @param {string} folderPath - Путь к создаваемой папке * @returns {Promise<boolean>} - true если папка создана или уже существует */ async createFolder(folderPath) { try { await axios.put( `${this.diskBaseURL}/resources`, null, { headers: { 'Authorization': `OAuth ${this.oauthToken}` }, params: { path: folderPath } } ); return true; } catch (error) { if (error.response?.status === 409) { // Папка уже существует return true; } throw error; } } } module.exports = YandexDiskService;
ydb-sevice.js - сервис который сохраняет вектора лиц в базе и ищет схожие лица (используя функцию Knn::CosineDistance - кстати, так как эта фича появилась в YDB только весной этого года, ИИ про нее еще не в курсе - тут пришлось писать код самому).
Еще важный момент - я в своем случае создал serverless базу. Это ок для экспериментов, но при хоть какой-то нагрузке (условно 1 запрос в секунду) сервис начинает постоянно отвечать с ошибкой RESOURCE_EXHAUSTED. Пришлось добавить обработку этой ошибки и делать несколько запросов с задержкой. Если решите использовать этот пример для плюс-минус объемного фотобанка - то лучше создавайте dedicated YDB.
const { Driver, TypedValues, Types, IamAuthService, getSACredentialsFromJson, withRetries } = require('ydb-sdk'); const { v4: uuidv4 } = require('uuid'); class YDBService { constructor(connectionString, database, serviceAccountKeyFile) { this.connectionString = connectionString; this.database = database; this.serviceAccountKeyFile = serviceAccountKeyFile; this.driver = null; } async initialize() { try { this.driver = new Driver({ endpoint: this.connectionString, database: this.database, authService: new IamAuthService( getSACredentialsFromJson(this.serviceAccountKeyFile) ) }); await this.driver.ready(10000); console.log('✅ YDB driver initialized with IAM auth service'); await this.createTables(); } catch (error) { console.error('YDB initialization failed:', error); throw error; } } /** * Вспомогательная функция для задержки */ sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Выполнение запроса с повторами при RESOURCE_EXHAUSTED * @param {Function} queryFn - Функция выполнения запроса * @param {number} maxRetries - Максимальное количество повторов */ async queryWithRetry(queryFn, maxRetries = 5) { let lastError; for (let attempt = 0; attempt < maxRetries; attempt++) { try { return await queryFn(); } catch (error) { // Проверяем код ошибки или сообщение const isResourceExhausted = error.code === 'RESOURCE_EXHAUSTED' || error.message?.includes('RESOURCE_EXHAUSTED') || error.message?.includes('OVERLOADED'); if (!isResourceExhausted) { throw error; } lastError = error; const delay = Math.min(1000 * Math.pow(2, attempt), 30000); console.log(` ⚠️ RESOURCE_EXHAUSTED: повтор ${attempt + 1}/${maxRetries} через ${delay}ms...`); await this.sleep(delay); } } throw lastError; } async createTables() { const tables = [ { name: 'faces', query: ` CREATE TABLE faces ( face_id Serial, person_id int32, image_path Utf8, embedding String, confidence Double, bbox Utf8, similar_face_id Int32, created_at Timestamp, PRIMARY KEY (face_id) ); ` }, { name: 'persons', query: ` CREATE TABLE persons ( person_id Serial, name Utf8, custom_name Utf8, PRIMARY KEY (person_id) ); ` } ]; for (const table of tables) { try { await this.driver.queryClient.do({ fn: async (session) => { await session.execute({ text: table.query }); }, }); console.log(`✅ Table '${table.name}' created/verified`); } catch (error) { // Игнорируем ошибку "table already exists" if (error.message?.includes('already exists') || error.message?.includes('ALREADY_EXISTS') || error.message?.includes('path exist')) { console.log(`ℹ️ Table '${table.name}' already exists`); } else { console.error(`❌ Error creating table '${table.name}':`, error.message); } } } } // Сохранение информации о лице async saveFace(faceData) { const query = ` DECLARE $person_id AS int32; DECLARE $image_path AS Utf8; DECLARE $embedding AS List<Float>; DECLARE $confidence AS Double; DECLARE $bbox AS Utf8; DECLARE $similar_face_id AS int32; UPSERT INTO faces (person_id, image_path, embedding, confidence, bbox, created_at, similar_face_id) VALUES ($person_id, $image_path, Untag(Knn::ToBinaryStringFloat($embedding), "FloatVector"), $confidence, $bbox, CurrentUtcTimestamp(), $similar_face_id); `; await this.queryWithRetry(async () => { await this.driver.tableClient.withSession(async (session) => { await session.executeQuery(query, { $person_id: TypedValues.int32(faceData.person_id), $image_path: TypedValues.utf8(faceData.image_path), $embedding: TypedValues.list(Types.FLOAT, faceData.embedding), $confidence: TypedValues.double(faceData.confidence), $bbox: TypedValues.utf8(JSON.stringify(faceData.bbox)), $similar_face_id: faceData.similar_face_id ? TypedValues.int32(faceData.similar_face_id) : TypedValues.optional(Types.INT32, null) } ); }); }); } // Сохранение/обновление персоны async savePerson(personData) { const query = ` DECLARE $name AS Utf8; DECLARE $custom_name AS Utf8; INSERT INTO persons (name, custom_name) VALUES ($name, $custom_name) RETURNING person_id; `; let personId; await this.queryWithRetry(async () => { await this.driver.tableClient.withSession(async (session) => { const result = await session.executeQuery(query, { $name: TypedValues.utf8(personData.name || ''), $custom_name: TypedValues.utf8(personData.custom_name || '') } ); // Получаем person_id из результата if (result.resultSets[0].rows && result.resultSets[0].rows.length > 0) { personId = result.resultSets[0].rows[0].items[0].int32Value; } }); }); return personId; } // Объединение двух персон async joinPersons(personId1, personId2) { const updateQuery = ` DECLARE $person_id1 AS Int32; DECLARE $person_id2 AS Int32; UPDATE faces SET person_id = $person_id1 WHERE person_id = $person_id2; `; const deleteQuery = ` DECLARE $person_id2 AS Int32; DELETE FROM persons WHERE person_id = $person_id2; `; await this.driver.tableClient.withSession(async (session) => { // Обновляем все лица с person_id2 на person_id1 await session.executeQuery(updateQuery, { $person_id1: TypedValues.int32(personId1), $person_id2: TypedValues.int32(personId2) } ); // Удаляем персону с person_id2 await session.executeQuery(deleteQuery, { $person_id2: TypedValues.int32(personId2) } ); }); console.log(`✅ Персоны объединены: person_id ${personId2} -> ${personId1}, персона ${personId2} удалена`); } /* Поиск похожих лиц * threshold - степень схожести. Важно - тут передается % схожести (чем выше, тем лучше) а Knn::CosineDistance наоборот возвращает разницу векторов (то есть чем меньше - тем лучше). * Поэтому мы вычитаем значение из 1 для конвертации */ async findSimilarFaces(embedding, threshold = 0.8, nResults) { // convert threshold to YDB value const ratio = 1 - threshold; const query = `DECLARE $vector AS List<Float>; DECLARE $ratio as Float; $TargetEmbedding = Knn::ToBinaryStringFloat($vector); SELECT face_id, person_id, image_path, Knn::CosineDistance(embedding, $TargetEmbedding) as ratio FROM faces WHERE Knn::CosineDistance(embedding, $TargetEmbedding) < $ratio ORDER BY Knn::CosineDistance(embedding, $TargetEmbedding) LIMIT ${nResults};`; const params = { $vector: TypedValues.list(Types.FLOAT, embedding), $ratio: TypedValues.float(ratio) }; const result = await this.queryWithRetry(async () => { return await this.driver.tableClient.withSession(async function(session) { return await session.executeQuery(query, params); }); }); return result.resultSets[0].rows.map(function(row) { return { face_id: row.items[0].int32Value, person_id: row.items[1].int32Value, image_path: row.items[2].textValue, ratio: 1 - row.items[3].floatValue // convert ratio from YDB format }; }); } // Получение всех фото для человека async getPersonPhotos(personId) { const query = ` SELECT image_path, confidence, created_at FROM faces WHERE person_id = $person_id ORDER BY confidence DESC; `; let result = []; await this.driver.tableClient.withSession(async (session) => { const dbResult = await session.executeQuery({ query: query, parameters: { $person_id: { textValue: personId } } }); if (dbResult.resultSets[0].rows) { result = dbResult.resultSets[0].rows.map(row => ({ image_path: row.items[0].textValue, confidence: row.items[1].doubleValue, created_at: row.items[2].timestampValue })); } }); return result; } // Получение уникальных путей к изображениям для персоны async getFacesByPersonId(personId) { const query = ` DECLARE $person_id AS Int32; SELECT DISTINCT image_path FROM faces WHERE person_id = $person_id; `; let result = []; await this.queryWithRetry(async () => { await this.driver.tableClient.withSession(async (session) => { const dbResult = await session.executeQuery(query, { $person_id: TypedValues.int32(personId) } ); if (dbResult.resultSets[0].rows) { result = dbResult.resultSets[0].rows.map(row => row.items[0].textValue); } }); }); return result; } // Получение всех лиц для заданного пути к изображению async findFacesByImagePath(imagePath) { const query = ` DECLARE $image_path AS Utf8; SELECT face_id, person_id, confidence, bbox, similar_face_id, created_at FROM faces WHERE image_path = $image_path; `; let result = []; await this.queryWithRetry(async () => { await this.driver.tableClient.withSession(async (session) => { const dbResult = await session.executeQuery(query, { $image_path: TypedValues.utf8(imagePath) } ); if (dbResult.resultSets[0].rows) { result = dbResult.resultSets[0].rows.map(row => ({ face_id: row.items[0].int32Value, person_id: row.items[1].int32Value, confidence: row.items[2].doubleValue, bbox: row.items[3].textValue, similar_face_id: row.items[4].int32Value || null, created_at: row.items[5].timestampValue })); } }); }); return result; } // Получение статистики async getStats() { const totalFacesQuery = `SELECT COUNT(*) as total_faces FROM faces;`; const totalPersonsQuery = `SELECT COUNT(DISTINCT person_id) as total_persons FROM faces;`; let stats = { total_faces: 0, total_persons: 0 }; await this.driver.tableClient.withSession(async (session) => { // Получаем общее количество лиц const facesResult = await session.executeQuery(totalFacesQuery); if (facesResult.resultSets[0].rows && facesResult.resultSets[0].rows.length > 0) { stats.total_faces = facesResult.resultSets[0].rows[0].items[0].uint64Value || 0; } // Получаем количество уникальных людей const personsResult = await session.executeQuery(totalPersonsQuery); if (personsResult.resultSets[0].rows && personsResult.resultSets[0].rows.length > 0) { stats.total_persons = personsResult.resultSets[0].rows[0].items[0].uint64Value || 0; } }); return stats; } // Закрытие соединения async destroy() { if (this.driver) { await this.driver.destroy(); } } } module.exports = YDBService;
face-service.js - собственно класс, который берет папку на ЯД, ищет на фото в папке лица используя предыдущие классы и сохраняет информацию в YDB.
Тут интересным параметром является SIMILAR_RATE - определяет пороговое значение, при котором лица считаются принадлежащим одному человеку. Чем меньше оно - тем больше шанс что разные лица будут назначены на одного человека. Чем выше - тем чаще система будет "не узнавать" человека и создавать новую персону. Но, если что, есть функция YDBService.joinPersons которая объединяет персоны.
// face-service.js const YandexVisionFaceDetector = require('./yandex-vision-face-detector'); const OnnxFaceEmbeddingService = require('./onnx-face-embedding-service'); const YDBService = require('./ydb-service'); const YandexDiskService = require('./yandex-disk-service'); const sharp = require('sharp'); const fs = require('fs'); const path = require('path'); class FaceService { constructor(visionApiKey, visionFolderId, ydbConnectionString, ydbDatabase, yServiceAccountKeyFile, yandexDiskOauthToken) { this.TEST_DIR = './testfiles'; this.SIMILAR_RATE = 0.35; this.visionDetector = new YandexVisionFaceDetector(visionApiKey, visionFolderId); this.embeddingService = new OnnxFaceEmbeddingService(); this.ydbService = new YDBService( ydbConnectionString, ydbDatabase, yServiceAccountKeyFile ); this.yandexDiskService = new YandexDiskService(yandexDiskOauthToken); this.isInitialized = false; // Настройки оптимизации изображения this.optimizationConfig = { maxFileSize: 4 * 1024 * 1024, // 4MB maxDimension: 2048, defaultQuality: 95, minQuality: 40 }; } /** * Инициализация сервиса */ async initialize(modelsPath = './models') { try { await this.embeddingService.initialize(modelsPath); await this.ydbService.initialize(); this.isInitialized = true; console.log('✅ FaceService инициализирован'); } catch (error) { console.error('❌ Ошибка инициализации FaceService:', error); throw error; } } /** * Оптимизация изображения для обработки */ async optimizeImage(imageBuffer) { const originalSize = imageBuffer.length; const originalSizeMB = (originalSize / 1024 / 1024).toFixed(2); console.log(`📏 Размер оригинального изображения: ${originalSizeMB} MB`); // Если изображение уже меньше лимита, возвращаем как есть if (originalSize <= this.optimizationConfig.maxFileSize) { console.log('✅ Изображение уже подходит по размеру'); return imageBuffer; } console.log('🔄 Оптимизируем изображение...'); // Получаем метаданные изображения const metadata = await sharp(imageBuffer).metadata(); console.log(` Исходные размеры: ${metadata.width}x${metadata.height}`); console.log(` Формат: ${metadata.format}`); let optimizedBuffer = imageBuffer; let quality = this.optimizationConfig.defaultQuality; let currentSize = originalSize; let attempts = 0; const maxAttempts = 5; // Сначала уменьшаем размеры если нужно if (metadata.width > this.optimizationConfig.maxDimension || metadata.height > this.optimizationConfig.maxDimension) { console.log(' Уменьшаем размеры изображения...'); optimizedBuffer = await sharp(imageBuffer) .resize( this.optimizationConfig.maxDimension, this.optimizationConfig.maxDimension, { fit: 'inside', withoutEnlargement: true } ) .jpeg({ quality: quality }) .toBuffer(); currentSize = optimizedBuffer.length; console.log(` Размер после ресайза: ${(currentSize / 1024 / 1024).toFixed(2)} MB`); } // Если все еще больше лимита, уменьшаем качество while (currentSize > this.optimizationConfig.maxFileSize && attempts < maxAttempts) { attempts++; quality -= 15; if (quality < this.optimizationConfig.minQuality) { quality = this.optimizationConfig.minQuality; } console.log(` Попытка ${attempts}: качество ${quality}%`); optimizedBuffer = await sharp(optimizedBuffer) .jpeg({ quality: quality, mozjpeg: true }) .toBuffer(); currentSize = optimizedBuffer.length; console.log(` Размер: ${(currentSize / 1024 / 1024).toFixed(2)} MB`); if (currentSize <= this.optimizationConfig.maxFileSize) { break; } } // Если все еще не влезает, используем более агрессивное сжатие if (currentSize > this.optimizationConfig.maxFileSize) { console.log(' Применяем агрессивное сжатие...'); optimizedBuffer = await sharp(imageBuffer) .resize(1024, 1024, { fit: 'inside', withoutEnlargement: true }) .jpeg({ quality: 60, chromaSubsampling: '4:2:0' }) .toBuffer(); currentSize = optimizedBuffer.length; console.log(` Финальный размер: ${(currentSize / 1024 / 1024).toFixed(2)} MB`); } const compressionRatio = ((originalSize - currentSize) / originalSize * 100).toFixed(1); console.log(`✅ Оптимизация завершена: сжатие ${compressionRatio}%`); return optimizedBuffer; } /** * Получение информации об изображении */ async getImageInfo(imageBuffer) { const metadata = await sharp(imageBuffer).metadata(); return { format: metadata.format, width: metadata.width, height: metadata.height, size: imageBuffer.length, sizeMB: (imageBuffer.length / 1024 / 1024).toFixed(2) }; } /** * Создание папки test если не существует */ ensureTestDir() { const testDir = this.TEST_DIR; if (!fs.existsSync(testDir)) { fs.mkdirSync(testDir, { recursive: true }); } return testDir; } /** * Сохранение вырезанного лица в файл */ async saveFaceImage(faceBuffer, index, timestamp) { const testDir = this.ensureTestDir(); const faceFilename = path.join(testDir, `face_${timestamp}_${index + 1}.jpg`); await sharp(faceBuffer) .jpeg({ quality: 90 }) .toFile(faceFilename); console.log(`💾 Сохранено лицо: ${faceFilename}`); return faceFilename; } /** * Вырезание и сохранение лиц (между детекцией и эмбеддингами) */ async extractAndSaveFaces(imageBuffer, faceDetections) { const timestamp = Date.now(); const faceBuffers = []; for (let i = 0; i < faceDetections.length; i++) { const detection = faceDetections[i]; try { // Вырезаем область лица const faceBuffer = await this.embeddingService.extractFaceRegion(imageBuffer, detection.bbox); // Сохраняем лицо в файл await this.saveFaceImage(faceBuffer, i, timestamp); faceBuffers.push({ buffer: faceBuffer, detection: detection }); } catch (error) { console.error(`❌ Ошибка сохранения лица ${i + 1}:`, error.message); } } return faceBuffers; } /** * Полная обработка: оптимизация -> детекция -> сохранение -> эмбеддинги */ async processFaces(imageBuffer, saveFaces = true) { if (!this.isInitialized) { throw new Error('❌ Сервис не инициализирован. Вызовите initialize() сначала.'); } try { // Шаг 1: Оптимизация изображения console.log('🔄 Оптимизация изображения...'); const optimizedImageBuffer = await this.optimizeImage(imageBuffer); const imageInfo = await this.getImageInfo(optimizedImageBuffer); console.log(`✅ Оптимизированное изображение: ${imageInfo.width}x${imageInfo.height}, ${imageInfo.sizeMB} MB`); // Шаг 2: Детекция лиц console.log('\n🔍 Детекция лиц через Yandex Vision...'); const faceDetections = await this.visionDetector.detectFaces(optimizedImageBuffer); console.log(`✅ Найдено лиц: ${faceDetections.length}`); if (faceDetections.length === 0) { return []; } // Шаг 3: Сохранение лиц let faceBuffers = []; if (saveFaces) { console.log('\n💾 Сохранение найденных лиц...'); faceBuffers = await this.extractAndSaveFaces(optimizedImageBuffer, faceDetections); console.log(`✅ Сохранено лиц: ${faceBuffers.length}`); } // Шаг 4: Получение эмбеддингов console.log('\n🧮 Получение эмбеддингов ...'); const results = []; for (let i = 0; i < faceDetections.length; i++) { const detection = faceDetections[i]; try { // Используем уже вырезанный буфер если сохраняли, иначе вырезаем заново const faceBuffer = saveFaces ? faceBuffers[i].buffer : await this.embeddingService.extractFaceRegion(optimizedImageBuffer, detection.bbox); const embedding = await this.embeddingService.getFaceEmbedding(faceBuffer); results.push({ bbox: detection.bbox, confidence: detection.confidence, embedding: embedding, faceImage: faceBuffer, landmarks: detection.landmarks, attributes: detection.attributes }); console.log(`✅ Получен эмбеддинг для лица ${i + 1}`); } catch (error) { console.error(`❌ Ошибка получения эмбеддинга для лица ${i + 1}:`, error.message); } } console.log(`🎉 Получено эмбеддингов: ${results.length}`); return results; } catch (error) { console.error('❌ Ошибка в processFaces:', error); throw error; } } /** * Только детекция лиц (с оптимизацией) */ async detectFacesOnly(imageBuffer) { if (!this.isInitialized) { throw new Error('❌ Сервис не инициализирован'); } console.log('🔄 Оптимизация изображения для детекции...'); const optimizedImageBuffer = await this.optimizeImage(imageBuffer); return await this.visionDetector.detectFaces(optimizedImageBuffer); } /** * Только получение эмбеддингов для уже обнаруженных лиц (с оптимизацией) */ async getEmbeddingsOnly(imageBuffer, faceDetections, saveFaces = true) { if (!this.isInitialized) { throw new Error('❌ Сервис не инициализирован'); } console.log('🔄 Оптимизация изображения для эмбеддингов...'); const optimizedImageBuffer = await this.optimizeImage(imageBuffer); let faceBuffers = []; /* if (saveFaces) { faceBuffers = await this.extractAndSaveFaces(optimizedImageBuffer, faceDetections); } */ const results = []; for (let i = 0; i < faceDetections.length; i++) { const detection = faceDetections[i]; try { const faceBuffer = saveFaces ? faceBuffers[i].buffer : await this.embeddingService.extractFaceRegion(optimizedImageBuffer, detection.bbox); const embedding = await this.embeddingService.getFaceEmbedding(faceBuffer); results.push({ bbox: detection.bbox, confidence: detection.confidence, embedding: embedding, faceImage: faceBuffer }); } catch (error) { console.error(`❌ Ошибка обработки лица ${i + 1}:`, error.message); } } return results; } /** Сохраняет данные о лице в YDB */ async saveFace(imagePath, face) { // поиск похожего лица в базе const similarFaces = await this.ydbService.findSimilarFaces(face.embedding, this.SIMILAR_RATE, 1); let personId; let similarFaceId = null; if (similarFaces.length > 0) { // если лицо найдено - используем его person_id и сохраняем similar_face_id personId = similarFaces[0].person_id; similarFaceId = similarFaces[0].face_id; console.log(` 🔍 В Базе найдены похожие лица: ${similarFaces[0].image_path} (схожесть: ${(similarFaces[0].ratio * 100).toFixed(1)}%)`); console.log(` 👤 Используем существующую персону: person_id=${personId}, similar_face_id=${similarFaceId}`); } else { // если не найдено - создаем новую персону console.log(` ℹ️ В Базе не найдены похожие лица`); personId = await this.ydbService.savePerson({ name: 'Face from ' + imagePath, custom_name: 'Face from ' + imagePath }); console.log(` ✨ Создана новая персона: person_id=${personId}`); } await this.ydbService.saveFace({ person_id: personId, image_path: imagePath, embedding: face.embedding, confidence: face.confidence, bbox: face.bbox, similar_face_id: similarFaceId }); } /** * Обработка одного файла с Яндекс.Диска * @param {Object} file - Объект файла с Яндекс.Диска * @returns {Promise<Object|null>} - Результат обработки или null при ошибке */ async processDiskFile(file) { try { console.log(`\n 🔄 Обработка файла: ${file.name}`); // Проверяем, есть ли уже лица для этого изображения в базе console.log(` 🔍 Проверка наличия в базе...`); const existingFaces = await this.ydbService.findFacesByImagePath(file.path); if (existingFaces.length > 0) { console.log(` ⏭️ Файл уже обработан (найдено ${existingFaces.length} лиц в базе)`); return { file: file.name, facesCount: 0, skipped: true }; } // Скачиваем файл console.log(` 📥 Скачивание...`); const fileData = await this.yandexDiskService.downloadFile(file.path); if (!fileData) { console.log(` ❌ Не удалось скачать файл`); return null; } console.log(` ✅ Файл скачан: ${(fileData.size / 1024).toFixed(2)} KB`); // Обрабатываем лица в изображении const faces = await this.processFaces(fileData.buffer, false); if (faces.length === 0) { console.log(` ℹ️ Лица не найдены`); return { file: file.name, facesCount: 0 }; } console.log(` 👥 Найдено лиц: ${faces.length}`); // Сохраняем каждое найденное лицо for (let i = 0; i < faces.length; i++) { console.log(` 💾 Сохранение лица ${i + 1}/${faces.length}...`); await this.saveFace(file.path, faces[i]); } console.log(` ✅ Обработка завершена: ${faces.length} лиц сохранено`); return { file: file.name, facesCount: faces.length }; } catch (error) { console.error(` ❌ Ошибка обработки файла ${file.name}:`, error.message); return null; } } /** * Обработка папки на Яндекс.Диске * @param {string} folderPath - Путь к папке на Яндекс.Диске * @param {number} limit - Максимальное количество файлов для обработки * @returns {Promise<Object>} - Статистика обработки */ async processDiskFolder(folderPath, limit = 100) { if (!this.isInitialized) { throw new Error('❌ Сервис не инициализирован. Вызовите initialize() сначала.'); } const startTime = Date.now(); console.log('📁 ОБРАБОТКА ПАПКИ НА ЯНДЕКС.ДИСКЕ'); console.log(` Путь: ${folderPath}`); console.log(` Лимит файлов: ${limit}\n`); try { // Получаем список файлов console.log('🔍 Получение списка файлов...'); const files = await this.yandexDiskService.getFilesList(folderPath, limit); if (files.length === 0) { console.log('❌ Файлы не найдены'); return { totalFiles: 0, processedFiles: 0, totalFaces: 0, duration: 0 }; } console.log(`✅ Найдено файлов: ${files.length}\n`); const stats = { totalFiles: files.length, processedFiles: 0, skippedFiles: 0, totalFaces: 0, errors: 0 }; // Обрабатываем каждый файл for (let i = 0; i < files.length; i++) { const file = files[i]; console.log(`\n${'='.repeat(60)}`); console.log(`📄 Файл ${i + 1}/${files.length}: ${file.name}`); console.log('='.repeat(60)); const result = await this.processDiskFile(file); if (result) { if (result.skipped) { stats.skippedFiles++; } else { stats.processedFiles++; stats.totalFaces += result.facesCount; } } else { stats.errors++; } } // Вычисляем время работы const endTime = Date.now(); const durationMs = endTime - startTime; const durationSec = (durationMs / 1000).toFixed(2); const durationMin = (durationMs / 60000).toFixed(2); stats.durationMs = durationMs; stats.durationSec = durationSec; stats.durationMin = durationMin; // Выводим итоговую статистику console.log('\n' + '═'.repeat(60)); console.log('📊 ИТОГОВАЯ СТАТИСТИКА'); console.log('═'.repeat(60)); console.log(` Всего файлов: ${stats.totalFiles}`); console.log(` Обработано: ${stats.processedFiles}`); console.log(` Пропущено: ${stats.skippedFiles}`); console.log(` Ошибок: ${stats.errors}`); console.log(` Всего найдено лиц: ${stats.totalFaces}`); console.log(` Время работы: ${durationMin} мин (${durationSec} сек)`); console.log('═'.repeat(60)); return stats; } catch (error) { console.error('❌ Ошибка обработки папки:', error.message); throw error; } } } module.exports = FaceService;
process-disk-folder.js - функция main которая это все запускает.
// process-disk-folder.js require('dotenv').config(); const FaceService = require('./face-service'); async function main() { // Проверяем аргументы командной строки if (process.argv.length < 3) { console.log('❌ Использование: node process-disk-folder.js <folder_path> [limit]'); console.log(' Пример: node process-disk-folder.js /testphoto 50'); console.log(' Пример: node process-disk-folder.js /photos (обрабатывает все файлы)'); console.log(''); console.log(' folder_path - путь к папке на Яндекс.Диске'); console.log(' limit - максимальное количество файлов для обработки (по умолчанию: все файлы)'); process.exit(1); } const folderPath = process.argv[2]; const limit = process.argv[3] ? parseInt(process.argv[3]) : 10000; // Валидация лимита if (process.argv[3] && (isNaN(limit) || limit <= 0)) { console.error('❌ Ошибка: limit должен быть положительным числом'); process.exit(1); } console.log('🚀 ОБРАБОТКА ПАПКИ С ЯНДЕКС.ДИСКА\n'); console.log(` Папка: ${folderPath}`); if (process.argv[3]) { console.log(` Лимит: ${limit} файлов\n`); } else { console.log(` Лимит: все файлы\n`); } // Валидация обязательных переменных окружения const requiredEnvVars = [ 'YANDEX_OAUTH_TOKEN', 'YANDEX_CLOUD_API_KEY', 'SERVICE_ACCOUNT_KEY_FILE', 'YANDEX_FOLDER_ID', 'YDB_CONNECTION_STRING', 'YDB_DATABASE' ]; const missingVars = requiredEnvVars.filter(varName => !process.env[varName]); if (missingVars.length > 0) { console.error('❌ Отсутствуют обязательные переменные окружения:'); missingVars.forEach(varName => console.error(` - ${varName}`)); console.error('\nПожалуйста, проверьте файл .env'); process.exit(1); } // Инициализация FaceService const faceService = new FaceService( process.env.YANDEX_CLOUD_API_KEY, process.env.YANDEX_FOLDER_ID, process.env.YDB_CONNECTION_STRING, process.env.YDB_DATABASE, process.env.SERVICE_ACCOUNT_KEY_FILE, process.env.YANDEX_OAUTH_TOKEN ); try { console.log('🔄 Инициализация сервисов...'); await faceService.initialize(); console.log('✅ Сервисы инициализированы\n'); // Обрабатываем папку const stats = await faceService.processDiskFolder(folderPath, limit); console.log('\n✅ Обработка завершена!'); console.log(` Обработано файлов: ${stats.processedFiles}/${stats.totalFiles}`); console.log(` Пропущено файлов: ${stats.skippedFiles}`); console.log(` Найдено лиц: ${stats.totalFaces}`); console.log(` Время работы: ${stats.durationMin} мин (${stats.durationSec} сек)`); if (stats.errors > 0) { console.log(` ⚠️ Ошибок: ${stats.errors}`); } } catch (error) { console.error('\n❌ Ошибка при обработке:', error.message); console.error(error); process.exit(1); } finally { await faceService.ydbService.destroy(); } } // Обработка graceful shutdown process.on('SIGINT', async () => { console.log('\n👋 Прерывание работы...'); process.exit(0); }); process.on('SIGTERM', async () => { console.log('\n🔚 Получен сигнал завершения...'); process.exit(0); }); main().catch(console.error);
Если вдруг кто-то решит воспользоваться надо выполнить следующие шаги:
Скачать модель insightfaces.onnx в папку ./models
Создать .env. c необходимыми переменными среды
# Яндекс OAuth токен для доступа к Диску YANDEX_OAUTH_TOKEN= # Путь к файлу с приватным ключом сервисного аккаунта - для работы с YDB SERVICE_ACCOUNT_KEY_FILE=./key.json # API Ключ для работы с VISION YANDEX_CLOUD_API_KEY= # Service Account ID SERVICE_ACCOUNT_ID= # Folder ID в Yandex Cloud YANDEX_FOLDER_ID= # YDB настройки YDB_CONNECTION_STRING=grpcs://ydb.serverless.yandexcloud.net:2135 YDB_DATABASE=
Вызвать node
process-disk-folder.js /SomeFolder/On/YandexDisk- и запастись попкорном.
Кстати, если вдруг для данной задачи есть какое-то более простое решение (AWS Rekognition не предлагать) - буду благодарен комментариям!
