Passed
Push — shared-storage-experiments ( 606554...2addb2 )
by Matias
08:21
created

ImageProcessingTask::getLocalFile()   A

Complexity

Conditions 4
Paths 6

Size

Total Lines 14
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 20

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 4
eloc 10
c 2
b 0
f 0
nc 6
nop 2
dl 0
loc 14
ccs 0
cts 10
cp 0
crap 20
rs 9.9332
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\IUser;
32
33
use OCA\FaceRecognition\BackgroundJob\FaceRecognitionBackgroundTask;
34
use OCA\FaceRecognition\BackgroundJob\FaceRecognitionContext;
35
use OCA\FaceRecognition\Db\Face;
36
use OCA\FaceRecognition\Db\Image;
37
use OCA\FaceRecognition\Db\ImageMapper;
38
use OCA\FaceRecognition\Helper\Requirements;
39
use OCA\FaceRecognition\Migration\AddDefaultFaceModel;
40
41
use OCA\FaceRecognition\Service\FileService;
42
43
/**
44
 * Plain old PHP object holding all information
45
 * that are needed to process all faces from one image
46
 */
47
class ImageProcessingContext {
48
	/** @var string Path to the image being processed */
49
	private $imagePath;
50
51
	/** @var string Path to temporary, resized image */
52
	private $tempPath;
53
54
	/** @var float Ratio of resized image, when scaling it */
55
	private $ratio;
56
57
	/** @var array<Face> All found faces in image */
58
	private $faces;
59
60
	/**
61
	 * @var bool True if detection should be skipped, but image should be marked as processed.
62
	 * If this is set, $tempPath and $ratio will be invalid and $faces should be empty array.
63
	 */
64
	private $skipDetection;
65
66
	public function __construct(string $imagePath, string $tempPath, float $ratio, bool $skipDetection) {
67
		$this->imagePath = $imagePath;
68
		$this->tempPath = $tempPath;
69
		$this->ratio = $ratio;
70
		$this->faces = array();
71
		$this->skipDetection = $skipDetection;
72
	}
73
74
	public function getImagePath(): string {
75
		return $this->imagePath;
76
	}
77
78
	public function getTempPath(): string {
79
		return $this->tempPath;
80
	}
81
82
	public function getRatio(): float {
83
		return $this->ratio;
84
	}
85
86
	public function getSkipDetection(): bool {
87
		return $this->skipDetection;
88
	}
89
90
	/**
91
	 * Gets all faces
92
	 *
93
	 * @return Face[] Array of faces
94
	 */
95
	public function getFaces(): array {
96
		return $this->faces;
97
	}
98
99
	/**
100
	 * @param array<Face> $faces Array of faces to set
101
	 */
102
	public function setFaces($faces) {
103
		$this->faces = $faces;
104
	}
105
}
106
107
/**
108
 * Taks that get all images that are still not processed and processes them.
109
 * Processing image means that each image is prepared, faces extracted form it,
110
 * and for each found face - face descriptor is extracted.
111
 */
