photo-service.js 9.85 KB
/**
 * 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<Array<{faceId, bbox, encoding}>>}
     */
    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 };