Passed
Push — shared-storage-experiments ( 3f9681...fb0eff )
by Matias
08:14
created

ImageProcessingTask::findFaces()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 37
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 20

Importance

Changes 3
Bugs 1 Features 0
Metric Value
cc 4
eloc 22
c 3
b 1
f 0
nc 4
nop 2
dl 0
loc 37
ccs 0
cts 23
cp 0
crap 20
rs 9.568
1
<?php
2
/**
3
 * @copyright Copyright (c) 2017, Matias De lellis <[email protected]>
4
 * @copyright Copyright (c) 2018, Branko Kokanovic <[email protected]>
5
 *
6
 * @author Branko Kokanovic <[email protected]>
7
 *
8
 * @license GNU AGPL version 3 or any later version
9
 *
10
 * This program is free software: you can redistribute it and/or modify
11
 * it under the terms of the GNU Affero General Public License as
12
 * published by the Free Software Foundation, either version 3 of the
13
 * License, or (at your option) any later version.
14
 *
15
 * This program is distributed in the hope that it will be useful,
16
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
17
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
18
 * GNU Affero General Public License for more details.
19
 *
20
 * You should have received a copy of the GNU Affero General Public License
21
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
22
 *
23
 */
24
namespace OCA\FaceRecognition\BackgroundJob\Tasks;
25
26
use OCP\Image as OCP_Image;
27
28
use OCP\Files\File;
29
use OCP\Files\Folder;
30
use OCP\IConfig;
31
use OCP\ITempManager;
32
use OCP\IUser;
33
34
use OCA\FaceRecognition\BackgroundJob\FaceRecognitionBackgroundTask;
35
use OCA\FaceRecognition\BackgroundJob\FaceRecognitionContext;
36
use OCA\FaceRecognition\Db\Face;
37
use OCA\FaceRecognition\Db\Image;
38
use OCA\FaceRecognition\Db\ImageMapper;
39
use OCA\FaceRecognition\Helper\Requirements;
40
use OCA\FaceRecognition\Migration\AddDefaultFaceModel;
41
42
/**
43
 * Plain old PHP object holding all information
44
 * that are needed to process all faces from one image
45
 */
46
class ImageProcessingContext {
47
	/** @var string Path to the image being processed */
48
	private $imagePath;
49
50
	/** @var string Path to temporary, resized image */
51
	private $tempPath;
52
53
	/** @var float Ratio of resized image, when scaling it */
54
	private $ratio;
55
56
	/** @var array<Face> All found faces in image */
57
	private $faces;
58
59
	/**
60
	 * @var bool True if detection should be skipped, but image should be marked as processed.
61
	 * If this is set, $tempPath and $ratio will be invalid and $faces should be empty array.
62
	 */
63
	private $skipDetection;
64
65
	public function __construct(string $imagePath, string $tempPath, float $ratio, bool $skipDetection) {
66
		$this->imagePath = $imagePath;
67
		$this->tempPath = $tempPath;
68
		$this->ratio = $ratio;
69
		$this->faces = array();
70
		$this->skipDetection = $skipDetection;
71
	}
72
73
	public function getImagePath(): string {
74
		return $this->imagePath;
75
	}
76
77
	public function getTempPath(): string {
78
		return $this->tempPath;
79
	}
80
81
	public function getRatio(): float {
82
		return $this->ratio;
83
	}
84
85
	public function getSkipDetection(): bool {
86
		return $this->skipDetection;
87
	}
88
89
	/**
90
	 * Gets all faces
91
	 *
92
	 * @return Face[] Array of faces
93
	 */
94
	public function getFaces(): array {
95
		return $this->faces;
96
	}
97
98
	/**
99
	 * @param array<Face> $faces Array of faces to set
100
	 */
101
	public function setFaces($faces) {
102
		$this->faces = $faces;
103
	}
104
}
105
106
/**
107
 * Taks that get all images that are still not processed and processes them.
108
 * Processing image means that each image is prepared, faces extracted form it,
109
 * and for each found face - face descriptor is extracted.
110
 */