112
class ImageProcessingTask extends FaceRecognitionBackgroundTask {
113
	/** @var IConfig Config */
114
	private $config;
115
116
	/** @var ImageMapper Image mapper*/
117
	protected $imageMapper;
118
119
	/** @var FileService */
120
	private $fileService;
121
122
	/** @var int|null Maximum image area (cached, so it is not recalculated for each image) */
123
	private $maxImageAreaCached;
124
125
	/**
126
	 * @param IConfig $config
127
	 * @param ImageMapper $imageMapper Image mapper
128
	 * @param FileService $fileService
129
	 */
130
	public function __construct(IConfig     $config,
131
	                            ImageMapper $imageMapper,
132
	                            FileService $fileService)
133
	{
134
		parent::__construct();
135
		$this->config             = $config;
136
		$this->imageMapper        = $imageMapper;
137
		$this->fileService        = $fileService;
138
		$this->maxImageAreaCached = null;
139
	}
140
141
	/**
142
	 * @inheritdoc
143
	 */
144
	public function description() {
145
		return "Process all images to extract faces";
146
	}
147
148
	/**
149
	 * @inheritdoc
150
	 */
151
	public function execute(FaceRecognitionContext $context) {
152
		$this->setContext($context);
153
154
		$model = intval($this->config->getAppValue('facerecognition', 'model', AddDefaultFaceModel::DEFAULT_FACE_MODEL_ID));
155
		$requirements = new Requirements($context->modelService, $model);
156
157
		$images = $context->propertyBag['images'];
158
159
		$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...
160
		$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...
161
		$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...
162
163
		$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');
164
165
		foreach($images as $image) {
166
			yield;
167
168
			$startMillis = round(microtime(true) * 1000);
169
170
			try {
171
				$imageProcessingContext = $this->findFaces($cfd, $image);
172
173
				if (($imageProcessingContext !== null) && ($imageProcessingContext->getSkipDetection() === false)) {
174
					$this->populateDescriptors($fld, $fr, $imageProcessingContext);
175
				}
176
177
				if ($imageProcessingContext === null) {
178
					continue;
179
				}
180
181
				$endMillis = round(microtime(true) * 1000);
182
				$duration = max($endMillis - $startMillis, 0);
183
				$this->imageMapper->imageProcessed($image, $imageProcessingContext->getFaces(), $duration);
184
			} catch (\Exception $e) {
185
				if ($e->getMessage() === "std::bad_alloc") {
186
					throw new \RuntimeException("Not enough memory to run face recognition! Please look FAQ at https://github.com/matiasdelellis/facerecognition/wiki/FAQ");
187
				}
188
				$this->logInfo('Faces found: 0. Image will be skipped because of the following error: ' . $e->getMessage());
189
				$this->logDebug($e);
190
				$this->imageMapper->imageProcessed($image, array(), 0, $e);
191
			} finally {
192
				$this->fileService->clean();
193
			}
194
		}
195
196
		return true;
197
	}
198
199
	/**
200
	 * Given an image, it finds all faces on it.
201
	 * If image should be skipped, returns null.
202
	 * If there is any error, throws exception
203
	 *
204
	 * @param \CnnFaceDetection $cfd Face detection model
205
	 * @param Image $image Image to find faces on
206
	 * @return ImageProcessingContext|null Generated context that hold all information needed later for this image
207
	 */
208
	private function findFaces(\CnnFaceDetection $cfd, Image $image) {
209
		// todo: check if this hits I/O (database, disk...), consider having lazy caching to return user folder from user
210
		$file = $this->fileService->getFileById($image->getFile(), $image->getUser());
211
212
		if (empty($file)) {
213
			// If we cannot find a file probably it was deleted out of our control and we must clean our tables.
214
			$this->config->setUserValue($image->user, 'facerecognition', StaleImagesRemovalTask::STALE_IMAGES_REMOVAL_NEEDED_KEY, 'true');
215
			$this->logInfo('File with ID ' . $image->file . ' doesn\'t exist anymore, skipping it');
216
			return null;
217
		}
218
219
		$imagePath = $this->fileService->getLocalFile($file);
220
221
		$this->logInfo('Processing image ' . $imagePath);
222
		$imageProcessingContext = $this->prepareImage($imagePath);
223
		if ($imageProcessingContext->getSkipDetection() === true) {
224
			$this->logInfo('Faces found: 0 (image will be skipped because it is too small)');
225
			return $imageProcessingContext;
226
		}
227
228
		// Detect faces from model
229
		$facesFound = $cfd->detect($imageProcessingContext->getTempPath());
230
231
		// Convert from dictionary of faces to our Face Db Entity
232
		$faces = array();
233
		foreach ($facesFound as $faceFound) {
234
			$face = Face::fromModel($image->getId(), $faceFound);
235
			$face->normalizeSize($imageProcessingContext->getRatio());
236
			$faces[] = $face;
237
		}
238
239
		$imageProcessingContext->setFaces($faces);
240
		$this->logInfo('Faces found: ' . count($faces));
241
242
		return $imageProcessingContext;
243
	}
244
245
	/**
246
	 * Given an image, it will rotate, scale and save image to temp location, ready to be consumed by pdlib.
247
	 *
248
	 * @param string $imagePath Path to image on disk
249
	 *
250
	 * @return ImageProcessingContext Generated context that hold all information needed later for this image.
251
	 */
252
	private function prepareImage(string $imagePath) {
253
		$image = new OCP_Image(null, $this->context->logger->getLogger(), $this->context->config);
254
		$image->loadFromFile($imagePath);
255
		$image->fixOrientation();
256
257
		if (!$image->valid()) {
258
			throw new \RuntimeException("Image is not valid, probably cannot be loaded");
259
		}
260
261
		// Ignore processing of images that are not large enough.
262
		$minImageSize = intval($this->config->getAppValue('facerecognition', 'min_image_size', '512'));
263
		if ((imagesx($image->resource()) < $minImageSize) || (imagesy($image->resource()) < $minImageSize)) {
264
			return new ImageProcessingContext($imagePath, "", -1, true);
265
		}
266
267
		$maxImageArea = $this->getMaxImageArea();
268
		$ratio = $this->resizeImage($image, $maxImageArea);
269
270
		$tempfile = $this->fileService->getTemporaryFile(pathinfo($imagePath, PATHINFO_EXTENSION));
271
		$image->save($tempfile);
272
273
		return new ImageProcessingContext($imagePath, $tempfile, $ratio, false);
274
	}
275
276
	/**
277
	 * Resizes the image to reach max image area, but preserving ratio.
278
	 * Stolen and adopted from OC_Image->resize() (difference is that this returns ratio of resize.)
279
	 *
280
	 * @param Image $image Image to resize
281
	 * @param int $maxImageArea The maximum size of image we can handle (in pixels^2).
282
	 *
283
	 * @return float Ratio of resize. 1 if there was no resize
284
	 */
285
	public function resizeImage(OCP_Image $image, int $maxImageArea): float {
286
		if (!$image->valid()) {
287
			$message = "Image is not valid, probably cannot be loaded";
288
			$this->logInfo($message);
289
			throw new \RuntimeException($message);
290
		}
291
292
		$widthOrig = imagesx($image->resource());
293
		$heightOrig = imagesy($image->resource());
294
		if (($widthOrig <= 0) || ($heightOrig <= 0)) {
295
			$message = "Image is having non-positive width or height, cannot continue";
296
			$this->logInfo($message);
297
			throw new \RuntimeException($message);
298
		}
299
300
		$areaRatio = $maxImageArea / ($widthOrig * $heightOrig);
301
		$scaleFactor = sqrt($areaRatio);
302
303
		$newWidth = intval(round($widthOrig * $scaleFactor));
304
		$newHeight = intval(round($heightOrig * $scaleFactor));
305
306
		$success = $image->preciseResize($newWidth, $newHeight);
307
		if ($success === false) {
308
			throw new \RuntimeException("Error during image resize");
309
		}
310
311
		$this->logDebug(sprintf('Image scaled from %dx%d to %dx%d (since max image area is %d pixels^2)',
312
			$widthOrig, $heightOrig, $newWidth, $newHeight, $maxImageArea));
313
314
		return 1 / $scaleFactor;
315
	}
316
317
	/**
318
	 * Gets all face descriptors in a given image processing context. Populates "descriptor" in array of faces.
319
	 *
320
	 * @param \FaceLandmarkDetection $fld Landmark detection model
321
	 * @param \FaceRecognition $fr Face recognition model
322
	 * @param ImageProcessingContext Image processing context
323
	 */
324
	private function populateDescriptors(\FaceLandmarkDetection $fld, \FaceRecognition $fr, ImageProcessingContext $imageProcessingContext) {
325
		$faces = $imageProcessingContext->getFaces();
326
327
		foreach($faces as &$face) {
328
			// For each face, we want to detect landmarks and compute descriptors.
329
			// We use already resized image (from temp, used to detect faces) for this.
330
			// (better would be to work with original image, but that will require
331
			// another orientation fix and another save to the temp)
332
			// But, since our face coordinates are already changed to align to original image,
333
			// we need to fix them up to align them to temp image here.
334
			$normalizedFace = clone $face;
335
			$normalizedFace->normalizeSize(1.0 / $imageProcessingContext->getRatio());
336
337
			// We are getting face landmarks from already prepared (temp) image (resized and with orienation fixed).
338
			$landmarks = $fld->detect($imageProcessingContext->getTempPath(), array(
339
				"left" => $normalizedFace->left, "top" => $normalizedFace->top,
340
				"bottom" => $normalizedFace->bottom, "right" => $normalizedFace->right));
341
			$face->landmarks = $landmarks['parts'];
342
343
			$descriptor = $fr->computeDescriptor($imageProcessingContext->getTempPath(), $landmarks);
344
			$face->descriptor = $descriptor;
345
		}
346
	}
347
348
	/**
349
	 * Obtains max image area lazily (from cache, or calculates it and puts it to cache)
350
	 *
351
	 * @return int Max image area (in pixels^2)
352
	 */
353
	private function getMaxImageArea(): int {
354
		if (!is_null($this->maxImageAreaCached)) {
355
			return $this->maxImageAreaCached;
356
		}
357
358
		$this->maxImageAreaCached = $this->calculateMaxImageArea();
359
		return $this->maxImageAreaCached;
360
	}
361
362
	/**
363
	 * Calculates max image area. This is separate function, as there are several levels of user overrides.
364
	 *
365
	 * @return int Max image area (in pixels^2)
366
	 */
367
	private function calculateMaxImageArea(): int {
368
		// First check if we are provided value from command line
369
		//
370
		if (
371
			(array_key_exists('max_image_area', $this->context->propertyBag)) &&
372
			(!is_null($this->context->propertyBag['max_image_area']))
373
		) {
374
				return $this->context->propertyBag['max_image_area'];
375
		}
376
377
		// Check if admin persisted this setting in config and it is valid value
378
		//
379
		$maxImageArea = intval($this->config->getAppValue('facerecognition', 'max_image_area', 0));
380
		if ($maxImageArea > 0) {
381
			return $maxImageArea;
382
		}
383
384
		// Calculate it from memory
385
		//
386
		$allowedMemory = $this->context->propertyBag['memory'];
387
		// Based on amount on memory PHP have, we will determine maximum amount of image size that we need to scale to.
388
		// This reasoning and calculations are all based on analysis given here:
389
		// https://github.com/matiasdelellis/facerecognition/wiki/Performance-analysis-of-DLib%E2%80%99s-CNN-face-detection
390
		$maxImageArea = intval((0.75 * $allowedMemory) / 1024); // in pixels^2
391
		return $maxImageArea;
392
	}
393
394
}