From 22aecea2637bdcb6d17b34e54d8b4c9b46211275 Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Sat, 12 Jan 2019 16:41:45 +0100 Subject: [PATCH] Implementing XMP face region parsing --- backend/model/sql/GalleryManager.ts | 104 ++++++++++++++++-- backend/model/sql/SQLConnection.ts | 8 +- backend/model/sql/enitites/FaceRegionEntry.ts | 38 +++++++ backend/model/sql/enitites/MediaEntity.ts | 7 +- backend/model/sql/enitites/PersonEntry.ts | 17 +++ backend/model/sql/enitites/PhotoEntity.ts | 13 ++- backend/model/threading/MetadataLoader.ts | 51 ++++++++- common/DataStructureVersion.ts | 2 +- common/entities/PhotoDTO.ts | 13 +++ package.json | 5 +- .../unit/assets/test image öüóőúéáű-.,.jpg | Bin 62786 -> 59011 bytes 11 files changed, 240 insertions(+), 18 deletions(-) create mode 100644 backend/model/sql/enitites/FaceRegionEntry.ts create mode 100644 backend/model/sql/enitites/PersonEntry.ts diff --git a/backend/model/sql/GalleryManager.ts b/backend/model/sql/GalleryManager.ts index 752eb13d..45e948a2 100644 --- a/backend/model/sql/GalleryManager.ts +++ b/backend/model/sql/GalleryManager.ts @@ -5,13 +5,13 @@ import * as fs from 'fs'; import {DirectoryEntity} from './enitites/DirectoryEntity'; import {SQLConnection} from './SQLConnection'; import {DiskManager} from '../DiskManger'; -import {PhotoEntity} from './enitites/PhotoEntity'; +import {PhotoEntity, PhotoMetadataEntity} from './enitites/PhotoEntity'; import {Utils} from '../../../common/Utils'; import {ProjectPath} from '../../ProjectPath'; import {Config} from '../../../common/config/private/Config'; import {ISQLGalleryManager} from './IGalleryManager'; import {DatabaseType, ReIndexingSensitivity} from '../../../common/config/private/IPrivateConfig'; -import {PhotoDTO} from '../../../common/entities/PhotoDTO'; +import {FaceRegion, PhotoDTO, PhotoMetadata} from '../../../common/entities/PhotoDTO'; import {OrientationType} from '../../../common/entities/RandomQueryDTO'; import {Brackets, Connection, Transaction, TransactionRepository, Repository} from 'typeorm'; import {MediaEntity} from './enitites/MediaEntity'; @@ -22,6 +22,8 @@ import {FileDTO} from '../../../common/entities/FileDTO'; import {NotificationManager} from '../NotifocationManager'; import {DiskMangerWorker} from '../threading/DiskMangerWorker'; import {Logger} from '../../Logger'; +import {FaceRegionEntry} from './enitites/FaceRegionEntry'; +import {PersonEntry} from './enitites/PersonEntry'; const LOG_TAG = '[GalleryManager]'; @@ -50,11 +52,23 @@ export class GalleryManager implements IGalleryManager, ISQLGalleryManager { protected async fillParentDir(connection: Connection, dir: DirectoryEntity): Promise { if (dir.media) { + const indexedFaces = await connection.getRepository(FaceRegionEntry) + .createQueryBuilder('face') + .leftJoinAndSelect('face.media', 'media') + .where('media.directory = :directory', { + directory: dir.id + }) + .leftJoinAndSelect('face.person', 'person') + .getMany(); for (let i = 0; i < dir.media.length; i++) { dir.media[i].directory = dir; dir.media[i].readyThumbnails = []; dir.media[i].readyIcon = false; + (dir.media[i]).metadata.faces = indexedFaces + .filter(fe => fe.media.id === dir.media[i].id) + .map(f => ({box: f.box, name: f.person.name})); } + } if (dir.directories) { for (let i = 0; i < dir.directories.length; i++) { @@ -235,6 +249,7 @@ export class GalleryManager implements IGalleryManager, ISQLGalleryManager { } protected async saveToDB(scannedDirectory: DirectoryDTO) { + console.log('saving'); this.isSaving = true; try { const connection = await SQLConnection.getConnection(); @@ -259,7 +274,7 @@ export class GalleryManager implements IGalleryManager, ISQLGalleryManager { currentDir = await directoryRepository.save(scannedDirectory); } - + // TODO: fix when first opened directory is not root // save subdirectories const childDirectories = await directoryRepository.createQueryBuilder('directory') .where('directory.parent = :dir', { @@ -299,10 +314,11 @@ export class GalleryManager implements IGalleryManager, ISQLGalleryManager { await directoryRepository.remove(childDirectories, {chunk: Math.max(Math.ceil(childDirectories.length / 500), 1)}); // save media - const indexedMedia = await mediaRepository.createQueryBuilder('media') + const indexedMedia = (await mediaRepository.createQueryBuilder('media') .where('media.directory = :dir', { dir: currentDir.id - }).getMany(); + }) + .getMany()); const mediaToSave = []; @@ -315,18 +331,30 @@ export class GalleryManager implements IGalleryManager, ISQLGalleryManager { break; } } + + if (media == null) { // not in DB yet scannedDirectory.media[i].directory = null; media = Utils.clone(scannedDirectory.media[i]); scannedDirectory.media[i].directory = scannedDirectory; media.directory = currentDir; mediaToSave.push(media); - } else if (!Utils.equalsFilter(media.metadata, scannedDirectory.media[i].metadata)) { - media.metadata = scannedDirectory.media[i].metadata; - mediaToSave.push(media); + } else { + delete (media.metadata).faces; + + if (!Utils.equalsFilter(media.metadata, scannedDirectory.media[i].metadata)) { + media.metadata = scannedDirectory.media[i].metadata; + mediaToSave.push(media); + } } + const scannedFaces = (scannedDirectory.media[i].metadata).faces; + delete (scannedDirectory.media[i].metadata).faces; + + const mediaEntry = await this.saveAMedia(connection, media); + + await this.saveFaces(connection, mediaEntry, scannedFaces); } - await this.saveMedia(connection, mediaToSave); + // await this.saveMedia(connection, mediaToSave); await mediaRepository.remove(indexedMedia); @@ -364,6 +392,64 @@ export class GalleryManager implements IGalleryManager, ISQLGalleryManager { } } + protected async saveFaces(connection: Connection, media: MediaEntity, scannedFaces: FaceRegion[]) { + + const faceRepository = connection.getRepository(FaceRegionEntry); + const personRepository = connection.getRepository(PersonEntry); + const indexedPersons = await personRepository.createQueryBuilder('person').getMany(); + + const indexedFaces = await faceRepository.createQueryBuilder('face') + .where('face.media = :media', { + media: media.id + }) + .leftJoinAndSelect('face.person', 'person') + .getMany(); + + + const getPerson = async (name: string) => { + let person = indexedPersons.find(p => p.name === name); + if (!person) { + person = await personRepository.save({name: name}); + indexedPersons.push(person); + } + return person; + }; + + const faceToSave = []; + for (let i = 0; i < scannedFaces.length; i++) { + let face: FaceRegionEntry = null; + for (let j = 0; j < indexedFaces.length; j++) { + if (indexedFaces[j].box.height === scannedFaces[i].box.height && + indexedFaces[j].box.width === scannedFaces[i].box.width && + indexedFaces[j].box.x === scannedFaces[i].box.x && + indexedFaces[j].box.y === scannedFaces[i].box.y && + indexedFaces[j].person.name === scannedFaces[i].name) { + face = indexedFaces[j]; + indexedFaces.splice(j, 1); + break; + } + } + + if (face == null) { + (scannedFaces[i]).person = await getPerson(scannedFaces[i].name); + (scannedFaces[i]).media = media; + // console.log('inserting', (scannedFaces[i]).person, (scannedFaces[i]).media); + // console.log('inserting', (scannedFaces[i]).person.id, (scannedFaces[i]).media.id); + faceToSave.push(scannedFaces[i]); + } + } + await faceRepository.save(faceToSave, {chunk: Math.max(Math.ceil(faceToSave.length / 500), 1)}); + await faceRepository.remove(indexedFaces, {chunk: Math.max(Math.ceil(indexedFaces.length / 500), 1)}); + + } + + protected async saveAMedia(connection: Connection, media: MediaDTO): Promise { + if (MediaDTO.isPhoto(media)) { + return await connection.getRepository(PhotoEntity).save(media); + } + return await connection.getRepository(VideoEntity).save(media); + } + protected async saveMedia(connection: Connection, mediaList: MediaDTO[]): Promise { const chunked = Utils.chunkArrays(mediaList, 100); let list: MediaEntity[] = []; diff --git a/backend/model/sql/SQLConnection.ts b/backend/model/sql/SQLConnection.ts index 0afc79f9..658e863a 100644 --- a/backend/model/sql/SQLConnection.ts +++ b/backend/model/sql/SQLConnection.ts @@ -15,6 +15,8 @@ import {MediaEntity} from './enitites/MediaEntity'; import {VideoEntity} from './enitites/VideoEntity'; import {DataStructureVersion} from '../../../common/DataStructureVersion'; import {FileEntity} from './enitites/FileEntity'; +import {FaceRegionEntry} from './enitites/FaceRegionEntry'; +import {PersonEntry} from './enitites/PersonEntry'; export class SQLConnection { @@ -32,6 +34,8 @@ export class SQLConnection { options.entities = [ UserEntity, FileEntity, + FaceRegionEntry, + PersonEntry, MediaEntity, PhotoEntity, VideoEntity, @@ -40,7 +44,7 @@ export class SQLConnection { VersionEntity ]; options.synchronize = false; - // options.logging = 'all'; + options.logging = 'all'; this.connection = await createConnection(options); await SQLConnection.schemeSync(this.connection); } @@ -57,6 +61,8 @@ export class SQLConnection { options.entities = [ UserEntity, FileEntity, + FaceRegionEntry, + PersonEntry, MediaEntity, PhotoEntity, VideoEntity, diff --git a/backend/model/sql/enitites/FaceRegionEntry.ts b/backend/model/sql/enitites/FaceRegionEntry.ts new file mode 100644 index 00000000..f9d1afd1 --- /dev/null +++ b/backend/model/sql/enitites/FaceRegionEntry.ts @@ -0,0 +1,38 @@ +import {FaceRegionBox} from '../../../../common/entities/PhotoDTO'; +import {Column, ManyToOne, Entity, PrimaryGeneratedColumn} from 'typeorm'; +import {PersonEntry} from './PersonEntry'; +import {MediaEntity, MediaMetadataEntity} from './MediaEntity'; + +export class FaceRegionBoxEntry implements FaceRegionBox { + @Column('int') + height: number; + @Column('int') + width: number; + @Column('int') + x: number; + @Column('int') + y: number; +} + +/** + * This is a switching table between media and persons + */ +@Entity() +export class FaceRegionEntry { + + @PrimaryGeneratedColumn() + id: number; + + @Column(type => FaceRegionBoxEntry) + box: FaceRegionBoxEntry; + + // @PrimaryColumn('int') + @ManyToOne(type => MediaEntity, media => media.metadata.faces, {onDelete: 'CASCADE', nullable: false}) + media: MediaEntity; + + // @PrimaryColumn('int') + @ManyToOne(type => PersonEntry, person => person.faces, {onDelete: 'CASCADE', nullable: false}) + person: PersonEntry; + + name: string; +} diff --git a/backend/model/sql/enitites/MediaEntity.ts b/backend/model/sql/enitites/MediaEntity.ts index 2a1aa00e..026290d3 100644 --- a/backend/model/sql/enitites/MediaEntity.ts +++ b/backend/model/sql/enitites/MediaEntity.ts @@ -1,8 +1,9 @@ -import {Column, Entity, ManyToOne, PrimaryGeneratedColumn, TableInheritance, Unique, Index} from 'typeorm'; +import {Column, Entity, OneToMany, ManyToOne, PrimaryGeneratedColumn, TableInheritance, Unique, Index} from 'typeorm'; import {DirectoryEntity} from './DirectoryEntity'; import {MediaDimension, MediaDTO, MediaMetadata} from '../../../../common/entities/MediaDTO'; import {OrientationTypes} from 'ts-exif-parser'; import {CameraMetadataEntity, PositionMetaDataEntity} from './PhotoEntity'; +import {FaceRegionEntry} from './FaceRegionEntry'; export class MediaDimensionEntity implements MediaDimension { @@ -27,7 +28,6 @@ export class MediaMetadataEntity implements MediaMetadata { @Column('int') fileSize: number; - @Column('simple-array') keywords: string[]; @@ -40,6 +40,9 @@ export class MediaMetadataEntity implements MediaMetadata { @Column('tinyint', {default: OrientationTypes.TOP_LEFT}) orientation: OrientationTypes; + @OneToMany(type => FaceRegionEntry, faceRegion => faceRegion.media) + faces: FaceRegionEntry[]; + @Column('int') bitRate: number; diff --git a/backend/model/sql/enitites/PersonEntry.ts b/backend/model/sql/enitites/PersonEntry.ts new file mode 100644 index 00000000..d539bd5e --- /dev/null +++ b/backend/model/sql/enitites/PersonEntry.ts @@ -0,0 +1,17 @@ +import {Column, Entity, OneToMany, PrimaryGeneratedColumn, Unique, Index} from 'typeorm'; +import {FaceRegionEntry} from './FaceRegionEntry'; + + +@Entity() +@Unique(['name']) +export class PersonEntry { + @Index() + @PrimaryGeneratedColumn() + id: number; + + @Column() + name: string; + + @OneToMany(type => FaceRegionEntry, faceRegion => faceRegion.person) + public faces: FaceRegionEntry[]; +} diff --git a/backend/model/sql/enitites/PhotoEntity.ts b/backend/model/sql/enitites/PhotoEntity.ts index fa9be6f1..4fe49156 100644 --- a/backend/model/sql/enitites/PhotoEntity.ts +++ b/backend/model/sql/enitites/PhotoEntity.ts @@ -1,6 +1,13 @@ import {Column, Entity, ChildEntity, Unique} from 'typeorm'; -import {CameraMetadata, GPSMetadata, PhotoDTO, PhotoMetadata, PositionMetaData} from '../../../../common/entities/PhotoDTO'; -import {OrientationTypes} from 'ts-exif-parser'; +import { + CameraMetadata, + FaceRegion, + FaceRegionBox, + GPSMetadata, + PhotoDTO, + PhotoMetadata, + PositionMetaData +} from '../../../../common/entities/PhotoDTO'; import {MediaEntity, MediaMetadataEntity} from './MediaEntity'; export class CameraMetadataEntity implements CameraMetadata { @@ -38,6 +45,7 @@ export class GPSMetadataEntity implements GPSMetadata { altitude: number; } + export class PositionMetaDataEntity implements PositionMetaData { @Column(type => GPSMetadataEntity) @@ -75,5 +83,4 @@ export class PhotoMetadataEntity extends MediaMetadataEntity implements PhotoMet export class PhotoEntity extends MediaEntity implements PhotoDTO { @Column(type => PhotoMetadataEntity) metadata: PhotoMetadataEntity; - } diff --git a/backend/model/threading/MetadataLoader.ts b/backend/model/threading/MetadataLoader.ts index 16e506e0..27123fc8 100644 --- a/backend/model/threading/MetadataLoader.ts +++ b/backend/model/threading/MetadataLoader.ts @@ -1,5 +1,5 @@ import {VideoMetadata} from '../../../common/entities/VideoDTO'; -import {PhotoMetadata} from '../../../common/entities/PhotoDTO'; +import {FaceRegion, PhotoMetadata} from '../../../common/entities/PhotoDTO'; import {Config} from '../../../common/config/private/Config'; import {Logger} from '../../Logger'; import * as fs from 'fs'; @@ -9,6 +9,14 @@ import {IptcParser} from 'ts-node-iptc'; import {FFmpegFactory} from '../FFmpegFactory'; import {FfprobeData} from 'fluent-ffmpeg'; +// TODO: fix up different metadata loaders +// @ts-ignore +global.DataView = require('jdataview'); +// @ts-ignore +global.DOMParser = require('xmldom').DOMParser; +// @ts-ignore +const ExifReader = require('exifreader'); + const LOG_TAG = '[MetadataLoader]'; const ffmpeg = FFmpegFactory.get(); @@ -157,6 +165,47 @@ export class MetadataLoader { metadata.creationDate = metadata.creationDate || 0; + + try { + const ret = ExifReader.load(data); + const faces: FaceRegion[] = []; + if (ret.Regions && ret.Regions.value.RegionList && ret.Regions.value.RegionList.value) { + for (let i = 0; i < ret.Regions.value.RegionList.value.length; i++) { + if (!ret.Regions.value.RegionList.value[i].value || + !ret.Regions.value.RegionList.value[i].value['rdf:Description'] || + !ret.Regions.value.RegionList.value[i].value['rdf:Description'].value || + !ret.Regions.value.RegionList.value[i].value['rdf:Description'].value['mwg-rs:Area']) { + continue; + } + const region = ret.Regions.value.RegionList.value[i].value['rdf:Description']; + const regionBox = ret.Regions.value.RegionList.value[i].value['rdf:Description'].value['mwg-rs:Area'].attributes; + if (region.attributes['mwg-rs:Type'] !== 'Face' || + !region.attributes['mwg-rs:Name']) { + continue; + } + const name = region.attributes['mwg-rs:Name']; + const box = { + width: Math.round(regionBox['stArea:w'] * metadata.size.width), + height: Math.round(regionBox['stArea:h'] * metadata.size.height), + x: Math.round(regionBox['stArea:x'] * metadata.size.width), + y: Math.round(regionBox['stArea:y'] * metadata.size.height) + }; + faces.push({name: name, box: box}); + } + } + if (faces.length > 0) { + metadata.faces = faces; // save faces + // remove faces from keywords + metadata.faces.forEach(f => { + const index = metadata.keywords.indexOf(f.name); + if (index !== -1) { + metadata.keywords.splice(index, 1); + } + }); + } + } catch (err) { + } + return resolve(metadata); } catch (err) { return reject({file: fullPath, error: err}); diff --git a/common/DataStructureVersion.ts b/common/DataStructureVersion.ts index 9313616e..5611455a 100644 --- a/common/DataStructureVersion.ts +++ b/common/DataStructureVersion.ts @@ -1 +1 @@ -export const DataStructureVersion = 7; +export const DataStructureVersion = 8; diff --git a/common/entities/PhotoDTO.ts b/common/entities/PhotoDTO.ts index 0eb994b5..23e06a71 100644 --- a/common/entities/PhotoDTO.ts +++ b/common/entities/PhotoDTO.ts @@ -11,6 +11,18 @@ export interface PhotoDTO extends MediaDTO { readyIcon: boolean; } +export interface FaceRegionBox { + width: number; + height: number; + x: number; + y: number; +} + +export interface FaceRegion { + name: string; + box: FaceRegionBox; +} + export interface PhotoMetadata extends MediaMetadata { caption?: string; keywords?: string[]; @@ -20,6 +32,7 @@ export interface PhotoMetadata extends MediaMetadata { size: MediaDimension; creationDate: number; fileSize: number; + faces?: FaceRegion[]; } diff --git a/package.json b/package.json index 269a7cfb..79fa2594 100644 --- a/package.json +++ b/package.json @@ -30,9 +30,11 @@ "cookie-parser": "1.4.3", "cookie-session": "2.0.0-beta.3", "ejs": "2.6.1", + "exifreader": "2.5.0", "express": "4.16.4", "fluent-ffmpeg": "2.1.2", "image-size": "0.6.3", + "jdataview": "2.5.0", "jimp": "0.6.0", "locale": "0.1.0", "reflect-metadata": "0.1.12", @@ -41,7 +43,8 @@ "ts-node-iptc": "1.0.11", "typeconfig": "1.0.7", "typeorm": "0.2.9", - "winston": "2.4.2" + "winston": "2.4.2", + "xmldom": "0.1.27" }, "devDependencies": { "@angular-devkit/build-angular": "0.11.4", diff --git a/test/backend/unit/assets/test image öüóőúéáű-.,.jpg b/test/backend/unit/assets/test image öüóőúéáű-.,.jpg index 4f838c4a9b754d25c1845de955fb5a9effee0d4c..546a986dd80103a3ef4cb52b272b3c8f7b918208 100644 GIT binary patch delta 5400 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 delta 8694 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