Integration with facerecognition

cap
Matias De lellis 2022-12-08 13:00:53 -08:00 committed by Varun Patil
parent 3f92e5ec6a
commit aeffe628f2
26 changed files with 653 additions and 90 deletions

View File

@ -23,7 +23,8 @@ return [
// Routes with params
w(['name' => 'Page#folder', 'url' => '/folders/{path}', 'verb' => 'GET'], 'path'),
w(['name' => 'Page#albums', 'url' => '/albums/{id}', 'verb' => 'GET'], 'id'),
w(['name' => 'Page#people', 'url' => '/people/{name}', 'verb' => 'GET'], 'name'),
w(['name' => 'Page#recognize', 'url' => '/recognize/{name}', 'verb' => 'GET'], 'name'),
w(['name' => 'Page#facerecognition', 'url' => '/facerecognition/{name}', 'verb' => 'GET'], 'name'),
w(['name' => 'Page#tags', 'url' => '/tags/{name}', 'verb' => 'GET'], 'name'),
// Public folder share
@ -50,8 +51,10 @@ return [
['name' => 'Tags#tags', 'url' => '/api/tags', 'verb' => 'GET'],
['name' => 'Tags#preview', 'url' => '/api/tags/preview/{tag}', 'verb' => 'GET'],
['name' => 'Faces#faces', 'url' => '/api/faces', 'verb' => 'GET'],
['name' => 'Faces#preview', 'url' => '/api/faces/preview/{id}', 'verb' => 'GET'],
['name' => 'People#recognizePeople', 'url' => '/api/recognize/people', 'verb' => 'GET'],
['name' => 'People#recognizePeoplePreview', 'url' => '/api/recognize/people/preview/{id}', 'verb' => 'GET'],
['name' => 'People#facerecognitionPeople', 'url' => '/api/facerecognition/people', 'verb' => 'GET'],
['name' => 'People#facerecognitionPeoplePreview', 'url' => '/api/facerecognition/people/preview/{id}', 'verb' => 'GET'],
['name' => 'Archive#archive', 'url' => '/api/archive/{id}', 'verb' => 'PATCH'],

View File

@ -291,6 +291,20 @@ class ApiBase extends Controller
return \OCA\Memories\Util::recognizeIsEnabled($this->appManager);
}
// Check if facerecognition is installed and enabled for this user.
protected function facerecognitionIsInstalled(): bool
{
return \OCA\Memories\Util::facerecognitionIsInstalled($this->appManager);
}
/**
* Check if facerecognition is enabled for this user.
*/
protected function facerecognitionIsEnabled(): bool
{
return \OCA\Memories\Util::facerecognitionIsEnabled($this->config, $this->getUID());
}
/**
* Helper to get one file or null from a fiolder.
*/

View File

@ -198,16 +198,24 @@ class DaysController extends ApiBase
$transforms[] = [$this->timelineQuery, 'transformVideoFilter'];
}
// Filter only for one face
if ($this->recognizeIsEnabled()) {
$face = $this->request->getParam('face');
if ($face) {
$transforms[] = [$this->timelineQuery, 'transformFaceFilter', $face];
}
// Filter only for one face on Recognize
if (($recognize = $this->request->getParam('recognize')) && $this->recognizeIsEnabled()) {
$transforms[] = [$this->timelineQuery, 'transformPeopleRecognitionFilter', $recognize];
$faceRect = $this->request->getParam('facerect');
if ($faceRect && !$aggregateOnly) {
$transforms[] = [$this->timelineQuery, 'transformFaceRect', $face];
$transforms[] = [$this->timelineQuery, 'transformPeopleRecognizeRect', $recognize];
}
}
// Filter only for one face on Face Recognition
if (($face = $this->request->getParam('facerecognition')) && $this->facerecognitionIsEnabled()) {
$currentModel = (int) $this->config->getAppValue('facerecognition', 'model', -1);
$transforms[] = [$this->timelineQuery, 'transformPeopleFaceRecognitionFilter', $currentModel, $face];
$faceRect = $this->request->getParam('facerect');
if ($faceRect && !$aggregateOnly) {
$transforms[] = [$this->timelineQuery, 'transformPeopleFaceRecognitionRect', $face];
}
}

View File

@ -86,6 +86,8 @@ class PageController extends Controller
$this->initialState->provideInitialState('systemtags', true === $this->appManager->isEnabledForUser('systemtags'));
$this->initialState->provideInitialState('maps', true === $this->appManager->isEnabledForUser('maps'));
$this->initialState->provideInitialState('recognize', \OCA\Memories\Util::recognizeIsEnabled($this->appManager));
$this->initialState->provideInitialState('facerecognitionInstalled', \OCA\Memories\Util::facerecognitionIsInstalled($this->appManager));
$this->initialState->provideInitialState('facerecognitionEnabled', \OCA\Memories\Util::facerecognitionIsEnabled($this->config, $uid));
$this->initialState->provideInitialState('albums', \OCA\Memories\Util::albumsIsEnabled($this->appManager));
// App version
@ -181,7 +183,17 @@ class PageController extends Controller
*
* @NoCSRFRequired
*/
public function people()
public function recognize()
{
return $this->main();
}
/**
* @NoAdminRequired
*
* @NoCSRFRequired
*/
public function facerecognition()
{
return $this->main();
}

View File

@ -29,14 +29,14 @@ use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\JSONResponse;
use OCP\Files\FileInfo;
class FacesController extends ApiBase
class PeopleController extends ApiBase
{
/**
* @NoAdminRequired
*
* Get list of faces with counts of images
*/
public function faces(): JSONResponse
public function recognizePeople(): JSONResponse
{
$user = $this->userSession->getUser();
if (null === $user) {
@ -45,7 +45,7 @@ class FacesController extends ApiBase
// Check faces enabled for this user
if (!$this->recognizeIsEnabled()) {
return new JSONResponse(['message' => 'Recognize app not enabled or not v3+'], Http::STATUS_PRECONDITION_FAILED);
return new JSONResponse(['message' => 'Recognize app not enabled or not v3+.'], Http::STATUS_PRECONDITION_FAILED);
}
// If this isn't the timeline folder then things aren't going to work
@ -55,7 +55,7 @@ class FacesController extends ApiBase
}
// Run actual query
$list = $this->timelineQuery->getFaces(
$list = $this->timelineQuery->getPeopleRecognize(
$root,
);
@ -71,7 +71,7 @@ class FacesController extends ApiBase
*
* @return DataResponse
*/
public function preview(string $id): Http\Response
public function recognizePeoplePreview(int $id): Http\Response
{
$user = $this->userSession->getUser();
if (null === $user) {
@ -90,11 +90,115 @@ class FacesController extends ApiBase
}
// Run actual query
$detections = $this->timelineQuery->getFacePreviewDetection($root, (int) $id);
$detections = $this->timelineQuery->getPeopleRecognizePreview($root, $id);
if (null === $detections || 0 === \count($detections)) {
return new DataResponse([], Http::STATUS_NOT_FOUND);
}
return $this->getPreviewResponse($detections, $user, 1.5);
}
/**
* @NoAdminRequired
*
* Get list of faces with counts of images
*/
public function facerecognitionPeople(): JSONResponse
{
$user = $this->userSession->getUser();
if (null === $user) {
return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
}
// Check if face recognition is installed and enabled for this user
if (!$this->facerecognitionIsInstalled()) {
return new DataResponse([], Http::STATUS_PRECONDITION_FAILED);
}
// If this isn't the timeline folder then things aren't going to work
$root = $this->getRequestRoot();
if ($root->isEmpty()) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
// If the user has recognition disabled, just returns an empty response.
if (!$this->facerecognitionIsEnabled()) {
return new JSONResponse([]);
}
// Run actual query
$currentModel = (int) $this->config->getAppValue('facerecognition', 'model', -1);
$list = $this->timelineQuery->getPeopleFaceRecognition(
$root,
$currentModel,
);
// Just append unnamed clusters to the end.
$list = array_merge($list, $this->timelineQuery->getPeopleFaceRecognition(
$root,
$currentModel,
true
));
return new JSONResponse($list, Http::STATUS_OK);
}
/**
* @NoAdminRequired
*
* @NoCSRFRequired
*
* Get face preview image cropped with imagick
*
* @return DataResponse
*/
public function facerecognitionPeoplePreview(string $id): Http\Response
{
$user = $this->userSession->getUser();
if (null === $user) {
return new DataResponse([], Http::STATUS_PRECONDITION_FAILED);
}
// Check if face recognition is installed and enabled for this user
if (!$this->facerecognitionIsInstalled()) {
return new DataResponse([], Http::STATUS_PRECONDITION_FAILED);
}
// Get folder to search for
$root = $this->getRequestRoot();
if ($root->isEmpty()) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
// If the user has facerecognition disabled, just returns an empty response.
if (!$this->facerecognitionIsEnabled()) {
return new JSONResponse([]);
}
// Run actual query
$currentModel = (int) $this->config->getAppValue('facerecognition', 'model', -1);
$detections = $this->timelineQuery->getFaceRecognitionPreview($root, $currentModel, $id);
if (null === $detections || 0 === \count($detections)) {
return new DataResponse([], Http::STATUS_NOT_FOUND);
}
return $this->getPreviewResponse($detections, $user, 1.8);
}
/**
* Get face preview image cropped with imagick.
*
* @param array $detections Array of detections to search
* @param \OCP\IUser $user User to search for
* @param int $padding Padding to add to the face in preview
*/
private function getPreviewResponse(
array $detections,
\OCP\IUser $user,
float $padding
): Http\Response
{
// Get preview manager
$previewManager = \OC::$server->get(\OCP\IPreview::class);
@ -152,7 +256,7 @@ class FacesController extends ApiBase
$dh = (float) $detection['height'];
$dcx = (float) $detection['x'] + (float) $detection['width'] / 2;
$dcy = (float) $detection['y'] + (float) $detection['height'] / 2;
$faceDim = max($dw * $iw, $dh * $ih) * 1.5;
$faceDim = max($dw * $iw, $dh * $ih) * $padding;
$image->cropImage(
(int) $faceDim,
(int) $faceDim,

View File

@ -11,10 +11,11 @@ class TimelineQuery
{
use TimelineQueryAlbums;
use TimelineQueryDays;
use TimelineQueryFaces;
use TimelineQueryFilters;
use TimelineQueryFolders;
use TimelineQueryLivePhoto;
use TimelineQueryPeopleFaceRecognition;
use TimelineQueryPeopleRecognize;
use TimelineQueryTags;
protected IDBConnection $connection;

View File

@ -299,7 +299,8 @@ trait TimelineQueryDays
}
// All transform processing
$this->processFace($row);
$this->processPeopleRecognizeDetection($row);
$this->processFaceRecognitionDetection($row);
// We don't need these fields
unset($row['datetaken'], $row['rootid']);

View File

@ -0,0 +1,241 @@
<?php
declare(strict_types=1);
namespace OCA\Memories\Db;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\Files\Folder;
use OCP\IDBConnection;
trait TimelineQueryPeopleFaceRecognition
{
protected IDBConnection $connection;
public function transformPeopleFaceRecognitionFilter(IQueryBuilder &$query, string $userId, int $currentModel, string $personStr)
{
// Get title and uid of face user
$personNames = explode('/', $personStr);
if (2 !== \count($personNames)) {
throw new \Exception('Invalid person query');
}
$personUid = $personNames[0];
$personName = $personNames[1];
// Join with images
$query->innerJoin('m', 'facerecog_images', 'fri', $query->expr()->andX(
$query->expr()->eq('fri.file', 'm.fileid'),
$query->expr()->eq('fri.model', $query->createNamedParameter($currentModel)),
));
// Join with faces
$query->innerJoin(
'fri',
'facerecog_faces',
'frf',
$query->expr()->eq('frf.image', 'fri.id')
);
// Join with persons
$nameField = is_numeric($personName) ? 'frp.id' : 'frp.name';
$query->innerJoin('frf', 'facerecog_persons', 'frp', $query->expr()->andX(
$query->expr()->eq('frf.person', 'frp.id'),
$query->expr()->eq('frp.user', $query->createNamedParameter($personUid)),
$query->expr()->eq($nameField, $query->createNamedParameter($personName)),
));
}
public function transformPeopleFaceRecognitionRect(IQueryBuilder &$query, string $userId)
{
// Include detection params in response
$query->addSelect(
'frf.x AS face_x',
'frf.y AS face_y',
'frf.width AS face_width',
'frf.height AS face_height',
'm.w AS image_width',
'm.h AS image_height',
);
}
public function getPeopleFaceRecognition(TimelineRoot &$root, int $currentModel, bool $show_clusters = false, bool $show_singles = false, bool $show_hidden = false)
{
$query = $this->connection->getQueryBuilder();
// SELECT all face clusters
$count = $query->func()->count($query->createFunction('DISTINCT m.fileid'), 'count');
$query->select('frp.id', 'frp.user as user_id', 'frp.name', $count)->from('facerecog_persons', 'frp');
// WHERE there are faces with this cluster
$query->innerJoin('frp', 'facerecog_faces', 'frf', $query->expr()->eq('frp.id', 'frf.person'));
// WHERE faces are from images.
$query->innerJoin('frf', 'facerecog_images', 'fri', $query->expr()->eq('fri.id', 'frf.image'));
// WHERE these items are memories indexed photos
$query->innerJoin('fri', 'memories', 'm', $query->expr()->andX(
$query->expr()->eq('fri.file', 'm.fileid'),
$query->expr()->eq('fri.model', $query->createNamedParameter($currentModel)),
));
// WHERE these photos are in the user's requested folder recursively
$query = $this->joinFilecache($query, $root, true, false);
if ($show_clusters) {
// GROUP by ID of face cluster
$query->groupBy('frp.id');
$query->where($query->expr()->isNull('frp.name'));
} else {
// GROUP by name of face clusters
$query->groupBy('frp.name');
$query->where($query->expr()->isNotNull('frp.name'));
}
// By default hides individual faces when they have no name.
if ($show_clusters && !$show_singles) {
$query->having($query->expr()->gt('count', $query->createNamedParameter(1)));
}
// By default it shows the people who were not hidden
if (!$show_hidden) {
$query->andWhere($query->expr()->eq('frp.is_visible', $query->createNamedParameter(true)));
}
// ORDER by number of faces in cluster
$query->orderBy('count', 'DESC');
$query->addOrderBy('name', 'ASC');
$query->addOrderBy('frp.id'); // tie-breaker
// FETCH all faces
$cursor = $this->executeQueryWithCTEs($query);
$faces = $cursor->fetchAll();
// Post process
foreach ($faces as &$row) {
$row['id'] = $row['name'] ?: (int) $row['id'];
$row['count'] = (int) $row['count'];
}
return $faces;
}
public function getFaceRecognitionPreview(TimelineRoot &$root, $currentModel, $previewId)
{
$query = $this->connection->getQueryBuilder();
// SELECT face detections
$query->select(
'fri.file as file_id', // Get actual file
'frf.x', // Image cropping
'frf.y',
'frf.width',
'frf.height',
'm.w as image_width', // Scoring
'm.h as image_height',
'frf.confidence',
'm.fileid',
'm.datetaken', // Just in case, for postgres
)->from('facerecog_faces', 'frf');
// WHERE faces are from images and current model.
$query->innerJoin('frf', 'facerecog_images', 'fri', $query->expr()->andX(
$query->expr()->eq('fri.id', 'frf.image'),
$query->expr()->eq('fri.model', $query->createNamedParameter($currentModel)),
));
// WHERE these photos are memories indexed
$query->innerJoin('fri', 'memories', 'm', $query->expr()->eq('m.fileid', 'fri.file'));
$query->innerJoin('frf', 'facerecog_persons', 'frp', $query->expr()->eq('frp.id', 'frf.person'));
if (is_numeric($previewId)) {
// WHERE faces are from id persons (a cluster).
$query->where($query->expr()->eq('frp.id', $query->createNamedParameter($previewId)));
} else {
// WHERE faces are from name on persons.
$query->where($query->expr()->eq('frp.name', $query->createNamedParameter($previewId)));
}
// WHERE these photos are in the user's requested folder recursively
$query = $this->joinFilecache($query, $root, true, false);
// LIMIT results
$query->setMaxResults(15);
// Sort by date taken so we get recent photos
$query->orderBy('m.datetaken', 'DESC');
$query->addOrderBy('m.fileid', 'DESC'); // tie-breaker
// FETCH face detections
$cursor = $this->executeQueryWithCTEs($query);
$previews = $cursor->fetchAll();
if (empty($previews)) {
return null;
}
// Score the face detections
foreach ($previews as &$p) {
// Get actual pixel size of face
$iw = min((int) ($p['image_width'] ?: 512), 2048);
$ih = min((int) ($p['image_height'] ?: 512), 2048);
// Get percentage position and size
$p['x'] = (float) $p['x'] / $p['image_width'];
$p['y'] = (float) $p['y'] / $p['image_height'];
$p['width'] = (float) $p['width'] / $p['image_width'];
$p['height'] = (float) $p['height'] / $p['image_height'];
$w = (float) $p['width'];
$h = (float) $p['height'];
// Get center of face
$x = (float) $p['x'] + (float) $p['width'] / 2;
$y = (float) $p['y'] + (float) $p['height'] / 2;
// 3D normal distribution - if the face is closer to the center, it's better
$positionScore = exp(-($x - 0.5) ** 2 * 4) * exp(-($y - 0.5) ** 2 * 4);
// Root size distribution - if the image is bigger, it's better,
// but it doesn't matter beyond a certain point
$imgSizeScore = ($iw * 100) ** (1 / 2) * ($ih * 100) ** (1 / 2);
// Faces occupying too much of the image don't look particularly good
$faceSizeScore = (-$w ** 2 + $w) * (-$h ** 2 + $h);
// Combine scores
$p['score'] = $positionScore * $imgSizeScore * $faceSizeScore * $p['confidence'];
}
// Sort previews by score descending
usort($previews, function ($a, $b) {
return $b['score'] <=> $a['score'];
});
return $previews;
}
/** Convert face fields to object */
private function processFaceRecognitionDetection(&$row, $days = false)
{
if (!isset($row)) {
return;
}
// Differentiate Recognize queries from Face Recognition
if (!isset($row['face_width']) || !isset($row['image_width'])) {
return;
}
if (!$days) {
$row['facerect'] = [
// Get percentage position and size
'w' => (float) $row['face_width'] / $row['image_width'],
'h' => (float) $row['face_height'] / $row['image_height'],
'x' => (float) $row['face_x'] / $row['image_width'],
'y' => (float) $row['face_y'] / $row['image_height'],
];
}
unset($row['face_x'], $row['face_y'], $row['face_w'], $row['face_h'], $row['image_height'], $row['image_width']);
}
}

View File

@ -7,13 +7,13 @@ namespace OCA\Memories\Db;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
trait TimelineQueryFaces
trait TimelineQueryPeopleRecognize
{
protected IDBConnection $connection;
public function transformFaceFilter(IQueryBuilder &$query, string $userId, string $faceStr)
public function transformPeopleRecognitionFilter(IQueryBuilder &$query, string $userId, string $faceStr)
{
// Get title and uid of face user
// Get name and uid of face user
$faceNames = explode('/', $faceStr);
if (2 !== \count($faceNames)) {
throw new \Exception('Invalid face query');
@ -35,7 +35,7 @@ trait TimelineQueryFaces
));
}
public function transformFaceRect(IQueryBuilder &$query, string $userId)
public function transformPeopleRecognizeRect(IQueryBuilder &$query, string $userId)
{
// Include detection params in response
$query->addSelect(
@ -46,7 +46,7 @@ trait TimelineQueryFaces
);
}
public function getFaces(TimelineRoot &$root)
public function getPeopleRecognize(TimelineRoot &$root)
{
$query = $this->connection->getQueryBuilder();
@ -86,7 +86,7 @@ trait TimelineQueryFaces
return $faces;
}
public function getFacePreviewDetection(TimelineRoot &$root, int $id)
public function getPeopleRecognizePreview(TimelineRoot &$root, int $id)
{
$query = $this->connection->getQueryBuilder();
@ -159,8 +159,9 @@ trait TimelineQueryFaces
}
/** Convert face fields to object */
private function processFace(&$row, $days = false)
private function processPeopleRecognizeDetection(&$row, $days = false)
{
// Differentiate Recognize queries from Face Recognition
if (!isset($row) || !isset($row['face_w'])) {
return;
}

View File

@ -4,6 +4,9 @@ declare(strict_types=1);
namespace OCA\Memories;
use OCP\App\IAppManager;
use OCP\IConfig;
class Util
{
public static $TAG_DAYID_START = -(1 << 30); // the world surely didn't exist
@ -46,10 +49,8 @@ class Util
/**
* Check if albums are enabled for this user.
*
* @param mixed $appManager
*/
public static function albumsIsEnabled(&$appManager): bool
public static function albumsIsEnabled(IAppManager &$appManager): bool
{
if (!$appManager->isEnabledForUser('photos')) {
return false;
@ -72,10 +73,8 @@ class Util
/**
* Check if recognize is enabled for this user.
*
* @param mixed $appManager
*/
public static function recognizeIsEnabled(&$appManager): bool
public static function recognizeIsEnabled(IAppManager &$appManager): bool
{
if (!$appManager->isEnabledForUser('recognize')) {
return false;
@ -87,11 +86,33 @@ class Util
}
/**
* Check if link sharing is allowed.
*
* @param mixed $config
* Check if Face Recognition is enabled by the user.
*/
public static function isLinkSharingEnabled(&$config): bool
public static function facerecognitionIsEnabled(IConfig &$config, string $userId): bool
{
$e = $config->getUserValue($userId, 'facerecognition', 'enabled', 'false');
return 'true' === $e;
}
/**
* Check if Face Recognition is installed and enabled for this user.
*/
public static function facerecognitionIsInstalled(IAppManager &$appManager): bool
{
if (!$appManager->isEnabledForUser('facerecognition')) {
return false;
}
$v = $appManager->getAppInfo('facerecognition')['version'];
return version_compare($v, '0.9.10-beta.2', '>=');
}
/**
* Check if link sharing is allowed.
*/
public static function isLinkSharingEnabled(IConfig &$config): bool
{
// Check if the shareAPI is enabled
if ('yes' !== $config->getAppValue('core', 'shareapi_enabled', 'yes')) {

View File

@ -99,7 +99,7 @@ export default class App extends Mixins(GlobalMixin, UserConfig) {
private metadataComponent!: Metadata;
private readonly navItemsAll = [
private readonly navItemsAll = (self: typeof this) => [
{
name: "timeline",
icon: ImageMultiple,
@ -124,13 +124,19 @@ export default class App extends Mixins(GlobalMixin, UserConfig) {
name: "albums",
icon: AlbumIcon,
title: t("memories", "Albums"),
if: (self: any) => self.showAlbums,
if: self.showAlbums,
},
{
name: "people",
name: "recognize",
icon: PeopleIcon,
title: t("memories", "People"),
if: (self: any) => self.showPeople,
title: self.recognize,
if: self.recognize,
},
{
name: "facerecognition",
icon: PeopleIcon,
title: self.facerecognition,
if: self.facerecognition,
},
{
name: "archive",
@ -146,13 +152,13 @@ export default class App extends Mixins(GlobalMixin, UserConfig) {
name: "tags",
icon: TagsIcon,
title: t("memories", "Tags"),
if: (self: any) => self.config_tagsEnabled,
if: self.config_tagsEnabled,
},
{
name: "maps",
icon: MapIcon,
title: t("memories", "Maps"),
if: (self: any) => self.config_mapsEnabled,
if: self.config_mapsEnabled,
},
];
@ -163,8 +169,28 @@ export default class App extends Mixins(GlobalMixin, UserConfig) {
return Number(version[0]);
}
get showPeople() {
return this.config_recognizeEnabled || getCurrentUser()?.isAdmin;
get recognize() {
if (!this.config_recognizeEnabled) {
return false;
}
if (this.config_facerecognitionInstalled) {
return t("memories", "People (Recognize)");
}
return t("memories", "People");
}
get facerecognition() {
if (!this.config_facerecognitionInstalled) {
return false;
}
if (this.config_recognizeEnabled) {
return t("memories", "People (Face Recognition)");
}
return t("memories", "People");
}
get isFirstStart() {
@ -192,8 +218,8 @@ export default class App extends Mixins(GlobalMixin, UserConfig) {
this.doRouteChecks();
// Populate navigation
this.navItems = this.navItemsAll.filter(
(item) => !item.if || item.if(this)
this.navItems = this.navItemsAll(this).filter(
(item) => typeof item.if === "undefined" || Boolean(item.if)
);
// Store CSS variables modified

View File

@ -209,13 +209,13 @@ export default class SelectionManager extends Mixins(GlobalMixin, UserConfig) {
name: t("memories", "Move to another person"),
icon: MoveIcon,
callback: this.moveSelectionToPerson.bind(this),
if: () => this.$route.name === "people",
if: () => this.$route.name === "recognize",
},
{
name: t("memories", "Remove from person"),
icon: CloseIcon,
callback: this.removeSelectionFromPerson.bind(this),
if: () => this.$route.name === "people",
if: () => this.$route.name === "recognize",
},
];
@ -846,7 +846,7 @@ export default class SelectionManager extends Mixins(GlobalMixin, UserConfig) {
private async removeSelectionFromPerson(selection: Selection) {
// Make sure route is valid
const { user, name } = this.$route.params;
if (this.$route.name !== "people" || !user || !name) {
if (this.$route.name !== "recognize" || !user || !name) {
return;
}

View File

@ -10,11 +10,12 @@
<!-- No content found and nothing is loading -->
<NcEmptyContent
title="Nothing to show here"
:description="emptyViewDescription"
v-if="loading === 0 && list.length === 0"
>
<template #icon>
<PeopleIcon v-if="$route.name === 'people'" />
<ArchiveIcon v-else-if="$route.name === 'archive'" />
<PeopleIcon v-if="routeIsPeople" />
<ArchiveIcon v-else-if="routeIsArchive" />
<ImageMultipleIcon v-else />
</template>
</NcEmptyContent>
@ -43,7 +44,7 @@
</div>
<OnThisDay
v-if="$route.name === 'timeline'"
v-if="routeIsBase"
:key="config_timelinePath"
:viewer="$refs.viewer"
@load="scrollerManager.adjust()"
@ -308,6 +309,18 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
window.removeEventListener("resize", this.handleResizeWithDelay);
}
get routeIsBase() {
return this.$route.name === "timeline";
}
get routeIsPeople() {
return ["recognize", "facerecognition"].includes(this.$route.name);
}
get routeIsArchive() {
return this.$route.name === "archive";
}
updateLoading(delta: number) {
this.loading += delta;
}
@ -574,12 +587,12 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
// People
if (
this.$route.name === "people" &&
this.routeIsPeople &&
this.$route.params.user &&
this.$route.params.name
) {
query.set(
"face",
this.$route.name, // "recognize" or "facerecognition"
`${this.$route.params.user}/${this.$route.params.name}`
);
@ -618,7 +631,8 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
return this.t("memories", "Your Timeline");
case "favorites":
return this.t("memories", "Favorites");
case "people":
case "recognize":
case "facerecognition":
return this.t("memories", "People");
case "videos":
return this.t("memories", "Videos");
@ -663,6 +677,33 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
return head.name;
}
/* Get a friendly description of empty view */
get emptyViewDescription() {
switch (this.$route.name) {
case "facerecognition":
if (this.config_facerecognitionEnabled)
return this.t(
"memories",
"You will find your friends soon. Please, be patient."
);
else
return this.t(
"memories",
"Face Recognition is disabled. Enable in settings to find your friends."
);
case "timeline":
case "favorites":
case "recognize":
case "videos":
case "albums":
case "archive":
case "thisday":
case "tags":
default:
return "";
}
}
/** Fetch timeline main call */
async fetchDays(noCache = false) {
const url = API.Q(API.DAYS(), this.getQuery());
@ -683,8 +724,8 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
data = await dav.getOnThisDayData();
} else if (this.$route.name === "tags" && !this.$route.params.name) {
data = await dav.getTagsData();
} else if (this.$route.name === "people" && !this.$route.params.name) {
data = await dav.getPeopleData();
} else if (this.routeIsPeople && !this.$route.params.name) {
data = await dav.getPeopleData(this.$route.name as any);
} else if (this.$route.name === "albums" && !this.$route.params.name) {
data = await dav.getAlbumsData("3");
} else {

View File

@ -58,7 +58,7 @@ export default class Tag extends Mixins(GlobalMixin) {
get previewUrl() {
if (this.face) {
return API.FACE_PREVIEW(this.face.fileid);
return API.FACE_PREVIEW(this.faceApp, this.face.fileid);
}
if (this.album) {
@ -78,7 +78,16 @@ export default class Tag extends Mixins(GlobalMixin) {
}
get face() {
return this.data.flag & constants.c.FLAG_IS_FACE ? this.data : null;
return this.data.flag & constants.c.FLAG_IS_FACE_RECOGNIZE ||
this.data.flag & constants.c.FLAG_IS_FACE_RECOGNITION
? this.data
: null;
}
get faceApp() {
return this.data.flag & constants.c.FLAG_IS_FACE_RECOGNITION
? "facerecognition"
: "recognize";
}
get album() {
@ -94,7 +103,7 @@ export default class Tag extends Mixins(GlobalMixin) {
if (this.face) {
const name = this.face.name || this.face.fileid.toString();
const user = this.face.user_id;
return { name: "people", params: { name, user } };
return { name: this.faceApp, params: { name, user } };
}
if (this.album) {

View File

@ -27,6 +27,7 @@ import { getCurrentUser } from "@nextcloud/auth";
import Modal from "./Modal.vue";
import GlobalMixin from "../../mixins/GlobalMixin";
import client from "../../services/DavClient";
import * as dav from "../../services/DavRequests";
@Component({
components: {
@ -74,8 +75,12 @@ export default class FaceDeleteModal extends Mixins(GlobalMixin) {
public async save() {
try {
if (this.$route.name === "recognize") {
await client.deleteFile(`/recognize/${this.user}/faces/${this.name}`);
this.$router.push({ name: "people" });
} else {
await dav.setVisibilityPeopleFaceRecognition(this.name, false);
}
this.$router.push({ name: this.$route.name });
this.close();
} catch (error) {
console.log(error);

View File

@ -34,6 +34,7 @@ import { getCurrentUser } from "@nextcloud/auth";
import Modal from "./Modal.vue";
import GlobalMixin from "../../mixins/GlobalMixin";
import client from "../../services/DavClient";
import * as dav from "../../services/DavRequests";
@Component({
components: {
@ -83,12 +84,16 @@ export default class FaceEditModal extends Mixins(GlobalMixin) {
public async save() {
try {
if (this.$route.name === "recognize") {
await client.moveFile(
`/recognize/${this.user}/faces/${this.oldName}`,
`/recognize/${this.user}/faces/${this.name}`
);
} else {
await dav.renamePeopleFaceRecognition(this.oldName, this.name);
}
this.$router.push({
name: "people",
name: this.$route.name,
params: { user: this.user, name: this.name },
});
this.close();

View File

@ -44,10 +44,18 @@ export default class FaceMergeModal extends Mixins(GlobalMixin) {
this.name = this.$route.params.name || "";
this.detail = null;
const data = await dav.getPeopleData();
let data = [];
let flags = this.c.FLAG_IS_TAG;
if (this.$route.name === "recognize") {
data = await dav.getPeopleData("recognize");
flags |= this.c.FLAG_IS_FACE_RECOGNIZE;
} else {
data = await dav.getPeopleData("facerecognition");
flags |= this.c.FLAG_IS_FACE_RECOGNITION;
}
let detail = data[0].detail;
detail.forEach((photo: IPhoto) => {
photo.flag = this.c.FLAG_IS_FACE | this.c.FLAG_IS_TAG;
photo.flag = flags;
});
detail = detail.filter((photo: ITag) => {
const pname = photo.name || photo.fileid.toString();

View File

@ -135,7 +135,7 @@ export default class FaceMergeModal extends Mixins(GlobalMixin) {
// Go to new face
if (failures === 0) {
this.$router.push({
name: "people",
name: "recognize",
params: { user: face.user_id, name: newName },
});
this.close();

View File

@ -99,7 +99,7 @@ export default class FaceTopMatter extends Mixins(GlobalMixin, UserConfig) {
}
back() {
this.$router.push({ name: "people" });
this.$router.push({ name: this.$route.name });
}
changeShowFaceRect() {

View File

@ -47,7 +47,8 @@ export default class TopMatter extends Mixins(GlobalMixin) {
return this.$route.params.name
? TopMatterType.TAG
: TopMatterType.NONE;
case "people":
case "recognize":
case "facerecognition":
return this.$route.params.name
? TopMatterType.FACE
: TopMatterType.NONE;

View File

@ -26,6 +26,12 @@ export default class UserConfig extends Vue {
config_recognizeEnabled = Boolean(
loadState("memories", "recognize", <string>"")
);
config_facerecognitionInstalled = Boolean(
loadState("memories", "facerecognitionInstalled", <string>"")
);
config_facerecognitionEnabled = Boolean(
loadState("memories", "facerecognitionEnabled", <string>"")
);
config_mapsEnabled = Boolean(loadState("memories", "maps", <string>""));
config_albumsEnabled = Boolean(loadState("memories", "albums", <string>""));

View File

@ -77,9 +77,18 @@ export default new Router({
},
{
path: "/people/:user?/:name?",
path: "/recognize/:user?/:name?",
component: Timeline,
name: "people",
name: "recognize",
props: (route) => ({
rootTitle: t("memories", "People"),
}),
},
{
path: "/facerecognition/:user?/:name?",
component: Timeline,
name: "facerecognition",
props: (route) => ({
rootTitle: t("memories", "People"),
}),

View File

@ -50,12 +50,15 @@ export class API {
return gen(`${BASE}/tags/preview/{tag}`, { tag });
}
static FACE_LIST() {
return gen(`${BASE}/faces`);
static FACE_LIST(app: "recognize" | "facerecognition") {
return gen(`${BASE}/${app}/people`);
}
static FACE_PREVIEW(face: string | number) {
return gen(`${BASE}/faces/preview/{face}`, { face });
static FACE_PREVIEW(
app: "recognize" | "facerecognition",
face: string | number
) {
return gen(`${BASE}/${app}/people/preview/{face}`, { face });
}
static ARCHIVE(fileid: number) {

View File

@ -212,7 +212,12 @@ export function convertFlags(photo: IPhoto) {
delete photo.isfolder;
}
if (photo.isface) {
photo.flag |= constants.c.FLAG_IS_FACE;
const app = photo.isface;
if (app === "recognize") {
photo.flag |= constants.c.FLAG_IS_FACE_RECOGNIZE;
} else if (app === "facerecognition") {
photo.flag |= constants.c.FLAG_IS_FACE_RECOGNITION;
}
delete photo.isface;
}
if (photo.istag) {
@ -310,10 +315,11 @@ export const constants = {
FLAG_IS_FAVORITE: 1 << 3,
FLAG_IS_FOLDER: 1 << 4,
FLAG_IS_TAG: 1 << 5,
FLAG_IS_FACE: 1 << 6,
FLAG_IS_ALBUM: 1 << 7,
FLAG_SELECTED: 1 << 8,
FLAG_LEAVING: 1 << 9,
FLAG_IS_FACE_RECOGNIZE: 1 << 6,
FLAG_IS_FACE_RECOGNITION: 1 << 7,
FLAG_IS_ALBUM: 1 << 8,
FLAG_SELECTED: 1 << 9,
FLAG_LEAVING: 1 << 10,
},
TagDayID: TagDayID,

View File

@ -1,16 +1,19 @@
import axios from "@nextcloud/axios";
import { showError } from "@nextcloud/dialogs";
import { translate as t } from "@nextcloud/l10n";
import { generateUrl } from "@nextcloud/router";
import { IDay, IPhoto } from "../../types";
import { API } from "../API";
import client from "../DavClient";
import { constants } from "../Utils";
import client from "../DavClient";
import * as base from "./base";
/**
* Get list of tags and convert to Days response
*/
export async function getPeopleData(): Promise<IDay[]> {
export async function getPeopleData(
app: "recognize" | "facerecognition"
): Promise<IDay[]> {
// Query for photos
let data: {
id: number;
@ -19,7 +22,7 @@ export async function getPeopleData(): Promise<IDay[]> {
previews: IPhoto[];
}[] = [];
try {
const res = await axios.get<typeof data>(API.FACE_LIST());
const res = await axios.get<typeof data>(API.FACE_LIST(app));
data = res.data;
} catch (e) {
throw e;
@ -39,13 +42,48 @@ export async function getPeopleData(): Promise<IDay[]> {
...face,
fileid: face.id,
istag: true,
isface: true,
isface: app,
} as any)
),
},
];
}
export async function updatePeopleFaceRecognition(
name: string,
params: object
) {
if (Number.isInteger(Number(name))) {
return await axios.put(
generateUrl(`/apps/facerecognition/api/2.0/cluster/${name}`),
params
);
} else {
return await axios.put(
generateUrl(`/apps/facerecognition/api/2.0/person/${name}`),
params
);
}
}
export async function renamePeopleFaceRecognition(
name: string,
newName: string
) {
return await updatePeopleFaceRecognition(name, {
name: newName,
});
}
export async function setVisibilityPeopleFaceRecognition(
name: string,
visibility: boolean
) {
return await updatePeopleFaceRecognition(name, {
visible: visibility,
});
}
/**
* Remove images from a face.
*

View File

@ -105,7 +105,7 @@ export type IPhoto = {
/** Is this an album */
isalbum?: boolean;
/** Is this a face */
isface?: boolean;
isface?: "recognize" | "facerecognition";
/** Optional datetaken epoch */
datetaken?: number;
};