«Крошка сын к отцу пришел и спросила кроха...»
Ну не сын на самом деле, а дочка, но пришла и спросила: «Паааап, у подруги тут ДР, вытащи мне из фотоархива все фото где мы с ней вместе». Да легко!
Но тут выяснилось, что и не так то легко. Дело в том, что еще в 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 не предлагать) - буду благодарен комментариям!
Комментарии (7)

freeExec
23.10.2025 10:49Это новый тренд устраивать гитхаб из статьи?

Olegun
23.10.2025 10:49Автор не доверяет иностранным сервисам кроме хабра.

akakunin Автор
23.10.2025 10:49Не совсем. Под эти эксперименты я сделал репы в SourceCraft Яндекса (заодно и его тестирую). Но там есть нюансы с созданием открытых репозиториев - надо менять настройки всей организации, пока не стал на это заморачиваться. Кода не много, поэтому решил в статью сразу выложить
positroid
Справедливости ради есть, но нашёл только в мобильном приложении на вкладке альбомов - само создаёт подборки с часто встречаемыми персонажами. Работает не идеально, пересечение по нескольким людям, как в посте, не посмотришь, но хоть что-то.
akakunin Автор
Нет, не нашел у себя такого :( Может еще зависит от версии (у меня Android)
positroid
Нашел на QA яндекса такую инфу: альбом создается сам и доступен по ссылке - https://disk.yandex.ru/client/albums/faces. Условия такие (на 2021 год):
Альбом лица создаётся, если на Диске не менее 10 фотографий одного и того же человека;
только для пользователей из России, Украины, Казахстана и Белоруссии.
Судя по комментариям какая-то сырая фича не для всех
JBFW
То есть, Яндекс сканирует загружаемые фото на часто встречаемых персонажей.
Ок, это интересная информация, буду ее думать (особенно в свете привязки к YandexID)