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 // Routes with params
w(['name' => 'Page#folder', 'url' => '/folders/{path}', 'verb' => 'GET'], 'path'), w(['name' => 'Page#folder', 'url' => '/folders/{path}', 'verb' => 'GET'], 'path'),
w(['name' => 'Page#albums', 'url' => '/albums/{id}', 'verb' => 'GET'], 'id'), 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'), w(['name' => 'Page#tags', 'url' => '/tags/{name}', 'verb' => 'GET'], 'name'),
// Public folder share // Public folder share
@ -50,8 +51,10 @@ return [
['name' => 'Tags#tags', 'url' => '/api/tags', 'verb' => 'GET'], ['name' => 'Tags#tags', 'url' => '/api/tags', 'verb' => 'GET'],
['name' => 'Tags#preview', 'url' => '/api/tags/preview/{tag}', 'verb' => 'GET'], ['name' => 'Tags#preview', 'url' => '/api/tags/preview/{tag}', 'verb' => 'GET'],
['name' => 'Faces#faces', 'url' => '/api/faces', 'verb' => 'GET'], ['name' => 'People#recognizePeople', 'url' => '/api/recognize/people', 'verb' => 'GET'],
['name' => 'Faces#preview', 'url' => '/api/faces/preview/{id}', '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'], ['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); 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. * Helper to get one file or null from a fiolder.
*/ */

View File

@ -198,16 +198,24 @@ class DaysController extends ApiBase
$transforms[] = [$this->timelineQuery, 'transformVideoFilter']; $transforms[] = [$this->timelineQuery, 'transformVideoFilter'];
} }
// Filter only for one face // Filter only for one face on Recognize
if ($this->recognizeIsEnabled()) { if (($recognize = $this->request->getParam('recognize')) && $this->recognizeIsEnabled()) {
$face = $this->request->getParam('face'); $transforms[] = [$this->timelineQuery, 'transformPeopleRecognitionFilter', $recognize];
if ($face) {
$transforms[] = [$this->timelineQuery, 'transformFaceFilter', $face];
}
$faceRect = $this->request->getParam('facerect'); $faceRect = $this->request->getParam('facerect');
if ($faceRect && !$aggregateOnly) { 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('systemtags', true === $this->appManager->isEnabledForUser('systemtags'));
$this->initialState->provideInitialState('maps', true === $this->appManager->isEnabledForUser('maps')); $this->initialState->provideInitialState('maps', true === $this->appManager->isEnabledForUser('maps'));
$this->initialState->provideInitialState('recognize', \OCA\Memories\Util::recognizeIsEnabled($this->appManager)); $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)); $this->initialState->provideInitialState('albums', \OCA\Memories\Util::albumsIsEnabled($this->appManager));
// App version // App version
@ -181,7 +183,17 @@ class PageController extends Controller
* *
* @NoCSRFRequired * @NoCSRFRequired
*/ */
public function people() public function recognize()
{
return $this->main();
}
/**
* @NoAdminRequired
*
* @NoCSRFRequired
*/
public function facerecognition()
{ {
return $this->main(); return $this->main();
} }

View File

@ -29,14 +29,14 @@ use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\Http\JSONResponse;
use OCP\Files\FileInfo; use OCP\Files\FileInfo;
class FacesController extends ApiBase class PeopleController extends ApiBase
{ {
/** /**
* @NoAdminRequired * @NoAdminRequired
* *
* Get list of faces with counts of images * Get list of faces with counts of images
*/ */
public function faces(): JSONResponse public function recognizePeople(): JSONResponse
{ {
$user = $this->userSession->getUser(); $user = $this->userSession->getUser();
if (null === $user) { if (null === $user) {
@ -45,7 +45,7 @@ class FacesController extends ApiBase
// Check faces enabled for this user // Check faces enabled for this user
if (!$this->recognizeIsEnabled()) { 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 // 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 // Run actual query
$list = $this->timelineQuery->getFaces( $list = $this->timelineQuery->getPeopleRecognize(
$root, $root,
); );
@ -71,7 +71,7 @@ class FacesController extends ApiBase
* *
* @return DataResponse * @return DataResponse
*/ */
public function preview(string $id): Http\Response public function recognizePeoplePreview(int $id): Http\Response
{ {
$user = $this->userSession->getUser(); $user = $this->userSession->getUser();
if (null === $user) { if (null === $user) {
@ -90,11 +90,115 @@ class FacesController extends ApiBase
} }
// Run actual query // Run actual query
$detections = $this->timelineQuery->getFacePreviewDetection($root, (int) $id); $detections = $this->timelineQuery->getPeopleRecognizePreview($root, $id);
if (null === $detections || 0 === \count($detections)) { if (null === $detections || 0 === \count($detections)) {
return new DataResponse([], Http::STATUS_NOT_FOUND); 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 // Get preview manager
$previewManager = \OC::$server->get(\OCP\IPreview::class); $previewManager = \OC::$server->get(\OCP\IPreview::class);
@ -152,7 +256,7 @@ class FacesController extends ApiBase
$dh = (float) $detection['height']; $dh = (float) $detection['height'];
$dcx = (float) $detection['x'] + (float) $detection['width'] / 2; $dcx = (float) $detection['x'] + (float) $detection['width'] / 2;
$dcy = (float) $detection['y'] + (float) $detection['height'] / 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( $image->cropImage(
(int) $faceDim, (int) $faceDim,
(int) $faceDim, (int) $faceDim,

View File

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

View File

@ -299,7 +299,8 @@ trait TimelineQueryDays
} }
// All transform processing // All transform processing
$this->processFace($row); $this->processPeopleRecognizeDetection($row);
$this->processFaceRecognitionDetection($row);
// We don't need these fields // We don't need these fields
unset($row['datetaken'], $row['rootid']); 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\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection; use OCP\IDBConnection;
trait TimelineQueryFaces trait TimelineQueryPeopleRecognize
{ {
protected IDBConnection $connection; 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); $faceNames = explode('/', $faceStr);
if (2 !== \count($faceNames)) { if (2 !== \count($faceNames)) {
throw new \Exception('Invalid face query'); 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 // Include detection params in response
$query->addSelect( $query->addSelect(
@ -46,7 +46,7 @@ trait TimelineQueryFaces
); );
} }
public function getFaces(TimelineRoot &$root) public function getPeopleRecognize(TimelineRoot &$root)
{ {
$query = $this->connection->getQueryBuilder(); $query = $this->connection->getQueryBuilder();
@ -86,7 +86,7 @@ trait TimelineQueryFaces
return $faces; return $faces;
} }
public function getFacePreviewDetection(TimelineRoot &$root, int $id) public function getPeopleRecognizePreview(TimelineRoot &$root, int $id)
{ {
$query = $this->connection->getQueryBuilder(); $query = $this->connection->getQueryBuilder();
@ -159,8 +159,9 @@ trait TimelineQueryFaces
} }
/** Convert face fields to object */ /** 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'])) { if (!isset($row) || !isset($row['face_w'])) {
return; return;
} }

View File

@ -4,6 +4,9 @@ declare(strict_types=1);
namespace OCA\Memories; namespace OCA\Memories;
use OCP\App\IAppManager;
use OCP\IConfig;
class Util class Util
{ {
public static $TAG_DAYID_START = -(1 << 30); // the world surely didn't exist 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. * 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')) { if (!$appManager->isEnabledForUser('photos')) {
return false; return false;
@ -72,10 +73,8 @@ class Util
/** /**
* Check if recognize is enabled for this user. * 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')) { if (!$appManager->isEnabledForUser('recognize')) {
return false; return false;
@ -87,11 +86,33 @@ class Util
} }
/** /**
* Check if link sharing is allowed. * Check if Face Recognition is enabled by the user.
*
* @param mixed $config
*/ */
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 // Check if the shareAPI is enabled
if ('yes' !== $config->getAppValue('core', 'shareapi_enabled', 'yes')) { 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 metadataComponent!: Metadata;
private readonly navItemsAll = [ private readonly navItemsAll = (self: typeof this) => [
{ {
name: "timeline", name: "timeline",
icon: ImageMultiple, icon: ImageMultiple,
@ -124,13 +124,19 @@ export default class App extends Mixins(GlobalMixin, UserConfig) {
name: "albums", name: "albums",
icon: AlbumIcon, icon: AlbumIcon,
title: t("memories", "Albums"), title: t("memories", "Albums"),
if: (self: any) => self.showAlbums, if: self.showAlbums,
}, },
{ {
name: "people", name: "recognize",
icon: PeopleIcon, icon: PeopleIcon,
title: t("memories", "People"), title: self.recognize,
if: (self: any) => self.showPeople, if: self.recognize,
},
{
name: "facerecognition",
icon: PeopleIcon,
title: self.facerecognition,
if: self.facerecognition,
}, },
{ {
name: "archive", name: "archive",
@ -146,13 +152,13 @@ export default class App extends Mixins(GlobalMixin, UserConfig) {
name: "tags", name: "tags",
icon: TagsIcon, icon: TagsIcon,
title: t("memories", "Tags"), title: t("memories", "Tags"),
if: (self: any) => self.config_tagsEnabled, if: self.config_tagsEnabled,
}, },
{ {
name: "maps", name: "maps",
icon: MapIcon, icon: MapIcon,
title: t("memories", "Maps"), 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]); return Number(version[0]);
} }
get showPeople() { get recognize() {
return this.config_recognizeEnabled || getCurrentUser()?.isAdmin; 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() { get isFirstStart() {
@ -192,8 +218,8 @@ export default class App extends Mixins(GlobalMixin, UserConfig) {
this.doRouteChecks(); this.doRouteChecks();
// Populate navigation // Populate navigation
this.navItems = this.navItemsAll.filter( this.navItems = this.navItemsAll(this).filter(
(item) => !item.if || item.if(this) (item) => typeof item.if === "undefined" || Boolean(item.if)
); );
// Store CSS variables modified // 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"), name: t("memories", "Move to another person"),
icon: MoveIcon, icon: MoveIcon,
callback: this.moveSelectionToPerson.bind(this), callback: this.moveSelectionToPerson.bind(this),
if: () => this.$route.name === "people", if: () => this.$route.name === "recognize",
}, },
{ {
name: t("memories", "Remove from person"), name: t("memories", "Remove from person"),
icon: CloseIcon, icon: CloseIcon,
callback: this.removeSelectionFromPerson.bind(this), 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) { private async removeSelectionFromPerson(selection: Selection) {
// Make sure route is valid // Make sure route is valid
const { user, name } = this.$route.params; const { user, name } = this.$route.params;
if (this.$route.name !== "people" || !user || !name) { if (this.$route.name !== "recognize" || !user || !name) {
return; return;
} }

View File

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

View File

@ -58,7 +58,7 @@ export default class Tag extends Mixins(GlobalMixin) {
get previewUrl() { get previewUrl() {
if (this.face) { if (this.face) {
return API.FACE_PREVIEW(this.face.fileid); return API.FACE_PREVIEW(this.faceApp, this.face.fileid);
} }
if (this.album) { if (this.album) {
@ -78,7 +78,16 @@ export default class Tag extends Mixins(GlobalMixin) {
} }
get face() { 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() { get album() {
@ -94,7 +103,7 @@ export default class Tag extends Mixins(GlobalMixin) {
if (this.face) { if (this.face) {
const name = this.face.name || this.face.fileid.toString(); const name = this.face.name || this.face.fileid.toString();
const user = this.face.user_id; const user = this.face.user_id;
return { name: "people", params: { name, user } }; return { name: this.faceApp, params: { name, user } };
} }
if (this.album) { if (this.album) {

View File

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

View File

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

View File

@ -44,10 +44,18 @@ export default class FaceMergeModal extends Mixins(GlobalMixin) {
this.name = this.$route.params.name || ""; this.name = this.$route.params.name || "";
this.detail = null; 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; let detail = data[0].detail;
detail.forEach((photo: IPhoto) => { detail.forEach((photo: IPhoto) => {
photo.flag = this.c.FLAG_IS_FACE | this.c.FLAG_IS_TAG; photo.flag = flags;
}); });
detail = detail.filter((photo: ITag) => { detail = detail.filter((photo: ITag) => {
const pname = photo.name || photo.fileid.toString(); const pname = photo.name || photo.fileid.toString();

View File

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

View File

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

View File

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

View File

@ -26,6 +26,12 @@ export default class UserConfig extends Vue {
config_recognizeEnabled = Boolean( config_recognizeEnabled = Boolean(
loadState("memories", "recognize", <string>"") 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_mapsEnabled = Boolean(loadState("memories", "maps", <string>""));
config_albumsEnabled = Boolean(loadState("memories", "albums", <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, component: Timeline,
name: "people", name: "recognize",
props: (route) => ({
rootTitle: t("memories", "People"),
}),
},
{
path: "/facerecognition/:user?/:name?",
component: Timeline,
name: "facerecognition",
props: (route) => ({ props: (route) => ({
rootTitle: t("memories", "People"), rootTitle: t("memories", "People"),
}), }),

View File

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

View File

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

View File

@ -1,16 +1,19 @@
import axios from "@nextcloud/axios"; import axios from "@nextcloud/axios";
import { showError } from "@nextcloud/dialogs"; import { showError } from "@nextcloud/dialogs";
import { translate as t } from "@nextcloud/l10n"; import { translate as t } from "@nextcloud/l10n";
import { generateUrl } from "@nextcloud/router";
import { IDay, IPhoto } from "../../types"; import { IDay, IPhoto } from "../../types";
import { API } from "../API"; import { API } from "../API";
import client from "../DavClient";
import { constants } from "../Utils"; import { constants } from "../Utils";
import client from "../DavClient";
import * as base from "./base"; import * as base from "./base";
/** /**
* Get list of tags and convert to Days response * 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 // Query for photos
let data: { let data: {
id: number; id: number;
@ -19,7 +22,7 @@ export async function getPeopleData(): Promise<IDay[]> {
previews: IPhoto[]; previews: IPhoto[];
}[] = []; }[] = [];
try { 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; data = res.data;
} catch (e) { } catch (e) {
throw e; throw e;
@ -39,13 +42,48 @@ export async function getPeopleData(): Promise<IDay[]> {
...face, ...face,
fileid: face.id, fileid: face.id,
istag: true, istag: true,
isface: true, isface: app,
} as any) } 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. * Remove images from a face.
* *

View File

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