From 4fc965d10f67bfda9650202acc5f7f6f954874eb Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Thu, 14 Feb 2019 18:25:55 -0500 Subject: [PATCH] implementing basic faces page --- backend/middlewares/PersonMWs.ts | 46 ++++++++++++ .../thumbnail/ThumbnailGeneratorMWs.ts | 72 +++++++++++++++++- backend/model/interfaces/IPersonManager.ts | 7 ++ backend/model/memory/PersonManager.ts | 16 +++- backend/model/sql/IndexingManager.ts | 1 + backend/model/sql/PersonManager.ts | 36 ++++++++- backend/model/sql/enitites/PersonEntry.ts | 8 +- backend/model/threading/DiskMangerWorker.ts | 2 +- backend/model/threading/MetadataLoader.ts | 3 + backend/model/threading/ThumbnailWorker.ts | 25 ++++++- backend/routes/PersonRouter.ts | 34 +++++++++ backend/routes/PublicRouter.ts | 2 +- backend/server.ts | 75 ++++++++++--------- common/DataStructureVersion.ts | 2 +- common/config/private/IPrivateConfig.ts | 1 + common/config/private/PrivateConfigClass.ts | 3 +- common/config/public/ConfigClass.ts | 6 +- common/entities/PersonDTO.ts | 6 ++ frontend/app/app.module.ts | 7 ++ frontend/app/app.routing.ts | 5 ++ frontend/app/faces/face/face.component.css | 56 ++++++++++++++ frontend/app/faces/face/face.component.html | 20 +++++ frontend/app/faces/face/face.component.ts | 31 ++++++++ frontend/app/faces/faces.component.css | 4 + frontend/app/faces/faces.component.html | 7 ++ frontend/app/faces/faces.component.ts | 21 ++++++ frontend/app/faces/faces.service.ts | 20 +++++ .../search/search.gallery.component.html | 2 +- frontend/app/settings/settings.service.ts | 2 + .../thumbnail/thumbanil.settings.component.ts | 3 + .../unit/assets/test image öüóőúéáű-.,.json | 8 +- test/backend/unit/model/sql/PersonManager.ts | 4 +- 32 files changed, 476 insertions(+), 59 deletions(-) create mode 100644 backend/middlewares/PersonMWs.ts create mode 100644 backend/routes/PersonRouter.ts create mode 100644 common/entities/PersonDTO.ts create mode 100644 frontend/app/faces/face/face.component.css create mode 100644 frontend/app/faces/face/face.component.html create mode 100644 frontend/app/faces/face/face.component.ts create mode 100644 frontend/app/faces/faces.component.css create mode 100644 frontend/app/faces/faces.component.html create mode 100644 frontend/app/faces/faces.component.ts create mode 100644 frontend/app/faces/faces.service.ts diff --git a/backend/middlewares/PersonMWs.ts b/backend/middlewares/PersonMWs.ts new file mode 100644 index 00000000..3eff211b --- /dev/null +++ b/backend/middlewares/PersonMWs.ts @@ -0,0 +1,46 @@ +import {NextFunction, Request, Response} from 'express'; +import {ErrorCodes, ErrorDTO} from '../../common/entities/Error'; +import {ObjectManagerRepository} from '../model/ObjectManagerRepository'; + + +const LOG_TAG = '[PersonMWs]'; + +export class PersonMWs { + + + public static async listPersons(req: Request, res: Response, next: NextFunction) { + + + try { + req.resultPipe = await ObjectManagerRepository.getInstance() + .PersonManager.getAll(); + + return next(); + + } catch (err) { + return next(new ErrorDTO(ErrorCodes.GENERAL_ERROR, 'Error during listing the directory', err)); + } + } + + + public static async getSamplePhoto(req: Request, res: Response, next: NextFunction) { + if (!req.params.name) { + return next(); + } + const name = req.params.name; + try { + const photo = await ObjectManagerRepository.getInstance() + .PersonManager.getSamplePhoto(name); + + if (photo === null) { + return next(); + } + req.resultPipe = photo; + return next(); + + } catch (err) { + return next(new ErrorDTO(ErrorCodes.GENERAL_ERROR, 'Error during listing the directory', err)); + } + } + +} diff --git a/backend/middlewares/thumbnail/ThumbnailGeneratorMWs.ts b/backend/middlewares/thumbnail/ThumbnailGeneratorMWs.ts index 0c0b0e7f..5fa98f10 100644 --- a/backend/middlewares/thumbnail/ThumbnailGeneratorMWs.ts +++ b/backend/middlewares/thumbnail/ThumbnailGeneratorMWs.ts @@ -14,6 +14,7 @@ import {RendererInput, ThumbnailSourceType, ThumbnailWorker} from '../../model/t import {MediaDTO} from '../../../common/entities/MediaDTO'; import {ITaskExecuter, TaskExecuter} from '../../model/threading/TaskExecuter'; +import {PhotoDTO} from '../../../common/entities/PhotoDTO'; export class ThumbnailGeneratorMWs { @@ -73,6 +74,65 @@ export class ThumbnailGeneratorMWs { } + public static async generatePersonThumbnail(req: Request, res: Response, next: NextFunction) { + if (!req.resultPipe) { + return next(); + } + // load parameters + const photo: PhotoDTO = req.resultPipe; + if (!photo.metadata.faces || photo.metadata.faces.length !== 1) { + return next(new ErrorDTO(ErrorCodes.THUMBNAIL_GENERATION_ERROR, 'Photo does not contain a face')); + } + + // load parameters + const mediaPath = path.resolve(ProjectPath.ImageFolder, photo.directory.path, photo.directory.name, photo.name); + const size: number = Config.Client.Thumbnail.personThumbnailSize; + const personName = photo.metadata.faces[0].name; + // generate thumbnail path + const thPath = path.join(ProjectPath.ThumbnailFolder, ThumbnailGeneratorMWs.generatePersonThumbnailName(mediaPath, personName, size)); + + + req.resultPipe = thPath; + + // check if thumbnail already exist + if (fs.existsSync(thPath) === true) { + return next(); + } + + // create thumbnail folder if not exist + if (!fs.existsSync(ProjectPath.ThumbnailFolder)) { + fs.mkdirSync(ProjectPath.ThumbnailFolder); + } + + const margin = { + x: Math.round(photo.metadata.faces[0].box.width * (Config.Server.thumbnail.personFaceMargin)), + y: Math.round(photo.metadata.faces[0].box.height * (Config.Server.thumbnail.personFaceMargin)) + }; + // run on other thread + const input = { + type: ThumbnailSourceType.Image, + mediaPath: mediaPath, + size: size, + thPath: thPath, + makeSquare: false, + cut: { + x: Math.round(Math.max(0, photo.metadata.faces[0].box.x - margin.x / 2)), + y: Math.round(Math.max(0, photo.metadata.faces[0].box.y - margin.y / 2)), + width: photo.metadata.faces[0].box.width + margin.x, + height: photo.metadata.faces[0].box.height + margin.y + }, + qualityPriority: Config.Server.thumbnail.qualityPriority + }; + try { + await ThumbnailGeneratorMWs.taskQue.execute(input); + return next(); + } catch (error) { + return next(new ErrorDTO(ErrorCodes.THUMBNAIL_GENERATION_ERROR, + 'Error during generating face thumbnail: ' + input.mediaPath, error.toString())); + } + + } + public static generateThumbnailFactory(sourceType: ThumbnailSourceType) { return (req: Request, res: Response, next: NextFunction) => { if (!req.resultPipe) { @@ -106,6 +166,14 @@ export class ThumbnailGeneratorMWs { }; } + public static generateThumbnailName(mediaPath: string, size: number): string { + return crypto.createHash('md5').update(mediaPath).digest('hex') + '_' + size + '.jpg'; + } + + public static generatePersonThumbnailName(mediaPath: string, personName: string, size: number): string { + return crypto.createHash('md5').update(mediaPath + '_' + personName).digest('hex') + '_' + size + '.jpg'; + } + private static addThInfoTODir(directory: DirectoryDTO) { if (typeof directory.media !== 'undefined') { ThumbnailGeneratorMWs.addThInfoToPhotos(directory.media); @@ -177,9 +245,5 @@ export class ThumbnailGeneratorMWs { 'Error during generating thumbnail: ' + input.mediaPath, error.toString())); } } - - public static generateThumbnailName(mediaPath: string, size: number): string { - return crypto.createHash('md5').update(mediaPath).digest('hex') + '_' + size + '.jpg'; - } } diff --git a/backend/model/interfaces/IPersonManager.ts b/backend/model/interfaces/IPersonManager.ts index 2526a6c0..f19296bb 100644 --- a/backend/model/interfaces/IPersonManager.ts +++ b/backend/model/interfaces/IPersonManager.ts @@ -1,10 +1,17 @@ import {PersonEntry} from '../sql/enitites/PersonEntry'; import {MediaDTO} from '../../../common/entities/MediaDTO'; +import {PhotoDTO} from '../../../common/entities/PhotoDTO'; export interface IPersonManager { + getAll(): Promise; + + getSamplePhoto(name: string): Promise; + get(name: string): Promise; saveAll(names: string[]): Promise; keywordsToPerson(media: MediaDTO[]): Promise; + + updateCounts(): Promise; } diff --git a/backend/model/memory/PersonManager.ts b/backend/model/memory/PersonManager.ts index 4f5d9243..d1e79bc1 100644 --- a/backend/model/memory/PersonManager.ts +++ b/backend/model/memory/PersonManager.ts @@ -1,7 +1,17 @@ import {IPersonManager} from '../interfaces/IPersonManager'; import {MediaDTO} from '../../../common/entities/MediaDTO'; +import {PersonEntry} from '../sql/enitites/PersonEntry'; +import {PhotoDTO} from '../../../common/entities/PhotoDTO'; + +export class PersonManager implements IPersonManager { + getAll(): Promise { + throw new Error('Method not implemented.'); + } + + getSamplePhoto(name: string): Promise { + throw new Error('Method not implemented.'); + } -export class IndexingTaskManager implements IPersonManager { keywordsToPerson(media: MediaDTO[]): Promise { throw new Error('Method not implemented.'); } @@ -13,4 +23,8 @@ export class IndexingTaskManager implements IPersonManager { saveAll(names: string[]): Promise { throw new Error('not supported by memory DB'); } + + updateCounts(): Promise { + throw new Error('not supported by memory DB'); + } } diff --git a/backend/model/sql/IndexingManager.ts b/backend/model/sql/IndexingManager.ts index 9f54f21d..cf4f7415 100644 --- a/backend/model/sql/IndexingManager.ts +++ b/backend/model/sql/IndexingManager.ts @@ -289,6 +289,7 @@ export class IndexingManager implements IIndexingManager { await this.saveChildDirs(connection, currentDirId, scannedDirectory); await this.saveMedia(connection, currentDirId, scannedDirectory.media); await this.saveMetaFiles(connection, currentDirId, scannedDirectory); + await ObjectManagerRepository.getInstance().PersonManager.updateCounts(); } catch (e) { throw e; } finally { diff --git a/backend/model/sql/PersonManager.ts b/backend/model/sql/PersonManager.ts index a5fcf97c..b83a6b02 100644 --- a/backend/model/sql/PersonManager.ts +++ b/backend/model/sql/PersonManager.ts @@ -3,13 +3,34 @@ import {SQLConnection} from './SQLConnection'; import {PersonEntry} from './enitites/PersonEntry'; import {MediaDTO} from '../../../common/entities/MediaDTO'; import {PhotoDTO} from '../../../common/entities/PhotoDTO'; +import {MediaEntity} from './enitites/MediaEntity'; +import {FaceRegionEntry} from './enitites/FaceRegionEntry'; const LOG_TAG = '[PersonManager]'; export class PersonManager implements IPersonManager { - persons: PersonEntry[] = []; + async getSamplePhoto(name: string): Promise { + const connection = await SQLConnection.getConnection(); + const rawAndEntities = await connection.getRepository(MediaEntity).createQueryBuilder('media') + .limit(1) + .leftJoinAndSelect('media.directory', 'directory') + .leftJoinAndSelect('media.metadata.faces', 'faces') + .leftJoinAndSelect('faces.person', 'person') + .where('person.name LIKE :name COLLATE utf8_general_ci', {name: '%' + name + '%'}).getRawAndEntities(); + + if (rawAndEntities.entities.length === 0) { + return null; + } + const media: PhotoDTO = rawAndEntities.entities[0]; + + media.metadata.faces = [FaceRegionEntry.fromRawToDTO(rawAndEntities.raw[0])]; + + return media; + + } + async loadAll(): Promise { const connection = await SQLConnection.getConnection(); const personRepository = connection.getRepository(PersonEntry); @@ -17,6 +38,11 @@ export class PersonManager implements IPersonManager { } + async getAll(): Promise { + await this.loadAll(); + return this.persons; + } + // TODO dead code, remove it async keywordsToPerson(media: MediaDTO[]) { await this.loadAll(); @@ -30,7 +56,7 @@ export class PersonManager implements IPersonManager { if (personKeywords.length === 0) { return; } - // remove persons + // remove persons from keywords m.metadata.keywords = m.metadata.keywords.filter(k => !personFilter(k)); m.metadata.faces = m.metadata.faces || []; personKeywords.forEach((pk: string) => { @@ -81,4 +107,10 @@ export class PersonManager implements IPersonManager { } + public async updateCounts() { + const connection = await SQLConnection.getConnection(); + await connection.query('update person_entry set count = ' + + ' (select COUNT(1) from face_region_entry where face_region_entry.personId = person_entry.id)'); + } + } diff --git a/backend/model/sql/enitites/PersonEntry.ts b/backend/model/sql/enitites/PersonEntry.ts index 2826453a..8fcc921b 100644 --- a/backend/model/sql/enitites/PersonEntry.ts +++ b/backend/model/sql/enitites/PersonEntry.ts @@ -1,10 +1,11 @@ -import {Column, Entity, OneToMany, PrimaryGeneratedColumn, Unique, Index} from 'typeorm'; +import {Column, Entity, Index, OneToMany, PrimaryGeneratedColumn, Unique} from 'typeorm'; import {FaceRegionEntry} from './FaceRegionEntry'; +import {PersonDTO} from '../../../../common/entities/PersonDTO'; @Entity() @Unique(['name']) -export class PersonEntry { +export class PersonEntry implements PersonDTO { @Index() @PrimaryGeneratedColumn({unsigned: true}) id: number; @@ -12,6 +13,9 @@ export class PersonEntry { @Column() name: string; + @Column('int', {unsigned: true, default: 0}) + count: number; + @OneToMany(type => FaceRegionEntry, faceRegion => faceRegion.person) public faces: FaceRegionEntry[]; } diff --git a/backend/model/threading/DiskMangerWorker.ts b/backend/model/threading/DiskMangerWorker.ts index 23422c18..52000590 100644 --- a/backend/model/threading/DiskMangerWorker.ts +++ b/backend/model/threading/DiskMangerWorker.ts @@ -99,7 +99,7 @@ export class DiskMangerWorker { metadata: await MetadataLoader.loadVideoMetadata(fullFilePath) }); } catch (e) { - Logger.warn('Media loading error, skipping: ' + file); + Logger.warn('Media loading error, skipping: ' + file + ', reason: ' + e.toString()); } } else if (photosOnly === false && Config.Client.MetaFile.enabled === true && diff --git a/backend/model/threading/MetadataLoader.ts b/backend/model/threading/MetadataLoader.ts index 572addf5..d8885783 100644 --- a/backend/model/threading/MetadataLoader.ts +++ b/backend/model/threading/MetadataLoader.ts @@ -214,6 +214,9 @@ export class MetadataLoader { x: Math.round(regionBox['stArea:x'] * metadata.size.width), y: Math.round(regionBox['stArea:y'] * metadata.size.height) }; + // convert center base box to corner based box + box.x = Math.max(0, box.x - box.width / 2); + box.y = Math.max(0, box.y - box.height / 2); faces.push({name: name, box: box}); } } diff --git a/backend/model/threading/ThumbnailWorker.ts b/backend/model/threading/ThumbnailWorker.ts index e42e744e..374164fe 100644 --- a/backend/model/threading/ThumbnailWorker.ts +++ b/backend/model/threading/ThumbnailWorker.ts @@ -47,6 +47,12 @@ export interface RendererInput { makeSquare: boolean; thPath: string; qualityPriority: boolean; + cut?: { + x: number, + y: number, + width: number, + height: number + }; } export class VideoRendererFactory { @@ -140,6 +146,15 @@ export class ImageRendererFactory { */ const ratio = image.bitmap.height / image.bitmap.width; const algo = input.qualityPriority === true ? Jimp.RESIZE_BEZIER : Jimp.RESIZE_NEAREST_NEIGHBOR; + + if (input.cut) { + image.crop( + input.cut.x, + input.cut.y, + input.cut.width, + input.cut.height + ); + } if (input.makeSquare === false) { const newWidth = Math.sqrt((input.size * input.size) / ratio); @@ -167,7 +182,6 @@ export class ImageRendererFactory { const sharp = require('sharp'); sharp.cache(false); return async (input: RendererInput): Promise => { - Logger.silly('[SharpThRenderer] rendering thumbnail:' + input.mediaPath); const image: Sharp = sharp(input.mediaPath, {failOnError: false}); const metadata: Metadata = await image.metadata(); @@ -183,6 +197,15 @@ export class ImageRendererFactory { */ const ratio = metadata.height / metadata.width; const kernel = input.qualityPriority === true ? sharp.kernel.lanczos3 : sharp.kernel.nearest; + + if (input.cut) { + image.extract({ + top: input.cut.y, + left: input.cut.x, + width: input.cut.width, + height: input.cut.height + }); + } if (input.makeSquare === false) { const newWidth = Math.round(Math.sqrt((input.size * input.size) / ratio)); image.resize(newWidth, null, { diff --git a/backend/routes/PersonRouter.ts b/backend/routes/PersonRouter.ts new file mode 100644 index 00000000..472ab518 --- /dev/null +++ b/backend/routes/PersonRouter.ts @@ -0,0 +1,34 @@ +import {AuthenticationMWs} from '../middlewares/user/AuthenticationMWs'; +import {Express} from 'express'; +import {RenderingMWs} from '../middlewares/RenderingMWs'; +import {UserRoles} from '../../common/entities/UserDTO'; +import {PersonMWs} from '../middlewares/PersonMWs'; +import {ThumbnailGeneratorMWs} from '../middlewares/thumbnail/ThumbnailGeneratorMWs'; + +export class PersonRouter { + public static route(app: Express) { + + this.addPersons(app); + this.getPersonThumbnail(app); + } + + private static addPersons(app: Express) { + app.get(['/api/person'], + AuthenticationMWs.authenticate, + AuthenticationMWs.authorise(UserRoles.User), + PersonMWs.listPersons, + RenderingMWs.renderResult + ); + } + + private static getPersonThumbnail(app: Express) { + app.get(['/api/person/:name/thumbnail'], + AuthenticationMWs.authenticate, + AuthenticationMWs.authorise(UserRoles.User), + PersonMWs.getSamplePhoto, + ThumbnailGeneratorMWs.generatePersonThumbnail, + RenderingMWs.renderFile + ); + } + +} diff --git a/backend/routes/PublicRouter.ts b/backend/routes/PublicRouter.ts index 9ac45955..cb46c1c3 100644 --- a/backend/routes/PublicRouter.ts +++ b/backend/routes/PublicRouter.ts @@ -79,7 +79,7 @@ export class PublicRouter { }); - app.get(['/', '/login', '/gallery*', '/share*', '/admin', '/duplicates', '/search*'], + app.get(['/', '/login', '/gallery*', '/share*', '/admin', '/duplicates', '/faces', '/search*'], AuthenticationMWs.tryAuthenticate, setLocale, renderIndex diff --git a/backend/server.ts b/backend/server.ts index bbbae590..f4665928 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -22,6 +22,7 @@ import {NotificationRouter} from './routes/NotificationRouter'; import {ConfigDiagnostics} from './model/diagnostics/ConfigDiagnostics'; import {Localizations} from './model/Localizations'; import {CookieNames} from '../common/CookieNames'; +import {PersonRouter} from './routes/PersonRouter'; const _session = require('cookie-session'); @@ -32,42 +33,6 @@ export class Server { private app: _express.Express; private server: HttpServer; - /** - * Event listener for HTTP server "error" event. - */ - private onError = (error: any) => { - if (error.syscall !== 'listen') { - Logger.error(LOG_TAG, 'Server error', error); - throw error; - } - - const bind = Config.Server.host + ':' + Config.Server.port; - - // handle specific listen error with friendly messages - switch (error.code) { - case 'EACCES': - Logger.error(LOG_TAG, bind + ' requires elevated privileges'); - process.exit(1); - break; - case 'EADDRINUSE': - Logger.error(LOG_TAG, bind + ' is already in use'); - process.exit(1); - break; - default: - throw error; - } - }; - /** - * Event listener for HTTP server "listening" event. - */ - private onListening = () => { - const addr = this.server.address(); - const bind = typeof addr === 'string' - ? 'pipe ' + addr - : 'port ' + addr.port; - Logger.info(LOG_TAG, 'Listening on ' + bind); - }; - constructor() { if (!(process.env.NODE_ENV === 'production')) { Logger.debug(LOG_TAG, 'Running in DEBUG mode, set env variable NODE_ENV=production to disable '); @@ -125,6 +90,7 @@ export class Server { UserRouter.route(this.app); GalleryRouter.route(this.app); + PersonRouter.route(this.app); SharingRouter.route(this.app); AdminRouter.route(this.app); NotificationRouter.route(this.app); @@ -146,6 +112,43 @@ export class Server { } + /** + * Event listener for HTTP server "error" event. + */ + private onError = (error: any) => { + if (error.syscall !== 'listen') { + Logger.error(LOG_TAG, 'Server error', error); + throw error; + } + + const bind = Config.Server.host + ':' + Config.Server.port; + + // handle specific listen error with friendly messages + switch (error.code) { + case 'EACCES': + Logger.error(LOG_TAG, bind + ' requires elevated privileges'); + process.exit(1); + break; + case 'EADDRINUSE': + Logger.error(LOG_TAG, bind + ' is already in use'); + process.exit(1); + break; + default: + throw error; + } + }; + + /** + * Event listener for HTTP server "listening" event. + */ + private onListening = () => { + const addr = this.server.address(); + const bind = typeof addr === 'string' + ? 'pipe ' + addr + : 'port ' + addr.port; + Logger.info(LOG_TAG, 'Listening on ' + bind); + }; + } diff --git a/common/DataStructureVersion.ts b/common/DataStructureVersion.ts index 8272ebda..573de743 100644 --- a/common/DataStructureVersion.ts +++ b/common/DataStructureVersion.ts @@ -1 +1 @@ -export const DataStructureVersion = 10; +export const DataStructureVersion = 11; diff --git a/common/config/private/IPrivateConfig.ts b/common/config/private/IPrivateConfig.ts index 156ed03d..fbded95b 100644 --- a/common/config/private/IPrivateConfig.ts +++ b/common/config/private/IPrivateConfig.ts @@ -39,6 +39,7 @@ export interface ThumbnailConfig { folder: string; processingLibrary: ThumbnailProcessingLib; qualityPriority: boolean; + personFaceMargin: number; // in ration [0-1] } export interface SharingConfig { diff --git a/common/config/private/PrivateConfigClass.ts b/common/config/private/PrivateConfigClass.ts index 22a062a7..c26afe91 100644 --- a/common/config/private/PrivateConfigClass.ts +++ b/common/config/private/PrivateConfigClass.ts @@ -25,7 +25,8 @@ export class PrivateConfigClass extends PublicConfigClass implements IPrivateCon thumbnail: { folder: 'demo/TEMP', processingLibrary: ThumbnailProcessingLib.sharp, - qualityPriority: true + qualityPriority: true, + personFaceMargin: 0.6 }, log: { level: LogLevel.info, diff --git a/common/config/public/ConfigClass.ts b/common/config/public/ConfigClass.ts index 73d32706..6e9df03e 100644 --- a/common/config/public/ConfigClass.ts +++ b/common/config/public/ConfigClass.ts @@ -40,7 +40,8 @@ export module ClientConfig { export interface ThumbnailConfig { iconSize: number; - thumbnailSizes: Array; + personThumbnailSize: number; + thumbnailSizes: number[]; concurrentThumbnailGenerations: number; } @@ -100,7 +101,8 @@ export class PublicConfigClass { Thumbnail: { concurrentThumbnailGenerations: 1, thumbnailSizes: [200, 400, 600], - iconSize: 45 + iconSize: 45, + personThumbnailSize: 200 }, Search: { enabled: true, diff --git a/common/entities/PersonDTO.ts b/common/entities/PersonDTO.ts new file mode 100644 index 00000000..6bcb4f4d --- /dev/null +++ b/common/entities/PersonDTO.ts @@ -0,0 +1,6 @@ +export interface PersonDTO { + id: number; + name: string; + count: number; +} + diff --git a/frontend/app/app.module.ts b/frontend/app/app.module.ts index 39977327..4895eefa 100644 --- a/frontend/app/app.module.ts +++ b/frontend/app/app.module.ts @@ -74,6 +74,9 @@ import {DuplicateService} from './duplicates/duplicates.service'; import {DuplicateComponent} from './duplicates/duplicates.component'; import {DuplicatesPhotoComponent} from './duplicates/photo/photo.duplicates.component'; import {SeededRandomService} from './model/seededRandom.service'; +import {FacesComponent} from './faces/faces.component'; +import {FacesService} from './faces/faces.service'; +import {FaceComponent} from './faces/face/face.component'; @Injectable() @@ -133,6 +136,7 @@ export function translationsFactory(locale: string) { LoginComponent, ShareLoginComponent, GalleryComponent, + FacesComponent, // misc FrameComponent, LanguageComponent, @@ -152,6 +156,8 @@ export function translationsFactory(locale: string) { AdminComponent, InfoPanelLightboxComponent, RandomQueryBuilderGalleryComponent, + // Face + FaceComponent, // Settings UserMangerSettingsComponent, DatabaseSettingsComponent, @@ -194,6 +200,7 @@ export function translationsFactory(locale: string) { OverlayService, QueryService, DuplicateService, + FacesService, { provide: TRANSLATIONS, useFactory: translationsFactory, diff --git a/frontend/app/app.routing.ts b/frontend/app/app.routing.ts index 6155b8ea..7d5853ea 100644 --- a/frontend/app/app.routing.ts +++ b/frontend/app/app.routing.ts @@ -6,6 +6,7 @@ import {AdminComponent} from './admin/admin.component'; import {ShareLoginComponent} from './sharelogin/share-login.component'; import {QueryParams} from '../../common/QueryParams'; import {DuplicateComponent} from './duplicates/duplicates.component'; +import {FacesComponent} from './faces/faces.component'; export function galleryMatcherFunction( segments: UrlSegment[]): UrlMatchResult | null { @@ -55,6 +56,10 @@ const ROUTES: Routes = [ path: 'duplicates', component: DuplicateComponent }, + { + path: 'faces', + component: FacesComponent + }, { matcher: galleryMatcherFunction, component: GalleryComponent diff --git a/frontend/app/faces/face/face.component.css b/frontend/app/faces/face/face.component.css new file mode 100644 index 00000000..ce672cd7 --- /dev/null +++ b/frontend/app/faces/face/face.component.css @@ -0,0 +1,56 @@ +a { + position: relative; +} + +.photo-container { + border: 2px solid #333; + width: 180px; + height: 180px; + background-color: #bbbbbb; +} + +.no-image { + + color: #7f7f7f; + font-size: 80px; + top: calc(50% - 40px); + left: calc(50% - 40px); +} + +.photo { + width: 100%; + height: 100%; + background-size: cover; + background-position: center; +} + +.button { + border: 0; + padding: 0; + text-align: left; + +} + +.info { + background-color: rgba(0, 0, 0, 0.6); + color: white; + font-size: medium; + position: absolute; + bottom: 0; + left: 0; + padding: 5px; + width: 100%; +} + +a:hover .info { + background-color: rgba(0, 0, 0, 0.8); +} + +a:hover .photo-container { + border-color: #000; +} + +.person-name { + width: 180px; + white-space: normal; +} diff --git a/frontend/app/faces/face/face.component.html b/frontend/app/faces/face/face.component.html new file mode 100644 index 00000000..c28d0d98 --- /dev/null +++ b/frontend/app/faces/face/face.component.html @@ -0,0 +1,20 @@ + + + +
+
+ + +
+ +
+
{{person.name}} ({{person.count}})
+ +
+
+ diff --git a/frontend/app/faces/face/face.component.ts b/frontend/app/faces/face/face.component.ts new file mode 100644 index 00000000..2e82dd95 --- /dev/null +++ b/frontend/app/faces/face/face.component.ts @@ -0,0 +1,31 @@ +import {Component, Input} from '@angular/core'; +import {RouterLink} from '@angular/router'; +import {PersonDTO} from '../../../../common/entities/PersonDTO'; +import {SearchTypes} from '../../../../common/entities/AutoCompleteItem'; +import {DomSanitizer} from '@angular/platform-browser'; + +@Component({ + selector: 'app-face', + templateUrl: './face.component.html', + styleUrls: ['./face.component.css'], + providers: [RouterLink], +}) +export class FaceComponent { + @Input() person: PersonDTO; + + SearchTypes = SearchTypes; + + constructor(private _sanitizer: DomSanitizer) { + + } + + getSanitizedThUrl() { + return this._sanitizer.bypassSecurityTrustStyle('url(' + + encodeURI('/api/person/' + this.person.name + '/thumbnail') + .replace(/\(/g, '%28') + .replace(/'/g, '%27') + .replace(/\)/g, '%29') + ')'); + } + +} + diff --git a/frontend/app/faces/faces.component.css b/frontend/app/faces/faces.component.css new file mode 100644 index 00000000..901e3e87 --- /dev/null +++ b/frontend/app/faces/faces.component.css @@ -0,0 +1,4 @@ +app-face { + margin: 2px; + display: inline-block; +} diff --git a/frontend/app/faces/faces.component.html b/frontend/app/faces/faces.component.html new file mode 100644 index 00000000..5958364c --- /dev/null +++ b/frontend/app/faces/faces.component.html @@ -0,0 +1,7 @@ + + +
+ +
+
diff --git a/frontend/app/faces/faces.component.ts b/frontend/app/faces/faces.component.ts new file mode 100644 index 00000000..4870a19e --- /dev/null +++ b/frontend/app/faces/faces.component.ts @@ -0,0 +1,21 @@ +import {Component} from '@angular/core'; +import {FacesService} from './faces.service'; +import {QueryService} from '../model/query.service'; + + +@Component({ + selector: 'app-faces', + templateUrl: './faces.component.html', + styleUrls: ['./faces.component.css'] +}) +export class FacesComponent { + + + constructor(public facesService: FacesService, + public queryService: QueryService) { + this.facesService.getPersons().catch(console.error); + } + + +} + diff --git a/frontend/app/faces/faces.service.ts b/frontend/app/faces/faces.service.ts new file mode 100644 index 00000000..ae303c5b --- /dev/null +++ b/frontend/app/faces/faces.service.ts @@ -0,0 +1,20 @@ +import {Injectable} from '@angular/core'; +import {NetworkService} from '../model/network/network.service'; +import {BehaviorSubject} from 'rxjs'; +import {PersonDTO} from '../../../common/entities/PersonDTO'; + + +@Injectable() +export class FacesService { + + public persons: BehaviorSubject; + + constructor(private networkService: NetworkService) { + this.persons = new BehaviorSubject(null); + } + + public async getPersons() { + this.persons.next((await this.networkService.getJson('/person')).sort((a, b) => a.name.localeCompare(b.name))); + } + +} diff --git a/frontend/app/gallery/search/search.gallery.component.html b/frontend/app/gallery/search/search.gallery.component.html index 2c85a978..df0bd6fc 100644 --- a/frontend/app/gallery/search/search.gallery.component.html +++ b/frontend/app/gallery/search/search.gallery.component.html @@ -22,10 +22,10 @@ + - {{item.preText}}{{item.highLightText}}{{item.postText}} diff --git a/frontend/app/settings/settings.service.ts b/frontend/app/settings/settings.service.ts index 06742110..bce79598 100644 --- a/frontend/app/settings/settings.service.ts +++ b/frontend/app/settings/settings.service.ts @@ -35,6 +35,7 @@ export class SettingsService { Thumbnail: { concurrentThumbnailGenerations: null, iconSize: 30, + personThumbnailSize: 200, thumbnailSizes: [] }, Sharing: { @@ -92,6 +93,7 @@ export class SettingsService { port: 80, host: '0.0.0.0', thumbnail: { + personFaceMargin: 0.1, folder: '', qualityPriority: true, processingLibrary: ThumbnailProcessingLib.sharp diff --git a/frontend/app/settings/thumbnail/thumbanil.settings.component.ts b/frontend/app/settings/thumbnail/thumbanil.settings.component.ts index eb1583df..e17e79d5 100644 --- a/frontend/app/settings/thumbnail/thumbanil.settings.component.ts +++ b/frontend/app/settings/thumbnail/thumbanil.settings.component.ts @@ -52,6 +52,9 @@ export class ThumbnailSettingsComponent if (v.value.toLowerCase() === 'sharp') { v.value += ' ' + this.i18n('(recommended)'); } + if (v.value.toLowerCase() === 'gm') { + v.value += ' ' + this.i18n('(deprecated)'); + } return v; }); this.ThumbnailProcessingLib = ThumbnailProcessingLib; diff --git a/test/backend/unit/assets/test image öüóőúéáű-.,.json b/test/backend/unit/assets/test image öüóőúéáű-.,.json index dd0bd2c7..f52ad9c8 100644 --- a/test/backend/unit/assets/test image öüóőúéáű-.,.json +++ b/test/backend/unit/assets/test image öüóőúéáű-.,.json @@ -15,8 +15,8 @@ "box": { "height": 2, "width": 2, - "x": 8, - "y": 4 + "x": 7, + "y": 3 }, "name": "squirrel" }, @@ -24,8 +24,8 @@ "box": { "height": 3, "width": 2, - "x": 5, - "y": 5 + "x": 4, + "y": 3.5 }, "name": "special_chars űáéúőóüío?._:" } diff --git a/test/backend/unit/model/sql/PersonManager.ts b/test/backend/unit/model/sql/PersonManager.ts index 53f0f757..5ed37fdc 100644 --- a/test/backend/unit/model/sql/PersonManager.ts +++ b/test/backend/unit/model/sql/PersonManager.ts @@ -14,8 +14,8 @@ describe('PersonManager', () => { it('should upgrade keywords to person', async () => { const pm = new PersonManager(); pm.loadAll = () => Promise.resolve(); - pm.persons = [{name: 'Han Solo', id: 0, faces: []}, - {name: 'Anakin', id: 2, faces: []}]; + pm.persons = [{name: 'Han Solo', id: 0, faces: [], count: 0}, + {name: 'Anakin', id: 2, faces: [], count: 0}]; const p_noFaces = { metadata: {