/** * PhotoService — 多模态资产关联服务 * * 功能: * detectFaces(imagePath) → 调用 Python face_recognition,返回人脸数组 * linkPhotoToPerson(imagePath, nameOrId, meta) → 关联照片到联系人(Asset:photo 节点 + CONTAINS 边) * extractExif(imagePath) → 提取 JPEG EXIF GPS/时间信息 */ const { spawn } = require('child_process'); const path = require('path'); const fs = require('fs'); const { getGraphStore } = require('../../db/GraphStore'); class PhotoService { constructor(photoDirOrFile) { this.photoDirOrFile = photoDirOrFile; } /** * 调用 Python face_recognition,返回人脸列表 * @param {string} imagePath * @returns {Promise>} */ async detectFaces(imagePath) { return new Promise((resolve, reject) => { const scriptPath = path.join(__dirname, 'face_recognizer.py'); const py = spawn('python3', [scriptPath, 'detect', imagePath]); let out = ''; let errOut = ''; py.stdout.on('data', d => { out += d; }); py.stderr.on('data', d => { errOut += d; }); py.on('close', code => { if (code === 0) { try { resolve(JSON.parse(out)); } catch (e) { resolve([]); // 解析失败时返回空数组(graceful fallback) } } else { reject(new Error('face_recognizer failed: ' + (errOut || out))); } }); py.on('error', err => { // python3 不存在时 graceful fallback resolve([]); }); }); } /** * 将照片作为 Asset 节点关联到指定 Person * @param {string} imagePath - 照片文件绝对路径 * @param {string} personNameOrId - 联系人姓名或节点 id * @param {object} meta - 额外元数据(可选) * @returns {Promise<{assetId, personId, exif}>} */ async linkPhotoToPerson(imagePath, personNameOrId, meta = {}) { const g = getGraphStore(); // 查找联系人 let person = g.getNode(personNameOrId); if (!person || person._type !== 'Person') { const results = g.findByName(personNameOrId, 'Person'); person = results[0] || null; } if (!person) { throw new Error(`未找到联系人:${personNameOrId}`); } // 提取 EXIF let exif = null; try { exif = await this.extractExif(imagePath); } catch { /* EXIF 提取失败不阻断主流程 */ } // 创建 Asset:photo 节点 const fileName = path.basename(imagePath); const assetData = { type: 'photo', name: fileName, filePath: imagePath, source: 'local', ...meta, }; if (exif) { if (exif.lat !== undefined && exif.lng !== undefined) { assetData.lat = exif.lat; assetData.lng = exif.lng; } if (exif.datetime) { assetData.takenAt = exif.datetime; } } const asset = g.createNode('Asset', assetData); // 创建 CONTAINS 边(Person → Asset) g.createEdge(person.id, asset.id, 'CONTAINS'); return { assetId: asset.id, personId: person.id, personName: person.name, exif, }; } /** * 简单解析 JPEG EXIF GPS 数据(纯 Node.js,不依赖外部库) * * 仅读取 APP1 段中的 GPS IFD,提取纬度、经度、拍摄时间。 * 如果文件不是 JPEG 或没有 GPS 数据,返回 null。 * * @param {string} imagePath * @returns {Promise<{lat, lng, datetime}|null>} */ async extractExif(imagePath) { return new Promise((resolve) => { try { const fd = fs.openSync(imagePath, 'r'); // 读取前 64KB 以覆盖 EXIF 段 const bufSize = 65536; const buf = Buffer.alloc(bufSize); const bytesRead = fs.readSync(fd, buf, 0, bufSize, 0); fs.closeSync(fd); const data = buf.slice(0, bytesRead); // JPEG 魔数检查 if (data[0] !== 0xFF || data[1] !== 0xD8) { resolve(null); return; } // 查找 APP1 段(0xFF 0xE1) let offset = 2; let exifBuf = null; while (offset < data.length - 4) { if (data[offset] !== 0xFF) break; const marker = data[offset + 1]; const segLen = data.readUInt16BE(offset + 2); if (marker === 0xE1) { // 检查 'Exif\0\0' 签名 if (data.slice(offset + 4, offset + 10).toString('ascii') === 'Exif\0\0') { exifBuf = data.slice(offset + 10, offset + 2 + segLen); break; } } offset += 2 + segLen; } if (!exifBuf) { resolve(null); return; } // 解析 TIFF 字节序 const isLittleEndian = exifBuf.slice(0, 2).toString('ascii') === 'II'; const readUInt16 = (buf, off) => isLittleEndian ? buf.readUInt16LE(off) : buf.readUInt16BE(off); const readUInt32 = (buf, off) => isLittleEndian ? buf.readUInt32LE(off) : buf.readUInt32BE(off); // IFD0 偏移 const ifd0Offset = readUInt32(exifBuf, 4); const numEntries = readUInt16(exifBuf, ifd0Offset); let gpsIFDOffset = null; let dateTimeStr = null; for (let i = 0; i < numEntries; i++) { const entryOffset = ifd0Offset + 2 + i * 12; if (entryOffset + 12 > exifBuf.length) break; const tag = readUInt16(exifBuf, entryOffset); if (tag === 0x8825) { // GPSInfo IFD pointer gpsIFDOffset = readUInt32(exifBuf, entryOffset + 8); } else if (tag === 0x0132) { // DateTime (ASCII) const count = readUInt32(exifBuf, entryOffset + 4); const valOff = count <= 4 ? entryOffset + 8 : readUInt32(exifBuf, entryOffset + 8); try { dateTimeStr = exifBuf.slice(valOff, valOff + 19).toString('ascii'); } catch { /* skip */ } } } if (!gpsIFDOffset) { resolve(dateTimeStr ? { datetime: dateTimeStr } : null); return; } // 解析 GPS IFD let lat = null, lng = null, latRef = 'N', lngRef = 'E'; const gpsEntries = readUInt16(exifBuf, gpsIFDOffset); for (let i = 0; i < gpsEntries; i++) { const entryOffset = gpsIFDOffset + 2 + i * 12; if (entryOffset + 12 > exifBuf.length) break; const tag = readUInt16(exifBuf, entryOffset); const type = readUInt16(exifBuf, entryOffset + 2); const count = readUInt32(exifBuf, entryOffset + 4); const valOff = count * (type === 5 ? 8 : 1) > 4 ? readUInt32(exifBuf, entryOffset + 8) : entryOffset + 8; if (tag === 0x0001) { // GPSLatitudeRef latRef = exifBuf.slice(valOff, valOff + 1).toString('ascii'); } else if (tag === 0x0003) { // GPSLongitudeRef lngRef = exifBuf.slice(valOff, valOff + 1).toString('ascii'); } else if (tag === 0x0002 && type === 5 && count === 3) { // GPSLatitude (3 rationals: degrees, minutes, seconds) lat = this._parseGPSRational(exifBuf, valOff, readUInt32); } else if (tag === 0x0004 && type === 5 && count === 3) { // GPSLongitude lng = this._parseGPSRational(exifBuf, valOff, readUInt32); } } const result = {}; if (lat !== null) result.lat = latRef === 'S' ? -lat : lat; if (lng !== null) result.lng = lngRef === 'W' ? -lng : lng; if (dateTimeStr) result.datetime = dateTimeStr; resolve(Object.keys(result).length > 0 ? result : null); } catch (e) { resolve(null); // 任何错误都 graceful fallback } }); } /** 解析 GPS 有理数(度/分/秒 → 十进制度) */ _parseGPSRational(buf, offset, readUInt32) { try { const degN = readUInt32(buf, offset); const degD = readUInt32(buf, offset + 4); const minN = readUInt32(buf, offset + 8); const minD = readUInt32(buf, offset + 12); const secN = readUInt32(buf, offset + 16); const secD = readUInt32(buf, offset + 20); const deg = degD ? degN / degD : 0; const min = minD ? minN / minD : 0; const sec = secD ? secN / secD : 0; return deg + min / 60 + sec / 3600; } catch { return null; } } } module.exports = { PhotoService };