Passed
Push — self-contained-model ( 9fdc5a...12ccc5 )
by Matias
04:03
created

ImageProcessingTask::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 12
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

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