111
class ImageProcessingTask extends FaceRecognitionBackgroundTask {
112
	/** @var IConfig Config */
113
	private $config;
114
115
	/** @var ImageMapper Image mapper*/
116
	protected $imageMapper;
117
118
	/** @var ITempManager */
119
	private $tempManager;
120
121
	/** @var int|null Maximum image area (cached, so it is not recalculated for each image) */
122
	private $maxImageAreaCached;
123
124
	/**
125
	 * @param ImageMapper $imageMapper Image mapper
126
	 */
127 5
	public function __construct(IConfig $config, ImageMapper $imageMapper, ITempManager $tempManager) {
128 5
		parent::__construct();
129 5
		$this->config = $config;
130 5
		$this->imageMapper = $imageMapper;
131 5
		$this->tempManager = $tempManager;
132 5
		$this->maxImageAreaCached = null;
133 5
	}
134
135
	/**
136
	 * @inheritdoc
137
	 */
138 4
	public function description() {
139 4
		return "Process all images to extract faces";
140
	}
141
142
	/**
143
	 * @inheritdoc
144
	 */
145 4
	public function execute(FaceRecognitionContext $context) {
146 4
		$this->setContext($context);
147
148 4
		$model = intval($this->config->getAppValue('facerecognition', 'model', AddDefaultFaceModel::DEFAULT_FACE_MODEL_ID));
149 4
		$requirements = new Requirements($context->modelService, $model);
150
151 4
		$images = $context->propertyBag['images'];
152
153 4
		$cfd = new \CnnFaceDetection($requirements->getFaceDetectionModel());
0 ignored issues
show
Bug introduced by
The type CnnFaceDetection was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
154
		$fld = new \FaceLandmarkDetection($requirements->getLandmarksDetectionModel());
0 ignored issues
show
Bug introduced by
The type FaceLandmarkDetection was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
155
		$fr = new \FaceRecognition($requirements->getFaceRecognitionModel());
0 ignored issues
show
Bug introduced by
The type FaceRecognition was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
156
157
		$this->logInfo('NOTE: Starting face recognition. If you experience random crashes after this point, please look FAQ at https://github.com/matiasdelellis/facerecognition/wiki/FAQ');
158
159
		foreach($images as $image) {
160
			yield;
161
162
			$imageProcessingContext = null;
0 ignored issues
show
Unused Code introduced by
The assignment to $imageProcessingContext is dead and can be removed.
Loading history...
163
			$startMillis = round(microtime(true) * 1000);
164
165
			try {
166
				$imageProcessingContext = $this->findFaces($cfd, $image);
167
				if (($imageProcessingContext !== null) && ($imageProcessingContext->getSkipDetection() === false)) {
168
					$this->populateDescriptors($fld, $fr, $imageProcessingContext);
169
				}
170
171
				if ($imageProcessingContext === null) {
172
					continue;
173
				}
174
175
				$endMillis = round(microtime(true) * 1000);
176
				$duration = max($endMillis - $startMillis, 0);
177
				$this->imageMapper->imageProcessed($image, $imageProcessingContext->getFaces(), $duration);
178
			} catch (\Exception $e) {
179
				if ($e->getMessage() === "std::bad_alloc") {
180
					throw new \RuntimeException("Not enough memory to run face recognition! Please look FAQ at https://github.com/matiasdelellis/facerecognition/wiki/FAQ");
181
				}
182
				$this->logInfo('Faces found: 0. Image will be skipped because of the following error: ' . $e->getMessage());
183
				$this->logDebug($e);
184
				$this->imageMapper->imageProcessed($image, array(), 0, $e);
185
			} finally {
186
				$this->tempManager->clean();
187
			}
188
		}
189
190
		return true;
191
	}
192
193
	/**
194
	 * Given an image, it finds all faces on it.
195
	 * If image should be skipped, returns null.
196
	 * If there is any error, throws exception
197
	 *
198
	 * @param \CnnFaceDetection $cfd Face detection model
199
	 * @param Image $image Image to find faces on
200
	 * @return ImageProcessingContext|null Generated context that hold all information needed later for this image
201
	 */
202
	private function findFaces(\CnnFaceDetection $cfd, Image $image) {
203
		// todo: check if this hits I/O (database, disk...), consider having lazy caching to return user folder from user
204
		$userFolder = $this->context->rootFolder->getUserFolder($image->user);
205
		$userRoot = $userFolder->getParent();
206
		$file = $userRoot->getById($image->file);
207
208
		if (empty($file)) {
209
			// If we cannot find a file probably it was deleted out of our control and we must clean our tables.
210
			$this->config->setUserValue($this->userId, 'facerecognition', StaleImagesRemovalTask::STALE_IMAGES_REMOVAL_NEEDED_KEY, 'true');
0 ignored issues
show
Bug Best Practice introduced by
The property userId does not exist on OCA\FaceRecognition\Back...sks\ImageProcessingTask. Did you maybe forget to declare it?
Loading history...
211
			$this->logInfo('File with ID ' . $image->file . ' doesn\'t exist anymore, skipping it');
212
			return null;
213
		}
214
215
		$imagePath = $this->getLocalFile($file[0]);
216
217
		$this->logInfo('Processing image ' . $imagePath);
218
		$imageProcessingContext = $this->prepareImage($imagePath);
219
		if ($imageProcessingContext->getSkipDetection() === true) {
220
			$this->logInfo('Faces found: 0 (image will be skipped because it is too small)');
221
			return $imageProcessingContext;
222
		}
223
224
		// Detect faces from model
225
		$facesFound = $cfd->detect($imageProcessingContext->getTempPath());
226
227
		// Convert from dictionary of faces to our Face Db Entity
228
		$faces = array();
229
		foreach ($facesFound as $faceFound) {
230
			$face = Face::fromModel($image->getId(), $faceFound);
231
			$face->normalizeSize($imageProcessingContext->getRatio());
232
			$faces[] = $face;
233
		}
234
235
		$imageProcessingContext->setFaces($faces);
236
		$this->logInfo('Faces found: ' . count($faces));
237
238
		return $imageProcessingContext;
239
	}
240
241
	/**
242
	 * Given an image, it will rotate, scale and save image to temp location, ready to be consumed by pdlib.
243
	 *
244
	 * @param string $imagePath Path to image on disk
245
	 *
246
	 * @return ImageProcessingContext Generated context that hold all information needed later for this image.
247
	 */
248
	private function prepareImage(string $imagePath) {
249
		$image = new OCP_Image(null, $this->context->logger->getLogger(), $this->context->config);
250
		$image->loadFromFile($imagePath);
251
		$image->fixOrientation();
252
		if (!$image->valid()) {
253
			throw new \RuntimeException("Image is not valid, probably cannot be loaded");
254
		}
255
256
		// Ignore processing of images that are not large enough.
257
		$minImageSize = intval($this->config->getAppValue('facerecognition', 'min_image_size', '512'));
258
		if ((imagesx($image->resource()) < $minImageSize) || (imagesy($image->resource()) < $minImageSize)) {
259
			return new ImageProcessingContext($imagePath, "", -1, true);
260
		}
261
262
		$maxImageArea = $this->getMaxImageArea();
263
		$ratio = $this->resizeImage($image, $maxImageArea);
264
265
		$tempfile = $this->tempManager->getTemporaryFile(pathinfo($imagePath, PATHINFO_EXTENSION));
266
		$image->save($tempfile);
267
		return new ImageProcessingContext($imagePath, $tempfile, $ratio, false);
268
	}
269
270
	/**
271
	 * Resizes the image to reach max image area, but preserving ratio.
272
	 * Stolen and adopted from OC_Image->resize() (difference is that this returns ratio of resize.)
273
	 *
274
	 * @param OC_Image $image Image to resize
0 ignored issues
show
Bug introduced by
The type OCA\FaceRecognition\BackgroundJob\Tasks\OC_Image was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
275
	 * @param int $maxImageArea The maximum size of image we can handle (in pixels^2).
276
	 *
277
	 * @return float Ratio of resize. 1 if there was no resize
278
	 */
279 1
	public function resizeImage(OCP_Image $image, int $maxImageArea): float {
280 1
		if (!$image->valid()) {
281
			$message = "Image is not valid, probably cannot be loaded";
282
			$this->logInfo($message);
283
			throw new \RuntimeException($message);
284
		}
285
286 1
		$widthOrig = imagesx($image->resource());
287 1
		$heightOrig = imagesy($image->resource());
288 1
		if (($widthOrig <= 0) || ($heightOrig <= 0)) {
289
			$message = "Image is having non-positive width or height, cannot continue";
290
			$this->logInfo($message);
291
			throw new \RuntimeException($message);
292
		}
293
294 1
		$areaRatio = $maxImageArea / ($widthOrig * $heightOrig);
295 1
		$scaleFactor = sqrt($areaRatio);
296
297 1
		$newWidth = intval(round($widthOrig * $scaleFactor));
298 1
		$newHeight = intval(round($heightOrig * $scaleFactor));
299
300 1
		$success = $image->preciseResize($newWidth, $newHeight);
301 1
		if ($success === false) {
302
			throw new \RuntimeException("Error during image resize");
303
		}
304
305 1
		$this->logDebug(sprintf('Image scaled from %dx%d to %dx%d (since max image area is %d pixels^2)',
306 1
			$widthOrig, $heightOrig, $newWidth, $newHeight, $maxImageArea));
307
308 1
		return 1 / $scaleFactor;
309
	}
310
311
	/**
312
	 * Gets all face descriptors in a given image processing context. Populates "descriptor" in array of faces.
313
	 *
314
	 * @param \FaceLandmarkDetection $fld Landmark detection model
315
	 * @param \FaceRecognition $fr Face recognition model
316
	 * @param ImageProcessingContext Image processing context
317
	 */
318
	private function populateDescriptors(\FaceLandmarkDetection $fld, \FaceRecognition $fr, ImageProcessingContext $imageProcessingContext) {
319
		$faces = $imageProcessingContext->getFaces();
320
321
		foreach($faces as &$face) {
322
			// For each face, we want to detect landmarks and compute descriptors.
323
			// We use already resized image (from temp, used to detect faces) for this.
324
			// (better would be to work with original image, but that will require
325
			// another orientation fix and another save to the temp)
326
			// But, since our face coordinates are already changed to align to original image,
327
			// we need to fix them up to align them to temp image here.
328
			$normalizedFace = clone $face;
329
			$normalizedFace->normalizeSize(1.0 / $imageProcessingContext->getRatio());
330
331
			// We are getting face landmarks from already prepared (temp) image (resized and with orienation fixed).
332
			$landmarks = $fld->detect($imageProcessingContext->getTempPath(), array(
333
				"left" => $normalizedFace->left, "top" => $normalizedFace->top,
334
				"bottom" => $normalizedFace->bottom, "right" => $normalizedFace->right));
335
			$face->landmarks = $landmarks['parts'];
336
337
			$descriptor = $fr->computeDescriptor($imageProcessingContext->getTempPath(), $landmarks);
338
			$face->descriptor = $descriptor;
339
		}
340
	}
341
342
	/**
343
	 * Obtains max image area lazily (from cache, or calculates it and puts it to cache)
344
	 *
345
	 * @return int Max image area (in pixels^2)
346
	 */
347
	private function getMaxImageArea(): int {
348
		if (!is_null($this->maxImageAreaCached)) {
349
			return $this->maxImageAreaCached;
350
		}
351
352
		$this->maxImageAreaCached = $this->calculateMaxImageArea();
353
		return $this->maxImageAreaCached;
354
	}
355
356
	/**
357
	 * Calculates max image area. This is separate function, as there are several levels of user overrides.
358
	 *
359
	 * @return int Max image area (in pixels^2)
360
	 */
361
	private function calculateMaxImageArea(): int {
362
		// First check if we are provided value from command line
363
		//
364
		if (
365
			(array_key_exists('max_image_area', $this->context->propertyBag)) &&
366
			(!is_null($this->context->propertyBag['max_image_area']))
367
		) {
368
				return $this->context->propertyBag['max_image_area'];
369
		}
370
371
		// Check if admin persisted this setting in config and it is valid value
372
		//
373
		$maxImageArea = intval($this->config->getAppValue('facerecognition', 'max_image_area', 0));
374
		if ($maxImageArea > 0) {
375
			return $maxImageArea;
376
		}
377
378
		// Calculate it from memory
379
		//
380
		$allowedMemory = $this->context->propertyBag['memory'];
381
		// Based on amount on memory PHP have, we will determine maximum amount of image size that we need to scale to.
382
		// This reasoning and calculations are all based on analysis given here:
383
		// https://github.com/matiasdelellis/facerecognition/wiki/Performance-analysis-of-DLib%E2%80%99s-CNN-face-detection
384
		$maxImageArea = intval((0.75 * $allowedMemory) / 1024); // in pixels^2
385
		return $maxImageArea;
386
	}
387
388
	/**
389
	 * Get a path to either the local file or temporary file
390
	 *
391
	 * @param File $file
392
	 * @param int $maxSize maximum size for temporary files
393
	 * @return string
394
	 */
395
	private function getLocalFile(File $file, int $maxSize = null): string {
396
		$useTempFile = $file->isEncrypted() || !$file->getStorage()->isLocal();
397
		if ($useTempFile) {
398
			$absPath = $this->tempManager->getTemporaryFile();
399
400
			$content = $file->fopen('r');
401
			if ($maxSize) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $maxSize of type integer|null is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
402
				$content = stream_get_contents($content, $maxSize);
403
			}
404
			file_put_contents($absPath, $content);
405
406
			return $absPath;
407
		} else {
408
			return $file->getStorage()->getLocalFile($file->getInternalPath());
0 ignored issues
show
Bug Best Practice introduced by
The expression return $file->getStorage...ile->getInternalPath()) could return the type false which is incompatible with the type-hinted return string. Consider adding an additional type-check to rule them out.
Loading history...
409
		}
410
	}
411
412
}