diff --git a/app/AnnotationLabel.php b/app/AnnotationLabel.php index 754dc2139..bce0ab96a 100644 --- a/app/AnnotationLabel.php +++ b/app/AnnotationLabel.php @@ -10,6 +10,7 @@ * @property int $annotation_id * @property int $user_id * @property int $label_id + * @property \Carbon\Carbon $created_at */ abstract class AnnotationLabel extends Model { diff --git a/app/Http/Controllers/Api/ProjectReportController.php b/app/Http/Controllers/Api/ProjectReportController.php new file mode 100644 index 000000000..88ad7e8f3 --- /dev/null +++ b/app/Http/Controllers/Api/ProjectReportController.php @@ -0,0 +1,54 @@ +source()->associate($request->project); + $report->type_id = $request->input('type_id'); + $report->user()->associate($request->user()); + $report->options = $request->getOptions(); + $report->save(); + + $queue = config('reports.generate_report_queue'); + GenerateReportJob::dispatch($report)->onQueue($queue); + + if ($this->isAutomatedRequest()) { + return $report; + } + } +} diff --git a/app/Http/Controllers/Api/ReportsController.php b/app/Http/Controllers/Api/ReportsController.php new file mode 100644 index 000000000..941045310 --- /dev/null +++ b/app/Http/Controllers/Api/ReportsController.php @@ -0,0 +1,80 @@ +authorize('access', $report); + $report->touch(); + + $disk = Storage::disk(config('reports.storage_disk')); + + if (!$disk->exists($report->getStorageFilename())) { + abort(Response::HTTP_NOT_FOUND); + } + + $path = $report->getStorageFilename(); + return $disk->download($path, $report->filename) + // Use a custom fallback with fread() because the default fpassthru() could + // lead to an out of memory error with large reports. + ->setCallback(function () use ($disk, $path) { + $stream = $disk->readStream($path); + while (!feof($stream)) { + echo fread($stream, 8192); + } + fclose($stream); + }); + } + + /** + * Delete a report. + * + * @api {delete} reports/:id Delete a report + * @apiGroup Reports + * @apiName DestroyReport + * @apiPermission reportOwner + * + * @apiParam {Number} id The report ID. + * + * @param int $id report id + * @return mixed + */ + public function destroy($id) + { + $report = Report::findOrFail($id); + $this->authorize('destroy', $report); + $report->delete(); + + if (!$this->isAutomatedRequest()) { + return $this->fuzzyRedirect() + ->with('message', 'Report deleted.') + ->with('messageType', 'success'); + } + } +} diff --git a/app/Http/Controllers/Api/VolumeReportController.php b/app/Http/Controllers/Api/VolumeReportController.php new file mode 100644 index 000000000..67b38f487 --- /dev/null +++ b/app/Http/Controllers/Api/VolumeReportController.php @@ -0,0 +1,56 @@ +source()->associate($request->volume); + $report->type_id = $request->input('type_id'); + $report->user()->associate($request->user()); + $report->options = $request->getOptions(); + $report->save(); + + $queue = config('reports.generate_report_queue'); + GenerateReportJob::dispatch($report)->onQueue($queue); + + if ($this->isAutomatedRequest()) { + return $report; + } + } +} diff --git a/app/Http/Controllers/Api/Volumes/ExportAreaController.php b/app/Http/Controllers/Api/Volumes/ExportAreaController.php new file mode 100644 index 000000000..55a822dde --- /dev/null +++ b/app/Http/Controllers/Api/Volumes/ExportAreaController.php @@ -0,0 +1,94 @@ +authorize('access', $volume); + + if (!$volume->isImageVolume()) { + abort(Response::HTTP_NOT_FOUND); + } + + return $volume->exportArea; + } + + /** + * Set the export area. + * + * @api {post} volumes/:id/export-area Set the export area + * @apiGroup Volumes + * @apiName StoreVolumesExportArea + * @apiPermission projectAdmin + * @apiDescription Only available for image volumes. + * + * @apiParam (Required arguments) {Number[]} coordinates Coordinates of the export area formatted as `[x1, y1, x2, y2]` array of integers + * + * @param Request $request + * @param int $id Volume ID + */ + public function store(Request $request, $id) + { + $volume = Volume::findOrFail($id); + $this->authorize('update', $volume); + if (!$volume->isImageVolume()) { + throw ValidationException::withMessages(['id' => 'The export area can only be set for image volumes.']); + } + $this->validate($request, ['coordinates' => 'required|array']); + + try { + $volume->exportArea = $request->input('coordinates'); + $volume->save(); + } catch (Exception $e) { + throw ValidationException::withMessages(['coordinates' => $e->getMessage()]); + } + } + + /** + * Remove the export area. + * + * @api {delete} volumes/:id/export-area Remove the export area + * @apiGroup Volumes + * @apiName DestroyVolumesExportArea + * @apiPermission projectAdmin + * @apiDescription Only available for image volumes. + * + * @param int $id Volume ID + */ + public function destroy($id) + { + $volume = Volume::findOrFail($id); + $this->authorize('update', $volume); + if (!$volume->isImageVolume()) { + abort(Response::HTTP_NOT_FOUND); + } + $volume->exportArea = null; + $volume->save(); + } +} diff --git a/app/Http/Controllers/Views/Projects/ProjectReportsController.php b/app/Http/Controllers/Views/Projects/ProjectReportsController.php new file mode 100644 index 000000000..f3b891190 --- /dev/null +++ b/app/Http/Controllers/Views/Projects/ProjectReportsController.php @@ -0,0 +1,76 @@ +videoVolumes()->exists(); + $hasImageVolume = $project->imageVolumes()->exists(); + if (!$hasVideoVolume && !$hasImageVolume) { + abort(Response::HTTP_NOT_FOUND); + } + + $this->authorize('access', $project); + + $userProject = $request->user()->projects()->where('id', $id)->first(); + $isMember = $userProject !== null; + $isPinned = $isMember && $userProject->getRelationValue('pivot')->pinned; + $canPin = $isMember && 3 > $request->user() + ->projects() + ->wherePivot('pinned', true) + ->count(); + + $types = ReportType::when($hasImageVolume, fn ($q) => $q->where('name', 'like', 'Image%')) + ->when($hasVideoVolume, fn ($q) => $q->orWhere('name', 'like', 'Video%')) + ->orderBy('name', 'asc') + ->get(); + + + $hasExportArea = $project->imageVolumes() + ->whereNotNull('attrs->export_area') + ->exists(); + + $labelTrees = $project->labelTrees()->with('labels', 'version')->get(); + + $hasIfdos = false; + + foreach ($project->volumes as $volume) { + if ($volume->metadata_parser === IfdoParser::class) { + $hasIfdos = true; + break; + } + } + + return view('projects.reports', [ + 'project' => $project, + 'isMember' => $isMember, + 'isPinned' => $isPinned, + 'canPin' => $canPin, + 'activeTab' => 'reports', + 'reportTypes' => $types, + 'hasExportArea' => $hasExportArea, + 'hasImageVolume' => $hasImageVolume, + 'hasVideoVolume' => $hasVideoVolume, + 'labelTrees' => $labelTrees, + 'hasIfdos' => $hasIfdos, + ]); + } +} diff --git a/app/Http/Controllers/Views/SearchController.php b/app/Http/Controllers/Views/SearchController.php index ab39d2099..f464a1c58 100644 --- a/app/Http/Controllers/Views/SearchController.php +++ b/app/Http/Controllers/Views/SearchController.php @@ -6,10 +6,12 @@ use Biigle\Image; use Biigle\LabelTree; use Biigle\Project; +use Biigle\Report; use Biigle\Services\Modules; use Biigle\User; use Biigle\Video; use Biigle\Volume; +use DB; use Illuminate\Contracts\Auth\Guard; use Illuminate\Http\Request; @@ -37,6 +39,7 @@ public function index(Guard $auth, Request $request, Modules $modules) $values = array_merge($values, $this->searchVolumes($user, $query, $type, $includeFederatedSearch)); $values = array_merge($values, $this->searchAnnotations($user, $query, $type)); $values = array_merge($values, $this->searchVideos($user, $query, $type)); + $values = array_merge($values, $this->searchReports($user, $query, $type)); $values = array_merge($values, $modules->callControllerMixins('search', $args)); if (array_key_exists('results', $values)) { @@ -347,4 +350,59 @@ protected function searchVideos(User $user, $query, $type) return $values; } + + /** + * Add report results to the search view. + * + * @param User $user + * @param string $query + * @param string $type + * + * @return array + */ + public function searchReports(User $user, $query, $type) + { + $queryBuilder = Report::where('reports.user_id', '=', $user->id); + + if ($query) { + $queryBuilder = $queryBuilder + ->where(function ($q) use ($query) { + $q + ->where(function ($q) use ($query) { + $q->where('reports.source_type', Volume::class) + ->whereExists(function ($q) use ($query) { + $q->select(DB::raw(1)) + ->from('volumes') + ->whereRaw('reports.source_id = volumes.id') + ->where('volumes.name', 'ilike', "%{$query}%"); + }); + }) + ->orWhere(function ($q) use ($query) { + $q->where('reports.source_type', Project::class) + ->whereExists(function ($q) use ($query) { + $q->select(DB::raw(1)) + ->from('projects') + ->whereRaw('reports.source_id = projects.id') + ->where('projects.name', 'ilike', "%{$query}%"); + }); + }) + // Kept for backwards compatibility of single video reports. + ->orWhere('reports.source_name', 'ilike', "%{$query}%"); + }); + } + + $values = []; + + if ($type === 'reports') { + $values['results'] = $queryBuilder->orderBy('reports.ready_at', 'desc') + ->with('source') + ->paginate(10); + + $values['reportResultCount'] = $values['results']->total(); + } else { + $values = ['reportResultCount' => $queryBuilder->count()]; + } + + return $values; + } } diff --git a/app/Http/Controllers/Views/Volumes/VolumeReportsController.php b/app/Http/Controllers/Views/Volumes/VolumeReportsController.php new file mode 100644 index 000000000..8c920f21d --- /dev/null +++ b/app/Http/Controllers/Views/Volumes/VolumeReportsController.php @@ -0,0 +1,69 @@ +authorize('access', $volume); + $sessions = $volume->annotationSessions()->orderBy('starts_at', 'desc')->get(); + $types = ReportType::when($volume->isImageVolume(), fn ($q) => $q->where('name', 'like', 'Image%')) + ->when($volume->isVideoVolume(), fn ($q) => $q->where('name', 'like', 'Video%')) + ->orderBy('name', 'asc') + ->get(); + + $user = $request->user(); + + if ($user->can('sudo')) { + // Global admins have no restrictions. + $projectIds = $volume->projects()->pluck('id'); + } else { + // Array of all project IDs that the user and the volume have in common. + $projectIds = Project::inCommon($user, $volume->id)->pluck('id'); + } + + // All label trees that are used by all projects of which the user is also member. + $labelTrees = LabelTree::select('id', 'name', 'version_id') + ->with('labels', 'version') + ->whereIn('id', function ($query) use ($projectIds) { + $query->select('label_tree_id') + ->from('label_tree_project') + ->whereIn('project_id', $projectIds); + }) + ->get(); + + $hasIfdo = $volume->metadata_parser === IfdoParser::class; + if ($volume->isImageVolume()) { + $reportPrefix = 'Image'; + } else { + $reportPrefix = 'Video'; + } + + return view('volumes.reports', [ + 'projects' => $volume->projects, + 'volume' => $volume, + 'annotationSessions' => $sessions, + 'reportTypes' => $types, + 'labelTrees' => $labelTrees, + 'reportPrefix' => $reportPrefix, + 'hasIfdo' => $hasIfdo, + ]); + } +} diff --git a/app/Http/Requests/StoreProjectReport.php b/app/Http/Requests/StoreProjectReport.php new file mode 100644 index 000000000..c1a403310 --- /dev/null +++ b/app/Http/Requests/StoreProjectReport.php @@ -0,0 +1,170 @@ +project = Project::findOrFail($this->route('id')); + + return $this->user()->can('access', $this->project); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules() + { + return array_merge(parent::rules(), [ + 'type_id' => 'required|integer|exists:report_types,id', + ]); + } + + /** + * Configure the validator instance. + * + * @param \Illuminate\Validation\Validator $validator + * @return void + */ + public function withValidator($validator) + { + parent::withValidator($validator); + + $validator->after(function ($validator) { + $this->validateReportType($validator); + $this->validateGeoInfo($validator); + $this->validateImageMetadata($validator); + $this->validateIfdos($validator); + }); + } + + /** + * Validate the report types. + * + * @param \Illuminate\Validation\Validator $validator + */ + protected function validateReportType($validator) + { + $imageReports = [ + ReportType::imageAnnotationsAreaId(), + ReportType::imageAnnotationsBasicId(), + ReportType::imageAnnotationsCsvId(), + ReportType::imageAnnotationsExtendedId(), + ReportType::imageAnnotationsCocoId(), + ReportType::imageAnnotationsFullId(), + ReportType::imageAnnotationsAbundanceId(), + ReportType::imageAnnotationsImageLocationId(), + ReportType::imageAnnotationsAnnotationLocationId(), + ReportType::imageLabelsBasicId(), + ReportType::imageLabelsCsvId(), + ReportType::imageLabelsImageLocationId(), + ReportType::imageIfdoId(), + ]; + + $videoReports = [ + ReportType::videoAnnotationsCsvId(), + ReportType::videoLabelsCsvId(), + ReportType::videoIfdoId(), + ]; + + if ($this->isType($imageReports) && !$this->project->imageVolumes()->exists()) { + $validator->errors()->add('type_id', 'The project does not contain any image volumes.'); + } elseif ($this->isType($videoReports) && !$this->project->videoVolumes()->exists()) { + $validator->errors()->add('type_id', 'The project does not contain any video volumes.'); + } + } + + /** + * Validate the geo info for certain types. + * + * @param \Illuminate\Validation\Validator $validator + */ + protected function validateGeoInfo($validator) + { + $needsGeoInfo = [ + ReportType::imageAnnotationsAnnotationLocationId(), + ReportType::imageAnnotationsImageLocationId(), + ReportType::imageLabelsImageLocationId(), + ]; + + if ($this->isType($needsGeoInfo)) { + $hasGeoInfo = $this->project->imageVolumes() + ->select('id') + ->get() + ->reduce(fn ($carry, $volume) => $carry && $volume->hasGeoInfo(), true); + + if (!$hasGeoInfo) { + $validator->errors()->add('id', 'No volume has images with geo coordinates.'); + } + } + } + + /** + * Validate image metadata for certain types. + * + * @param \Illuminate\Validation\Validator $validator + */ + protected function validateImageMetadata($validator) + { + if ($this->isType(ReportType::imageAnnotationsAnnotationLocationId())) { + $query = Image::join('project_volume', 'project_volume.volume_id', '=', 'images.volume_id') + ->where('project_volume.project_id', $this->project->id); + + $hasImagesWithMetadata = (clone $query) + ->whereNotNull('attrs->metadata->yaw') + ->whereNotNull('attrs->metadata->distance_to_ground') + ->exists(); + + if (!$hasImagesWithMetadata) { + $validator->errors()->add('id', 'No volume has images with yaw and/or distance to ground metadata.'); + } + + $hasImagesWithDimensions = (clone $query) + ->whereNotNull('attrs->width') + ->whereNotNull('attrs->height') + ->exists(); + + if (!$hasImagesWithDimensions) { + $validator->errors()->add('id', 'No volume has images with dimension information. Try again later if the images are new and still being processed.'); + } + } + } + + /** + * Check if some volumes have iFDO files (if an iFDO report is requested). + * + * @param \Illuminate\Validation\Validator $validator + */ + protected function validateIfdos($validator) + { + if ($this->isType([ReportType::imageIfdoId(), ReportType::videoIfdoId()])) { + foreach ($this->project->volumes as $volume) { + if ($volume->metadata_parser === IfdoParser::class) { + return; + } + } + + $validator->errors()->add('id', 'The project has no volumes with attached iFDO files.'); + } + } +} diff --git a/app/Http/Requests/StoreReport.php b/app/Http/Requests/StoreReport.php new file mode 100644 index 000000000..575e69048 --- /dev/null +++ b/app/Http/Requests/StoreReport.php @@ -0,0 +1,148 @@ + 'nullable|boolean', + 'separate_users' => 'nullable|boolean', + 'export_area' => 'nullable|boolean', + 'newest_label' => 'nullable|boolean', + 'only_labels' => 'nullable|array', + 'only_labels.*' => 'integer|exists:labels,id', + 'aggregate_child_labels' => "nullable|boolean", + 'disable_notifications' => "nullable|boolean", + 'strip_ifdo' => "nullable|boolean", + ]; + } + + /** + * Configure the validator instance. + * + * @param \Illuminate\Validation\Validator $validator + * @return void + */ + public function withValidator($validator) + { + $validator->after(function ($validator) { + $exportArea = boolval($this->input('export_area', false)); + + if ($exportArea && !$this->isAllowedForExportArea()) { + $validator->errors()->add('export_area', 'The export area is only supported for image annotation reports.'); + } + + $aggregate = boolval($this->input('aggregate_child_labels', false)); + if ($aggregate && !$this->isAllowedForAggregateChildLabels()) { + $validator->errors()->add('aggregate_child_labels', 'Child labels can only be aggregated for basic, extended and abundance image annotation reports.'); + } + + if ($this->input('separate_label_trees', false) && $this->input('separate_users', false)) { + $validator->errors()->add('separate_label_trees', 'Only one of separate_label_trees or separate_users may be specified.'); + } + }); + } + + /** + * Get the options for the new report. + * + * @return array + */ + public function getOptions() + { + $options = [ + 'separateLabelTrees' => boolval($this->input('separate_label_trees', false)), + 'separateUsers' => boolval($this->input('separate_users', false)), + 'newestLabel' => boolval($this->input('newest_label', false)), + 'onlyLabels' => $this->input('only_labels', []), + ]; + + if ($this->isAllowedForExportArea()) { + $options['exportArea'] = boolval($this->input('export_area', false)); + } + + if ($this->isAllowedForAggregateChildLabels()) { + $options['aggregateChildLabels'] = boolval($this->input('aggregate_child_labels', false)); + } + + if ($this->has('disable_notifications')) { + $options['disableNotifications'] = boolval($this->input('disable_notifications', false)); + } + + if ($this->isAllowedForStripIfdo()) { + $options['stripIfdo'] = boolval($this->input('strip_ifdo', false)); + } + + return $options; + } + + /** + * Check if the requested reporty type ID is in the supplied array. + * + * @param array|int $allowed + * + * @return boolean + */ + protected function isType($allowed) + { + $id = intval($this->input('type_id')); + + if (is_array($allowed)) { + return in_array($id, $allowed); + } + + return $id === $allowed; + } + + /** + * Check if export_area may be configured for the requested report type. + * + * @return boolean + */ + protected function isAllowedForExportArea() + { + return $this->isType([ + ReportType::imageAnnotationsAreaId(), + ReportType::imageAnnotationsBasicId(), + ReportType::imageAnnotationsCsvId(), + ReportType::imageAnnotationsExtendedId(), + ReportType::imageAnnotationsFullId(), + ReportType::imageAnnotationsAbundanceId(), + ]); + } + + /** + * Check if aggregate_child_labels may be configured for the requested report type. + * + * @return boolean + */ + protected function isAllowedForAggregateChildLabels() + { + return $this->isType([ + ReportType::imageAnnotationsAbundanceId(), + ]); + } + + /** + * Check if strip_ifdo may be configured for the requested report type. + * + * @return boolean + */ + protected function isAllowedForStripIfdo() + { + return $this->isType([ + ReportType::imageIfdoId(), + ReportType::videoIfdoId(), + ]); + } +} diff --git a/app/Http/Requests/StoreVolumeReport.php b/app/Http/Requests/StoreVolumeReport.php new file mode 100644 index 000000000..f40401cdc --- /dev/null +++ b/app/Http/Requests/StoreVolumeReport.php @@ -0,0 +1,126 @@ +volume = Volume::findOrFail($this->route('id')); + + return $this->user()->can('access', $this->volume); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules() + { + if ($this->volume->isImageVolume()) { + $types = [ + ReportType::imageAnnotationsAreaId(), + ReportType::imageAnnotationsBasicId(), + ReportType::imageAnnotationsCsvId(), + ReportType::imageAnnotationsExtendedId(), + ReportType::imageAnnotationsCocoId(), + ReportType::imageAnnotationsFullId(), + ReportType::imageAnnotationsAbundanceId(), + ReportType::imageAnnotationsImageLocationId(), + ReportType::imageAnnotationsAnnotationLocationId(), + ReportType::imageLabelsBasicId(), + ReportType::imageLabelsCsvId(), + ReportType::imageLabelsImageLocationId(), + ReportType::imageIfdoId(), + ]; + } else { + $types = [ + ReportType::videoAnnotationsCsvId(), + ReportType::videoLabelsCsvId(), + ReportType::videoIfdoId(), + ]; + } + + return array_merge(parent::rules(), [ + 'type_id' => ['required', Rule::in($types)], + 'annotation_session_id' => "nullable|integer|exists:annotation_sessions,id,volume_id,{$this->volume->id}", + ]); + } + + /** + * Configure the validator instance. + * + * @param \Illuminate\Validation\Validator $validator + * @return void + */ + public function withValidator($validator) + { + parent::withValidator($validator); + + $validator->after(function ($validator) { + $needsGeoInfo = [ + ReportType::imageAnnotationsAnnotationLocationId(), + ReportType::imageAnnotationsImageLocationId(), + ReportType::imageLabelsImageLocationId(), + ]; + + if ($this->isType($needsGeoInfo) && !$this->volume->hasGeoInfo()) { + $validator->errors()->add('id', 'The volume images have no geo coordinates.'); + } + + if ($this->isType(ReportType::imageAnnotationsAnnotationLocationId())) { + $hasImagesWithMetadata = $this->volume->images() + ->whereNotNull('attrs->metadata->yaw') + ->whereNotNull('attrs->metadata->distance_to_ground') + ->exists(); + + if (!$hasImagesWithMetadata) { + $validator->errors()->add('id', 'The volume images have no yaw and/or distance to ground metadata.'); + } + + $hasImagesWithDimensions = $this->volume->images() + ->whereNotNull('attrs->width') + ->whereNotNull('attrs->height') + ->exists(); + + if (!$hasImagesWithDimensions) { + $validator->errors()->add('id', 'The volume images have no dimension information. Try again later if the images are new and still being processed.'); + } + } + + if ($this->isType([ReportType::imageIfdoId(), ReportType::videoIfdoId()]) && $this->volume->metadata_parser !== IfdoParser::class) { + $validator->errors()->add('id', 'The volume has no attached iFDO file.'); + } + }); + } + + /** + * Get the options for the new report. + * + * @return array + */ + public function getOptions() + { + return array_merge(parent::getOptions(), [ + 'annotationSession' => $this->input('annotation_session_id'), + ]); + } +} diff --git a/app/Http/Requests/UpdateUserSettings.php b/app/Http/Requests/UpdateUserSettings.php index b5607d603..876ea14db 100644 --- a/app/Http/Requests/UpdateUserSettings.php +++ b/app/Http/Requests/UpdateUserSettings.php @@ -41,10 +41,16 @@ public function authorize() */ public function rules() { - return array_merge(static::$additionalRules, [ + $rules = [ 'super_user_mode' => 'filled|bool', 'include_federated_search' => 'filled|bool', - ]); + ]; + + if (config('reports.notifications.allow_user_settings')) { + $rules['report_notifications'] = 'filled|in:email,web'; + } + + return array_merge(static::$additionalRules, $rules); } /** diff --git a/app/Jobs/GenerateReportJob.php b/app/Jobs/GenerateReportJob.php new file mode 100644 index 000000000..8b37688fb --- /dev/null +++ b/app/Jobs/GenerateReportJob.php @@ -0,0 +1,48 @@ +report = $report; + } + + /** + * Execute the job. + */ + public function handle() + { + $this->report->generate(); + $this->report->ready_at = new Carbon; + $this->report->save(); + + $disableNotifications = $this->report->options['disableNotifications'] ?? false; + + if (!$disableNotifications) { + $this->report->user->notify(new ReportReady($this->report)); + } + } +} diff --git a/app/Notifications/ReportReady.php b/app/Notifications/ReportReady.php new file mode 100644 index 000000000..bed20df57 --- /dev/null +++ b/app/Notifications/ReportReady.php @@ -0,0 +1,89 @@ +report = $report; + } + + /** + * Get the notification's delivery channels. + * + * @param mixed $notifiable + * @return array + */ + public function via($notifiable) + { + $settings = config('reports.notifications.default_settings'); + + if (config('reports.notifications.allow_user_settings') === true) { + $settings = $notifiable->getSettings('report_notifications', $settings); + } + + if ($settings === 'web') { + return ['database']; + } + + return ['mail']; + } + + /** + * Get the mail representation of the notification. + * + * @param mixed $notifiable + * @return \Illuminate\Notifications\Messages\MailMessage + */ + public function toMail($notifiable) + { + $message = (new MailMessage) + ->subject('Your BIIGLE report is ready') + ->line("Your {$this->report->name} for {$this->report->subject} is ready for download!"); + + if (config('app.url')) { + $message = $message->action('Download report', $this->report->getUrl()); + } + + return $message; + } + + /** + * Get the array representation of the notification. + * + * @param mixed $notifiable + * @return array + */ + public function toArray($notifiable) + { + $array = [ + 'title' => 'Your BIIGLE report is ready', + 'message' => "Your {$this->report->name} for {$this->report->subject} is ready for download!", + ]; + + if (config('app.url')) { + $array['action'] = 'Download report'; + $array['actionLink'] = $this->report->getUrl(); + } + + return $array; + } +} diff --git a/app/Observers/ProjectObserver.php b/app/Observers/ProjectObserver.php index 06d362bad..42a4a44f9 100644 --- a/app/Observers/ProjectObserver.php +++ b/app/Observers/ProjectObserver.php @@ -3,6 +3,8 @@ namespace Biigle\Observers; use Biigle\LabelTree; +use Biigle\Project; +use Biigle\Report; use Biigle\Role; use Exception; @@ -40,4 +42,18 @@ public function created($project) ->all(); $project->labelTrees()->attach($ids); } + + /** + * Update the source name of reports when the source is deleted. + * + * @param \Biigle\Project $project + */ + public function deleted($project) + { + Report::where('source_id', '=', $project->id) + ->where('source_type', '=', Project::class) + ->update([ + 'source_name' => $project->name, + ]); + } } diff --git a/app/Observers/ReportObserver.php b/app/Observers/ReportObserver.php new file mode 100644 index 000000000..b2e93cac8 --- /dev/null +++ b/app/Observers/ReportObserver.php @@ -0,0 +1,32 @@ +source->name) { + $report->source_name = $report->source->name; + } elseif (empty($report->source_name)) { + $report->source_name = ''; + } + } + + /** + * Remove report file of a report that should be deleted. + * + * @param Report $report + */ + public function deleted($report) + { + $report->deleteFile(); + } +} diff --git a/app/Observers/UserObserver.php b/app/Observers/UserObserver.php new file mode 100644 index 000000000..47d32a5f3 --- /dev/null +++ b/app/Observers/UserObserver.php @@ -0,0 +1,22 @@ +id) + ->eachById(fn ($report) => $report->deleteFile()); + } +} diff --git a/app/Observers/VolumeObserver.php b/app/Observers/VolumeObserver.php index 94e8deb70..696f61f88 100644 --- a/app/Observers/VolumeObserver.php +++ b/app/Observers/VolumeObserver.php @@ -5,6 +5,7 @@ use Biigle\Events\ImagesDeleted; use Biigle\Events\TiledImagesDeleted; use Biigle\Events\VideosDeleted; +use Biigle\Report; use Biigle\Volume; use Exception; @@ -52,4 +53,18 @@ public function deleting(Volume $volume) return true; } + + /** + * Update the source name of reports when the source is deleted. + * + * @param \Biigle\Volume $volume + */ + public function deleted($volume) + { + Report::where('source_id', '=', $volume->id) + ->where('source_type', '=', Volume::class) + ->update([ + 'source_name' => $volume->name, + ]); + } } diff --git a/app/Policies/ReportPolicy.php b/app/Policies/ReportPolicy.php new file mode 100644 index 000000000..0c8486f76 --- /dev/null +++ b/app/Policies/ReportPolicy.php @@ -0,0 +1,50 @@ +can('sudo')) { + return true; + } + } + + /** + * Determine if the given report can be accessed by the user. + * + * @param User $user + * @param Report $report + * @return bool + */ + public function access(User $user, Report $report) + { + return $report->user_id === $user->id; + } + + /** + * Determine if the given report can be destroyed by the user. + * + * @param User $user + * @param Report $report + * @return bool + */ + public function destroy(User $user, Report $report) + { + return $report->user_id === $user->id; + } +} diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 8a97813da..6a20727c7 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -29,10 +29,12 @@ class EventServiceProvider extends ServiceProvider */ public function boot(): void { - \Biigle\Project::observe(new \Biigle\Observers\ProjectObserver); - \Biigle\Volume::observe(new \Biigle\Observers\VolumeObserver); \Biigle\Image::observe(new \Biigle\Observers\ImageObserver); + \Biigle\Project::observe(new \Biigle\Observers\ProjectObserver); + \Biigle\Report::observe(new \Biigle\Observers\ReportObserver); + \Biigle\User::observe(new \Biigle\Observers\UserObserver); \Biigle\Video::observe(new \Biigle\Observers\VideoObserver); + \Biigle\Volume::observe(new \Biigle\Observers\VolumeObserver); } /** diff --git a/app/Report.php b/app/Report.php new file mode 100644 index 000000000..8ecb5168b --- /dev/null +++ b/app/Report.php @@ -0,0 +1,183 @@ + + */ + protected $casts = [ + 'user_id' => 'int', + 'type_id' => 'int', + 'source_id' => 'int', + 'options' => 'array', + 'ready_at' => 'datetime', + ]; + + /** + * The user that requested the report. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function user() + { + return $this->belongsTo(\Biigle\User::class); + } + + /** + * Type of the report. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function type() + { + return $this->belongsTo(ReportType::class); + } + + /** + * Source of the report (\Biigle\Volume or \Biigle\Project). + * + * @return \Illuminate\Database\Eloquent\Relations\MorphTo + */ + public function source() + { + /** + * @var \Illuminate\Database\Eloquent\Relations\MorphTo + */ + return $this->morphTo(); + } + + /** + * Get the source name dynamically if the source still exists. + * + * @return string + */ + public function getSourceNameAttribute() + { + if (is_null($this->source) || empty($this->source->name)) { + return $this->attributes['source_name']; + } + + return $this->source->name; + } + + /** + * Set the report generator for this model. + * + * @param ReportGenerator $generator + */ + public function setReportGenerator(ReportGenerator $generator) + { + $this->reportGenerator = $generator; + } + + /** + * Get the report generator for this report;. + * + * @return ReportGenerator + */ + public function getReportGenerator() + { + if (!$this->reportGenerator) { + $this->reportGenerator = ReportGenerator::get($this->source_type, $this->type, $this->options); + } + + return $this->reportGenerator; + } + + /** + * Generate the report file for this report. + */ + public function generate() + { + $path = $this->getReportGenerator()->generate($this->source); + try { + Storage::disk(config('reports.storage_disk')) + ->putFileAs('', new SplFileInfo($path), $this->getStorageFilename()); + } finally { + File::delete($path); + } + } + + /** + * Get the subject for this report. + * + * @return string + */ + public function getSubjectAttribute() + { + $reflect = new ReflectionClass($this->source_type); + + return strtolower($reflect->getShortName()).' '.$this->source_name; + } + + /** + * Get the name for this report. + * + * @return string + */ + public function getNameAttribute() + { + return $this->getReportGenerator()->getName(); + } + + /** + * Get the filename for this report. + * + * @return string + */ + public function getFilenameAttribute() + { + return $this->source_id.'_'.$this->getReportGenerator()->getFullFilename(); + } + + /** + * Get the URL to download the report. + * + * @return string + */ + public function getUrl() + { + return route('show-reports', $this->id); + } + + /** + * Delete the file that belongs to this report. + */ + public function deleteFile() + { + Storage::disk(config('reports.storage_disk'))->delete($this->getStorageFilename()); + } + + /** + * Get the filename of the report in storage (not the filename for download). + * + * @return string + */ + public function getStorageFilename() + { + $extension = $this->getReportGenerator()->extension; + + return $this->id.'.'.$extension; + } +} diff --git a/app/ReportType.php b/app/ReportType.php new file mode 100644 index 000000000..85d166d92 --- /dev/null +++ b/app/ReportType.php @@ -0,0 +1,77 @@ + 'ImageAnnotations\Abundance', + 'imageAnnotationsAnnotationLocation' => 'ImageAnnotations\AnnotationLocation', + 'imageAnnotationsArea' => 'ImageAnnotations\Area', + 'imageAnnotationsBasic' => 'ImageAnnotations\Basic', + 'imageAnnotationsCsv' => 'ImageAnnotations\Csv', + 'imageAnnotationsExtended' => 'ImageAnnotations\Extended', + 'imageAnnotationsCoco' => 'ImageAnnotations\Coco', + 'imageAnnotationsFull' => 'ImageAnnotations\Full', + 'imageAnnotationsImageLocation' => 'ImageAnnotations\ImageLocation', + 'imageIfdo' => 'ImageIfdo', + 'imageLabelsBasic' => 'ImageLabels\Basic', + 'imageLabelsCsv' => 'ImageLabels\Csv', + 'imageLabelsImageLocation' => 'ImageLabels\ImageLocation', + 'videoAnnotationsCsv' => 'VideoAnnotations\Csv', + 'videoIfdo' => 'VideoIfdo', + 'videoLabelsCsv' => 'VideoLabels\Csv', + ]; + + /** + * Don't maintain timestamps for this model. + * + * @var bool + */ + public $timestamps = false; +} diff --git a/app/Services/Reports/CsvFile.php b/app/Services/Reports/CsvFile.php new file mode 100644 index 000000000..a8912a8e6 --- /dev/null +++ b/app/Services/Reports/CsvFile.php @@ -0,0 +1,55 @@ +delimiter = $delimiter; + $this->enclosure = $enclosure; + $this->escape_char = $escape_char; + } + + /** + * Append a new row to the CSV file. + * + * @param array $items Row items + */ + public function putCsv($items) + { + if (is_array($items)) { + fputcsv($this->handle, $items, $this->delimiter, $this->enclosure, $this->escape_char); + } + } +} diff --git a/app/Services/Reports/File.php b/app/Services/Reports/File.php new file mode 100644 index 000000000..c02ca398a --- /dev/null +++ b/app/Services/Reports/File.php @@ -0,0 +1,100 @@ +path = tempnam(config('reports.tmp_storage'), 'biigle-report-'); + } else { + $this->path = $path; + } + + $this->handle = fopen($this->path, 'w'); + } + + public function __destruct() + { + $this->close(); + } + + /** + * Delete the file. + */ + public function delete() + { + $this->close(); + FS::delete($this->path); + } + + /** + * Close the file. + */ + public function close() + { + try { + if (is_resource($this->handle)) { + fclose($this->handle); + } + } catch (\Exception $e) { + // + } + } + + /** + * Returns the path to the file. + * + * @return string + */ + public function getPath() + { + return $this->path; + } + + /** + * Add content to the file. + * + * @param string $content File content + * @return int + */ + public function put($content) + { + if (is_string($content)) { + return fwrite($this->handle, $content); + } + } +} diff --git a/app/Services/Reports/MakesZipArchives.php b/app/Services/Reports/MakesZipArchives.php new file mode 100644 index 000000000..99ed1b642 --- /dev/null +++ b/app/Services/Reports/MakesZipArchives.php @@ -0,0 +1,52 @@ +availableReport->path`. + * + * @param array $files Array of files, with source path as keys and target filenames (in the ZIP) as values. + * @param string $path Path to the file to store the generated ZIP to + * + * @throws Exception If the ZIP file could not be created. + */ + protected function makeZip($files, $path) + { + $zip = App::make(ZipArchive::class); + $open = $zip->open($path, ZipArchive::OVERWRITE); + + if ($open !== true) { + throw new Exception("Could not open ZIP file '{$path}'."); + } + + try { + foreach ($files as $source => $target) { + $zip->addFile($source, $target); + } + } finally { + $zip->close(); + } + } + + /** + * Sanitizes a filename. + * + * @param string $name Filename to sanitize + * @param string $extension File extension to use (since dots are sanitized, too) + * + * @return string + */ + protected function sanitizeFilename($name, $extension) + { + return Str::slug($name).'.'.$extension; + } +} diff --git a/app/Services/Reports/Projects/ImageAnnotations/AbundanceReportGenerator.php b/app/Services/Reports/Projects/ImageAnnotations/AbundanceReportGenerator.php new file mode 100644 index 000000000..d919a2f2e --- /dev/null +++ b/app/Services/Reports/Projects/ImageAnnotations/AbundanceReportGenerator.php @@ -0,0 +1,29 @@ +source->imageVolumes() + ->whereExists(function ($query) { + $query->select(DB::raw(1)) + ->from('images') + ->whereColumn('images.volume_id', 'volumes.id') + ->whereNotNull('images.lat') + ->whereNotNull('images.lng') + ->whereNotNull('images.attrs->width') + ->whereNotNull('images.attrs->height') + ->whereNotNull('images.attrs->metadata->yaw') + ->whereNotNull('images.attrs->metadata->distance_to_ground'); + }) + ->get(); + } +} diff --git a/app/Services/Reports/Projects/ImageAnnotations/AnnotationReportGenerator.php b/app/Services/Reports/Projects/ImageAnnotations/AnnotationReportGenerator.php new file mode 100644 index 000000000..b57754492 --- /dev/null +++ b/app/Services/Reports/Projects/ImageAnnotations/AnnotationReportGenerator.php @@ -0,0 +1,70 @@ +isRestrictedToExportArea()) { + $restrictions[] = 'export area'; + } + + if ($this->isRestrictedToNewestLabel()) { + $restrictions[] = 'newest label for each image annotation'; + } + + if (!empty($restrictions)) { + $suffix = implode(' and ', $restrictions); + + return "{$this->name} (restricted to {$suffix})"; + } + + return $this->name; + } + + /** + * Get the filename. + * + * @return string + */ + public function getFilename() + { + $restrictions = []; + + if ($this->isRestrictedToExportArea()) { + $restrictions[] = 'export_area'; + } + + if ($this->isRestrictedToNewestLabel()) { + $restrictions[] = 'newest_label'; + } + + if (!empty($restrictions)) { + $suffix = implode('_', $restrictions); + + return "{$this->filename}_restricted_to_{$suffix}"; + } + + return $this->filename; + } + + /** + * Should this report be restricted to the export area? + * + * @return bool + */ + protected function isRestrictedToExportArea() + { + return $this->options->get('exportArea', false); + } +} diff --git a/app/Services/Reports/Projects/ImageAnnotations/AreaReportGenerator.php b/app/Services/Reports/Projects/ImageAnnotations/AreaReportGenerator.php new file mode 100644 index 000000000..166492bca --- /dev/null +++ b/app/Services/Reports/Projects/ImageAnnotations/AreaReportGenerator.php @@ -0,0 +1,29 @@ +source->imageVolumes() + ->whereExists(function ($query) { + $query->select(DB::raw(1)) + ->from('images') + ->whereColumn('images.volume_id', 'volumes.id') + ->whereNotNull('images.lat') + ->whereNotNull('images.lng'); + }) + ->get(); + } +} diff --git a/app/Services/Reports/Projects/ImageIfdoReportGenerator.php b/app/Services/Reports/Projects/ImageIfdoReportGenerator.php new file mode 100644 index 000000000..005c223ff --- /dev/null +++ b/app/Services/Reports/Projects/ImageIfdoReportGenerator.php @@ -0,0 +1,48 @@ +filter(fn ($v) => $v->metadata_parser === IfdoParser::class); + + if ($volumes->isEmpty()) { + throw new Exception('No volume with iFDO found for this project.'); + } + + return $volumes; + } +} diff --git a/app/Services/Reports/Projects/ImageLabels/BasicReportGenerator.php b/app/Services/Reports/Projects/ImageLabels/BasicReportGenerator.php new file mode 100644 index 000000000..7d36c396c --- /dev/null +++ b/app/Services/Reports/Projects/ImageLabels/BasicReportGenerator.php @@ -0,0 +1,30 @@ +source->imageVolumes() + ->whereExists(function ($query) { + $query->select(DB::raw(1)) + ->from('images') + ->whereColumn('images.volume_id', 'volumes.id') + ->whereNotNull('images.lat') + ->whereNotNull('images.lng'); + }) + ->get(); + } +} diff --git a/app/Services/Reports/Projects/ProjectImageReportGenerator.php b/app/Services/Reports/Projects/ProjectImageReportGenerator.php new file mode 100644 index 000000000..ad5013e93 --- /dev/null +++ b/app/Services/Reports/Projects/ProjectImageReportGenerator.php @@ -0,0 +1,14 @@ +source->imageVolumes; + } +} diff --git a/app/Services/Reports/Projects/ProjectReportGenerator.php b/app/Services/Reports/Projects/ProjectReportGenerator.php new file mode 100644 index 000000000..81a212f37 --- /dev/null +++ b/app/Services/Reports/Projects/ProjectReportGenerator.php @@ -0,0 +1,73 @@ +getProjectSources() as $source) { + $report = $this->getReportGenerator(); + $p = $report->generate($source); + // The individual source reports should be deleted again after + // the ZIP of this report was created. + $this->tmpFiles[] = $p; + $filesForZip[$p] = $source->id.'_'.$report->getFullFilename(); + } + + $this->makeZip($filesForZip, $path); + } + + /** + * Get sources for the sub-reports that should be generated for this project. + * + * @return mixed + */ + abstract public function getProjectSources(); + + /** + * Get the report generator. + * + * @return \Biigle\Services\Reports\ReportGenerator + */ + protected function getReportGenerator() + { + return new $this->reportClass($this->options); + } + + /** + * Determines if this report should take only the newest label for each annotation. + * + * @return bool + */ + protected function isRestrictedToNewestLabel() + { + return $this->options->get('newestLabel', false); + } +} diff --git a/app/Services/Reports/Projects/ProjectVideoReportGenerator.php b/app/Services/Reports/Projects/ProjectVideoReportGenerator.php new file mode 100644 index 000000000..bd08eb147 --- /dev/null +++ b/app/Services/Reports/Projects/ProjectVideoReportGenerator.php @@ -0,0 +1,14 @@ +source->videoVolumes; + } +} diff --git a/app/Services/Reports/Projects/VideoAnnotations/CsvReportGenerator.php b/app/Services/Reports/Projects/VideoAnnotations/CsvReportGenerator.php new file mode 100644 index 000000000..60ef37f84 --- /dev/null +++ b/app/Services/Reports/Projects/VideoAnnotations/CsvReportGenerator.php @@ -0,0 +1,84 @@ +isRestrictedToNewestLabel()) { + $restrictions[] = 'newest label for each video annotation'; + } + + if (!empty($restrictions)) { + $suffix = implode(' and ', $restrictions); + + return "{$this->name} (restricted to {$suffix})"; + } + + return $this->name; + } + + /** + * Get the filename. + * + * @return string + */ + public function getFilename() + { + $restrictions = []; + + if ($this->isRestrictedToNewestLabel()) { + $restrictions[] = 'newest_label'; + } + + if (!empty($restrictions)) { + $suffix = implode('_', $restrictions); + + return "{$this->filename}_restricted_to_{$suffix}"; + } + + return $this->filename; + } + + /** + * Determines if this report should take only the newest label for each annotation. + * + * @return bool + */ + protected function isRestrictedToNewestLabel() + { + return $this->options->get('newestLabel', false); + } +} diff --git a/app/Services/Reports/Projects/VideoIfdoReportGenerator.php b/app/Services/Reports/Projects/VideoIfdoReportGenerator.php new file mode 100644 index 000000000..e0e2fdcef --- /dev/null +++ b/app/Services/Reports/Projects/VideoIfdoReportGenerator.php @@ -0,0 +1,48 @@ +filter(fn ($v) => $v->metadata_parser === IfdoParser::class); + + if ($volumes->isEmpty()) { + throw new Exception('No volume with iFDO found for this project.'); + } + + return $volumes; + } +} diff --git a/app/Services/Reports/Projects/VideoLabels/CsvReportGenerator.php b/app/Services/Reports/Projects/VideoLabels/CsvReportGenerator.php new file mode 100644 index 000000000..148733703 --- /dev/null +++ b/app/Services/Reports/Projects/VideoLabels/CsvReportGenerator.php @@ -0,0 +1,30 @@ +id === ReportType::videoAnnotationsCsvId()) { + $sourceClass = Volume::class; + } + + if (class_exists($sourceClass)) { + $reflect = new ReflectionClass($sourceClass); + $sourceClass = Str::plural($reflect->getShortName()); + $fullClass = __NAMESPACE__.'\\'.$sourceClass.'\\'.$type->name.'ReportGenerator'; + + if (class_exists($fullClass)) { + return new $fullClass($options); + } + + throw new Exception("Report generator {$fullClass} does not exist."); + } + + throw new Exception("Source class {$sourceClass} does not exist."); + } + + /** + * Create a report generator instance. + * + * @param array $options Options for the report + */ + public function __construct($options = []) + { + $this->options = collect($options); + $this->tmpFiles = []; + } + + /** + * Generate the report. + * + * @param mixed $source Source to generate the report for (e.g. a volume) + * + * @return string Path to the generated report file. + */ + public function generate($source) + { + $this->setSource($source); + + if (is_null($this->source)) { + throw new Exception('Cannot generate report because the source does not exist.'); + } + + $path = FileHelper::makeTmp()->getPath(); + + try { + $this->generateReport($path); + } catch (Exception $e) { + if (File::exists($path)) { + File::delete($path); + } + throw $e; + } finally { + array_walk($this->tmpFiles, function ($file) { + if (is_string($file)) { + File::delete($file); + } else { + $file->delete(); + } + }); + } + + return $path; + } + + /** + * Internal function to generate the report. + * + * (public for better testability) + * + * @param string $path Path to write the report file to. + */ + public function generateReport($path) + { + // + } + + /** + * Set the source. + * + * @param mixed $source + */ + public function setSource($source) + { + $this->source = $source; + } + + /** + * Get the report name. + * + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * Get the report filename. + * + * @return string + */ + public function getFilename() + { + return $this->filename; + } + + /** + * Get the filename with extension. + * + * @return string + */ + public function getFullFilename() + { + return "{$this->getFilename()}.{$this->extension}"; + } + + /** + * Constructs a label name from the names of all parent labels and the label itself. + * + * Example: `Animalia > Annelida > Polychaeta > Buskiella sp` + * + * @param int $id Label ID + * @return string + */ + public function expandLabelName($id) + { + if (is_null($this->labels)) { + $this->labels = collect(); + } + + if (!$this->labels->has($id)) { + // Fetch the whole label tree for each label that wasn't already loaded. + $labels = $this->getSiblingLabels($id); + $this->labels = $this->labels->merge($labels)->keyBy('id'); + } + + $label = $this->labels[$id]; + $name = $label->name; + + while (!is_null($label->parent_id)) { + // We can assume that all parents belong to the same label tree so they + // should already be cached here. + $label = $this->labels[$label->parent_id]; + $name = "{$label->name} > {$name}"; + } + + return $name; + } + + /** + * Get all labels that belong to the label tree of the given label. + * + * @param int $id Label ID + * @return \Illuminate\Support\Collection + */ + protected function getSiblingLabels($id) + { + return Label::select('id', 'name', 'parent_id') + ->whereIn('label_tree_id', function ($query) use ($id) { + $query->select('label_tree_id') + ->from('labels') + ->where('id', $id); + }) + ->get(); + } + + /** + * Should this report separate the output files for different label trees? + * + * @return bool + */ + protected function shouldSeparateLabelTrees() + { + return $this->options->get('separateLabelTrees', false); + } + + /** + * Should this report separate the output files for different user? + * + * @return bool + */ + protected function shouldSeparateUsers() + { + return $this->options->get('separateUsers', false); + } + + /** + * Returns the array of label ids to which this report should be restricted. + * + * @return array + */ + protected function getOnlyLabels() + { + return $this->options->get('onlyLabels', []); + } + + /** + * Determines if this report is restricted to a subset of labels. + * + * @return bool + */ + protected function isRestrictedToLabels() + { + return !empty($this->getOnlyLabels()); + } +} diff --git a/app/Services/Reports/Volumes/IfdoReportGenerator.php b/app/Services/Reports/Volumes/IfdoReportGenerator.php new file mode 100644 index 000000000..37e927cc4 --- /dev/null +++ b/app/Services/Reports/Volumes/IfdoReportGenerator.php @@ -0,0 +1,308 @@ +wormsLabelSource = LabelSource::where('name', 'worms')->first(); + $this->users = $this->getUsers()->keyBy('id'); + $this->labels = $this->getLabels()->keyBy('id'); + + $this->processFiles(); + + if (!$this->hasIfdo($this->source)) { + throw new Exception("No iFDO file found for the volume."); + } + + $ifdo = $this->getIfdo($this->source)->getJsonData(); + + $creators = array_map(function ($user) { + return [ + 'id' => $user->uuid, + 'name' => "{$user->firstname} {$user->lastname}", + 'uuid' => $user->uuid, + ]; + }, $this->imageAnnotationCreators); + + if ($this->options->get('stripIfdo', false)) { + unset($ifdo['image-set-header']['image-annotation-creators'], $ifdo['image-set-header']['image-annotation-labels']); + if (array_key_exists('image-set-items', $ifdo)) { + foreach ($ifdo['image-set-items'] as &$item) { + if ($this->isArrayItem($item)) { + foreach ($item as &$entry) { + unset($entry['image-annotations']); + } + // Always unset by-reference variables of loops. + unset($entry); + } else { + unset($item['image-annotations']); + } + } + // Always unset by-reference variables of loops. + unset($item); + } + } + + if (!empty($creators)) { + $ifdo['image-set-header']['image-annotation-creators'] = array_merge( + $ifdo['image-set-header']['image-annotation-creators'] ?? [], + $creators + ); + } + + $labels = array_map(function ($label) { + $id = $label->id; + if ($this->shouldConvertWormsId($label)) { + $id = $this->getWormsUrn($label); + } + + return [ + 'id' => "$id", + 'name' => $label->name, + 'uuid' => $label->uuid, + 'color' => $label->color, + ]; + }, $this->imageAnnotationLabels); + + if (!empty($labels)) { + $ifdo['image-set-header']['image-annotation-labels'] = array_merge( + $ifdo['image-set-header']['image-annotation-labels'] ?? [], + $labels + ); + } + + if (!empty($this->imageSetItems)) { + $keys = array_keys($this->imageSetItems); + + $ifdo['image-set-items'] = $ifdo['image-set-items'] ?? []; + + foreach ($keys as $key) { + $this->mergeImageSetItem($key, $ifdo['image-set-items']); + } + } + + $this->writeIfdo($ifdo, $path); + } + + /** + * Get all users who annotated in the volume. + * + * @return \Illuminate\Support\Collection + */ + abstract protected function getUsers(); + + /** + * Get all labels that were used in the volume. + * + * @return \Illuminate\Support\Collection + */ + abstract protected function getLabels(); + + /** + * Determine if the volume has a iFDO metadata file. + */ + protected function hasIfdo(Volume $source): bool + { + return $source->metadata_parser === IfdoParser::class; + } + + /** + * Create the image-set-item entries for the images or videos. + */ + abstract public function processFiles(); + + /** + * Get the iFDO object of the volume if it has any. + */ + protected function getIfdo(Volume $source): ?Ifdo + { + if (!$source->metadata_file_path) { + return null; + } + + $content = Storage::disk($source->getMetadataFileDisk()) + ->get($source->metadata_file_path); + + if (!$content) { + return null; + } + + return Ifdo::fromString($content); + } + + /** + * Write the report JSON file. + * + * @param array $content + * @param string $path + */ + protected function writeIfdo(array $content, string $path) + { + File::put($path, json_encode($content)); + } + + /** + * Determine if the label ID should be converted to a WoRMS URN. + * + * @param Label $label + * + * @return bool + */ + protected function shouldConvertWormsId(Label $label) + { + return $this->wormsLabelSource && $label->label_source_id === $this->wormsLabelSource->id; + } + + /** + * Get the WoRMS URN for a label (if it has one). + * + * @param Label $label + * + * @return string + */ + protected function getWormsUrn($label) + { + return "urn:lsid:marinespecies.org:taxname:{$label->source_id}"; + } + + /** + * Determine if an iFDO item is a single object or an array of objects. + * Both are allowed for images. Only the latter should be the case for videos. + * + * @param array $item + * + * @return boolean + */ + protected function isArrayItem($item) + { + return !empty($item) && array_reduce(array_keys($item), fn ($carry, $key) => $carry && is_numeric($key), true); + } + + /** + * Merge an image-set-items item of the original iFDO with the item generated by this + * report. + * + * @param string $key Filename key of the item (guaranteed to be in + * $this->imageSetItems). + * @param array $ifdoItems image-set-items of the original iFDO + */ + protected function mergeImageSetItem($key, &$ifdoItems) + { + if (array_key_exists($key, $ifdoItems)) { + if ($this->isArrayItem($ifdoItems[$key])) { + if ($this->isArrayItem($this->imageSetItems[$key])) { + $ifdoItems[$key][0] = array_merge_recursive( + $ifdoItems[$key][0], + $this->imageSetItems[$key][0] + ); + } else { + $ifdoItems[$key][0] = array_merge_recursive( + $ifdoItems[$key][0], + $this->imageSetItems[$key] + ); + } + } else { + $ifdoItems[$key] = array_merge_recursive( + $ifdoItems[$key], + $this->imageSetItems[$key] + ); + } + } else { + $ifdoItems[$key] = $this->imageSetItems[$key]; + } + } + + /** + * Get an iFDO geometry name string for an annotation. + * + * @param Annotation $annotation + * + * @return string + */ + protected function getGeometryName(Annotation $annotation) + { + if ($annotation->shape_id === Shape::pointId()) { + return 'single-pixel'; + } elseif ($annotation->shape_id === Shape::lineId()) { + return 'polyline'; + } elseif ($annotation->shape_id === Shape::circleId()) { + return 'circle'; + } elseif ($annotation->shape_id === Shape::rectangleId()) { + return 'rectangle'; + } elseif ($annotation->shape_id === Shape::ellipseId()) { + return 'ellipse'; + } elseif ($annotation->shape_id === Shape::wholeFrameId()) { + return 'whole-image'; + } else { + return 'polygon'; + } + } +} diff --git a/app/Services/Reports/Volumes/ImageAnnotations/AbundanceReportGenerator.php b/app/Services/Reports/Volumes/ImageAnnotations/AbundanceReportGenerator.php new file mode 100644 index 000000000..f451b54fa --- /dev/null +++ b/app/Services/Reports/Volumes/ImageAnnotations/AbundanceReportGenerator.php @@ -0,0 +1,223 @@ +query()->get(); + + if ($this->shouldSeparateLabelTrees() && $rows->isNotEmpty()) { + $rows = $rows->groupBy('label_tree_id'); + $trees = LabelTree::whereIn('id', $rows->keys())->pluck('name', 'id'); + + foreach ($trees as $id => $name) { + $rowGroup = $rows->get($id); + $labels = Label::whereIn('id', $rowGroup->pluck('label_id')->unique())->get(); + $this->tmpFiles[] = $this->createCsv($rowGroup, $name, $labels); + } + } elseif ($this->shouldSeparateUsers() && $rows->isNotEmpty()) { + $labels = Label::whereIn('id', $rows->pluck('label_id')->unique())->get(); + $rows = $rows->groupBy('user_id'); + $users = User::whereIn('id', $rows->keys()) + ->selectRaw("id, concat(firstname, ' ', lastname) as name") + ->pluck('name', 'id'); + + foreach ($users as $id => $name) { + $rowGroup = $rows->get($id); + $this->tmpFiles[] = $this->createCsv($rowGroup, $name, $labels); + } + } else { + $labels = Label::whereIn('id', $rows->pluck('label_id')->unique())->get(); + $this->tmpFiles[] = $this->createCsv($rows, $this->source->name, $labels); + } + + $this->executeScript('csvs_to_xlsx', $path); + } + + /** + * Assemble a new DB query for the volume of this report. + * + * @return \Illuminate\Database\Query\Builder + */ + protected function query() + { + $query = $this->initQuery() + ->orderBy('images.filename') + ->select(DB::raw('images.filename, image_annotation_labels.label_id, count(image_annotation_labels.label_id) as count')) + ->groupBy('image_annotation_labels.label_id', 'images.id'); + + if ($this->shouldSeparateLabelTrees()) { + $query->addSelect('labels.label_tree_id') + ->groupBy('image_annotation_labels.label_id', 'images.id', 'labels.label_tree_id'); + } elseif ($this->shouldSeparateUsers()) { + $query->addSelect('image_annotation_labels.user_id') + ->groupBy('image_annotation_labels.label_id', 'images.id', 'image_annotation_labels.user_id'); + } + + return $query; + } + + /** + * Create a CSV file for a single sheet of the spreadsheet of this report. + * + * @param \Illuminate\Support\Collection $rows The rows for the CSV + * @param string $title The title to put in the first row of the CSV + * @param \Illuminate\Support\Collection $labels + * + * @return CsvFile + */ + protected function createCsv($rows, $title, $labels) + { + $rows = $rows->groupBy('filename'); + + if ($this->shouldAggregateChildLabels()) { + [$rows, $labels] = $this->aggregateChildLabels($rows, $labels); + } + + $labels = $labels->sortBy('id'); + + $csv = CsvFile::makeTmp(); + $csv->put($title); + + $columns = ['image_filename']; + foreach ($labels as $label) { + $columns[] = $label->name; + } + $csv->putCsv($columns); + + foreach ($rows as $filename => $annotations) { + $row = [$filename]; + $annotations = $annotations->keyBy('label_id'); + foreach ($labels as $label) { + if ($annotations->has($label->id)) { + $row[] = $annotations[$label->id]->count; + } else { + $row[] = 0; + } + } + + $csv->putCsv($row); + } + + $csv->close(); + + return $csv; + } + + /** + * Aggregate the number of child labels to the number of the highest parent label + * and remove the child labels from the list. + * + * @param \Illuminate\Support\Collection $rows + * @param \Illuminate\Support\Collection $labels + * + * @return array + */ + protected function aggregateChildLabels($rows, $labels) + { + // Add all possible labels because the parent to which the child labels should + // be aggregated may not have "own" annotations. Unused labels are filtered + // later. + $addLabels = Label::whereIn('label_tree_id', $labels->pluck('label_tree_id')->unique()) + ->whereNotIn('id', $labels->pluck('id')) + ->when($this->isRestrictedToLabels(), function ($query) { + $query->whereIn('id', $this->getOnlyLabels()); + }) + ->get(); + + $labels = $labels->concat($addLabels); + + $parentIdMap = $labels->pluck('parent_id', 'id') + ->when($this->isRestrictedToLabels(), function ($labels) { + $onlyLabels = $this->getOnlyLabels(); + + return $labels->map(function ($value) use ($onlyLabels) { + // Act as if excluded parent labels do not exist. + return in_array($value, $onlyLabels) ? $value : null; + }); + }) + ->reject(fn ($value) => is_null($value)); + + // Determine the highest parent label for all child labels. + do { + $hoistedParentLabel = false; + foreach ($parentIdMap as $id => $parentId) { + if ($parentIdMap->has($parentId)) { + $parentIdMap[$id] = $parentIdMap[$parentId]; + $hoistedParentLabel = true; + } + } + } while ($hoistedParentLabel); + + $presentLabels = collect([]); + + foreach ($rows as $filename => $annotations) { + // Aggregate the number of annotations of child labels to the number of their + // parent. + $annotations = $annotations->keyBy('label_id'); + foreach ($annotations as $labelId => $annotation) { + $parentId = $parentIdMap->get($labelId); + if ($parentId) { + $presentLabels->push($parentId); + if ($annotations->has($parentId)) { + $annotations[$parentId]->count += $annotation->count; + } else { + // Add a new entry for a parent label which has no "own" + // annotations. + $annotations[$parentId] = (object) [ + 'count' => $annotation->count, + 'label_id' => $parentId, + 'filename' => $filename, + ]; + } + } else { + $presentLabels->push($labelId); + } + } + + // Remove rows of child labels so they are not counted twice. + $rows[$filename] = $annotations->values() + ->reject(fn ($annotation) => $parentIdMap->has($annotation->label_id)); + } + + // Remove all labels that did not occur (as parent) in the rows. + $presentLabels = $presentLabels->unique()->flip(); + $labels = $labels->filter(fn ($label) => $presentLabels->has($label->id)); + + return [$rows, $labels]; + } +} diff --git a/app/Services/Reports/Volumes/ImageAnnotations/AnnotationLocationReportGenerator.php b/app/Services/Reports/Volumes/ImageAnnotations/AnnotationLocationReportGenerator.php new file mode 100644 index 000000000..861e23f6a --- /dev/null +++ b/app/Services/Reports/Volumes/ImageAnnotations/AnnotationLocationReportGenerator.php @@ -0,0 +1,273 @@ +query()->get(); + + if ($this->shouldSeparateLabelTrees() && $items->isNotEmpty()) { + $items = $items->groupBy('label_tree_id'); + $trees = LabelTree::whereIn('id', $items->keys())->pluck('name', 'id'); + + foreach ($trees as $id => $name) { + $tmpItems = $items->get($id); + $file = $this->createNdJSON($tmpItems); + $this->tmpFiles[] = $file; + $toZip[$file->getPath()] = $this->sanitizeFilename("{$id}-{$name}", 'ndjson'); + } + } elseif ($this->shouldSeparateUsers() && $items->isNotEmpty()) { + $items = $items->groupBy('user_id'); + $users = User::whereIn('id', $items->keys()) + ->selectRaw("id, concat(firstname, ' ', lastname) as name") + ->pluck('name', 'id'); + + foreach ($users as $id => $name) { + $tmpItems = $items->get($id); + $file = $this->createNdJSON($tmpItems); + $this->tmpFiles[] = $file; + $toZip[$file->getPath()] = $this->sanitizeFilename("{$id}-{$name}", 'ndjson'); + } + } else { + $file = $this->createNdJSON($items); + $this->tmpFiles[] = $file; + $toZip[$file->getPath()] = $this->sanitizeFilename("{$this->source->id}-{$this->source->name}", 'ndjson'); + } + + $this->makeZip($toZip, $path); + } + + /** + * Assemble a new DB query for the volume of this report. + * + * @return \Illuminate\Database\Query\Builder + */ + public function query() + { + return $this + ->initQuery([ + 'image_annotation_labels.id as annotation_label_id', + 'image_annotation_labels.label_id', + 'image_annotations.image_id', + 'image_annotations.shape_id', + 'images.filename', + 'images.attrs->metadata->yaw as yaw', + 'images.attrs->metadata->distance_to_ground as distance_to_ground', + 'images.attrs->width as width', + 'images.attrs->height as height', + 'images.lat', + 'images.lng', + 'image_annotations.points', + 'image_annotation_labels.id as annotation_label_id', + 'labels.name as label_name', + ]) + ->whereNotNull('images.lat') + ->whereNotNull('images.lng') + ->whereNotNull('images.attrs->width') + ->whereNotNull('images.attrs->height') + ->whereNotNull('images.attrs->metadata->distance_to_ground') + ->whereNotNull('images.attrs->metadata->yaw'); + } + + /** + * Create the newline delimited GeoJSON file. + * + * @param \Illuminate\Support\Collection $items + * + * @return File + */ + protected function createNdJSON($items) + { + $file = File::makeTmp(); + + $items->each(function ($item) use ($file) { + $properties = [ + '_id' => $item->annotation_label_id, + '_image_id' => $item->image_id, + '_image_filename' => $item->filename, + '_image_latitude' => floatval($item->lat), + '_image_longitude' => floatval($item->lng), + '_label_name' => $item->label_name, + '_label_id' => $item->label_id, + ]; + + $geometry = $this->estimateAnnotationPosition($item); + + $feature = new Feature($geometry, $properties); + $file->put(json_encode($feature)."\n"); + }); + $file->close(); + + return $file; + } + + /** + * Estimate the position of an annotation in world coordinates. + * + * @param object $item + * + * @return \GeoJson\Geometry\Geometry + */ + protected function estimateAnnotationPosition($item) + { + // First calculate the offset of the annotation from the image center in pixels. + + $imageCenter = [$item->width / 2, $item->height / 2]; + $flatPoints = json_decode($item->points); + + // GeoJSON does no support circles so we treat them as points. + if ($item->shape_id === Shape::circleId()) { + unset($flatPoints[2]); + } + + $points = []; + $limit = count($flatPoints) - 1; + for ($i = 0; $i < $limit; $i += 2) { + $points[] = [$flatPoints[$i], $flatPoints[$i + 1]]; + } + + // Annotation position relative to the image center. Also, change the y axis from + // going top down to going bottom up. This is required for the correct rotation + // and shift calculation below. + $pointsOffsetInPx = array_map(function ($point) use ($item, $imageCenter) { + return [ + $point[0] - $imageCenter[0], + ($item->height - $point[1]) - $imageCenter[1], + ]; + }, $points); + + // Now rotate the annotation position around the image center according to the + // yaw. This assumes that 0° yaw is north and 90° yaw is east. + // See: https://stackoverflow.com/a/34374437/1796523 + + // Yaw specifies the clockwise rotation in degrees but the formula below expects + // the counterclockwise angle in radians. + $angle = deg2rad(360 - floatval($item->yaw)); + + // We don't need to shift the rotated coordinates back by adding $imageCenter, + // as we assume that latitude and longitude describe the image center point and + // not [0, 0], so the center is the "origin" here. + $rotatedOffsetInPx = array_map(function ($point) use ($angle) { + return [ + $point[0] * cos($angle) - $point[1] * sin($angle), + $point[0] * sin($angle) + $point[1] * cos($angle), + ]; + }, $pointsOffsetInPx); + + // Then convert the pixel offset to meters. + + /* We assume that the camera points straight to the ground and the opening angle + * is 90°. Therefore, the width of the image in meters is twice the distance of + * the camera to the ground. + * + * camera + * o - Angle a is 90°. + * /a\ | Distance d to the ground. + * / \ |d w = 2 * d + * / \ | + * -----|-------|--- + * ground w + */ + $imageWidthInM = 2 * floatval($item->distance_to_ground); + + // The ratio of meter per pixel. + $scalingFactor = $imageWidthInM / $item->width; + + $rotatedOffsetInM = array_map(function ($point) use ($scalingFactor) { + return [ + $point[0] * $scalingFactor, + $point[1] * $scalingFactor, + ]; + }, $rotatedOffsetInPx); + + // Finally, shift the image coordinates by the offset in meters to estimate the + // annotation position. + // See: https://gis.stackexchange.com/a/2980/50820 + + $rotatedOffsetInRadians = array_map(function ($point) use ($item) { + return [ + $point[0] / (self::EARTH_RADIUS * cos(M_PI * $item->lat / 180)), + $point[1] / self::EARTH_RADIUS, + ]; + }, $rotatedOffsetInM); + + $coordinates = array_map(function ($point) use ($item) { + // Shifted image center position. + return [ + $item->lng + $point[0] * 180 / M_PI, + $item->lat + $point[1] * 180 / M_PI, + ]; + }, $rotatedOffsetInRadians); + + switch ($item->shape_id) { + case Shape::pointId(): + case Shape::circleId(): + return new Point($coordinates[0]); + case Shape::lineId(): + return new LineString($coordinates); + } + + // The last polygon coordinate must equal the first. + $last = count($coordinates) - 1; + if ($coordinates[0][0] !== $coordinates[$last][0] || $coordinates[0][1] !== $coordinates[$last][1]) { + $coordinates[] = $coordinates[0]; + } + + // Catch some edge cases where a polygon does not have at least three unique + // coordinates (triangle). + while (count($coordinates) < 4) { + $coordinates[] = $coordinates[0]; + } + + return new Polygon([$coordinates]); + } +} diff --git a/app/Services/Reports/Volumes/ImageAnnotations/AnnotationReportGenerator.php b/app/Services/Reports/Volumes/ImageAnnotations/AnnotationReportGenerator.php new file mode 100644 index 000000000..0f0a6d67f --- /dev/null +++ b/app/Services/Reports/Volumes/ImageAnnotations/AnnotationReportGenerator.php @@ -0,0 +1,137 @@ +isRestrictedToExportArea()) { + $restrictions[] = 'export area'; + } + + if ($this->isRestrictedToAnnotationSession()) { + $name = $this->getAnnotationSessionName(); + $restrictions[] = "annotation session {$name}"; + } + + if ($this->isRestrictedToNewestLabel()) { + $restrictions[] = 'newest label of each annotation'; + } + + if (!empty($restrictions)) { + $suffix = implode(', ', $restrictions); + + return "{$this->name} (restricted to {$suffix})"; + } + + return $this->name; + } + + /** + * Get the filename. + * + * @return string + */ + public function getFilename() + { + $restrictions = []; + + if ($this->isRestrictedToExportArea()) { + $restrictions[] = 'export_area'; + } + + if ($this->isRestrictedToAnnotationSession()) { + $name = Str::slug($this->getAnnotationSessionName()); + $restrictions[] = "annotation_session_{$name}"; + } + + if ($this->isRestrictedToNewestLabel()) { + $restrictions[] = 'newest_label'; + } + + if (!empty($restrictions)) { + $suffix = implode('_', $restrictions); + + return "{$this->filename}_restricted_to_{$suffix}"; + } + + return $this->filename; + } + + /** + * Callback to be used in a `when` query statement that restricts the resulting annotation labels to the annotation session of this report. + * + * @param \Illuminate\Database\Query\Builder $query + * @return \Illuminate\Database\Query\Builder + */ + public function restrictToAnnotationSessionQuery($query) + { + $session = $this->getAnnotationSession(); + + return $query->where(function ($query) use ($session) { + // take only annotations that belong to the time span... + $query->where('image_annotations.created_at', '>=', $session->starts_at) + ->where('image_annotations.created_at', '<', $session->ends_at) + // ...and to the users of the session + ->whereIn('image_annotation_labels.user_id', function ($query) use ($session) { + $query->select('user_id') + ->from('annotation_session_user') + ->where('annotation_session_id', $session->id); + }); + }); + } + + /** + * Assembles the part of the DB query that is the same for all annotation reports. + * + * @param mixed $columns The columns to select + * @return \Illuminate\Database\Query\Builder + */ + public function initQuery($columns = []) + { + $query = DB::table('image_annotation_labels') + ->join('image_annotations', 'image_annotation_labels.annotation_id', '=', 'image_annotations.id') + ->join('images', 'image_annotations.image_id', '=', 'images.id') + ->join('labels', 'image_annotation_labels.label_id', '=', 'labels.id') + ->where('images.volume_id', $this->source->id) + ->when($this->isRestrictedToExportArea(), [$this, 'restrictToExportAreaQuery']) + ->when($this->isRestrictedToAnnotationSession(), [$this, 'restrictToAnnotationSessionQuery']) + ->when($this->isRestrictedToNewestLabel(), fn ($query) => $this->restrictToNewestLabelQuery($query, $this->source)) + ->when($this->isRestrictedToLabels(), fn ($query) => $this->restrictToLabelsQuery($query, 'image_annotation_labels')) + ->select($columns); + + if ($this->shouldSeparateLabelTrees()) { + $query->addSelect('labels.label_tree_id'); + } elseif ($this->shouldSeparateUsers()) { + $query->addSelect('image_annotation_labels.user_id'); + } + + return $query; + } + + /** + * Determines if this report should aggregate child labels. + * + * @return bool + */ + protected function shouldAggregateChildLabels() + { + return $this->options->get('aggregateChildLabels', false); + } +} diff --git a/app/Services/Reports/Volumes/ImageAnnotations/AreaReportGenerator.php b/app/Services/Reports/Volumes/ImageAnnotations/AreaReportGenerator.php new file mode 100644 index 000000000..ca23688e0 --- /dev/null +++ b/app/Services/Reports/Volumes/ImageAnnotations/AreaReportGenerator.php @@ -0,0 +1,341 @@ + + */ + protected $images; + + /** + * Generate the report. + * + * @param string $path Path to the report file that should be generated + */ + public function generateReport($path) + { + $rows = $this->query()->get(); + + $this->images = Image::whereIn('id', $rows->pluck('image_id')->unique()) + ->select('id', 'filename', 'attrs') + ->get() + ->keyBy('id'); + + if ($this->shouldSeparateLabelTrees() && $rows->isNotEmpty()) { + $rows = $rows->groupBy('label_tree_id'); + $trees = LabelTree::whereIn('id', $rows->keys())->pluck('name', 'id'); + + foreach ($trees as $id => $name) { + $this->tmpFiles[] = $this->createCsv($rows->get($id), $name); + } + } elseif ($this->shouldSeparateUsers() && $rows->isNotEmpty()) { + $rows = $rows->groupBy('user_id'); + $users = User::whereIn('id', $rows->keys()) + ->selectRaw("id, concat(firstname, ' ', lastname) as name") + ->pluck('name', 'id'); + + foreach ($users as $id => $name) { + $this->tmpFiles[] = $this->createCsv($rows->get($id), $name); + } + } else { + $this->tmpFiles[] = $this->createCsv($rows, $this->source->name); + } + + $this->executeScript('csvs_to_xlsx', $path); + } + + /** + * Assemble a new DB query for the volume of this report. + * + * @return \Illuminate\Database\Query\Builder + */ + protected function query() + { + $query = $this + ->initQuery([ + 'image_annotations.id as annotation_id', + 'shapes.id as shape_id', + 'shapes.name as shape_name', + 'image_annotation_labels.label_id', + 'labels.name as label_name', + 'image_annotations.image_id', + 'image_annotations.points', + ]) + ->join('shapes', 'image_annotations.shape_id', '=', 'shapes.id') + // We can only compute the area from annotations that have an area. + ->whereIn('shapes.id', [ + Shape::circleId(), + Shape::rectangleId(), + Shape::polygonId(), + Shape::ellipseId(), + Shape::lineId(), + ]) + ->orderBy('image_annotation_labels.id'); + + return $query; + } + + /** + * Create a CSV file for a single sheet of the spreadsheet of this report. + * + * @param \Illuminate\Support\Collection $rows The rows for the CSV + * @param string $title The title to put in the first row of the CSV + * @return CsvFile + */ + protected function createCsv($rows, $title = '') + { + $rows = $this->parseRows($rows); + $csv = CsvFile::makeTmp(); + $csv->put($title); + $csv->putCsv([ + 'annotation_id', + 'shape_id', + 'shape_name', + 'label_ids', + 'label_names', + 'image_id', + 'image_filename', + 'annotation_width_m', + 'annotation_height_m', + 'annotation_area_sqm', + 'annotation_width_px', + 'annotation_height_px', + 'annotation_area_sqpx', + ]); + + foreach ($rows as $row) { + $csv->putCsv([ + $row->id, + $row->shape_id, + $row->shape_name, + implode(', ', $row->label_ids), + implode(', ', $row->label_names), + $row->image_id, + $row->image_filename, + $row->width_m, + $row->height_m, + $row->area_sqm, + $row->width_px, + $row->height_px, + $row->area_sqpx, + ]); + } + + $csv->close(); + + return $csv; + } + + /** + * Creates the array of annotations that is inserted into the CSV file. + * + * @param \Illuminate\Support\Collection $rows + * @return array + */ + protected function parseRows($rows) + { + $annotations = []; + + foreach ($rows as $row) { + if (array_key_exists($row->annotation_id, $annotations)) { + $annotations[$row->annotation_id]->label_ids[] = $row->label_id; + $annotations[$row->annotation_id]->label_names[] = $row->label_name; + } else { + $annotation = new StdClass(); + $annotation->id = $row->annotation_id; + $annotation->shape_id = $row->shape_id; + $annotation->shape_name = $row->shape_name; + $annotation->label_ids = [$row->label_id]; + $annotation->label_names = [$row->label_name]; + $annotation->image_id = $row->image_id; + $annotation->image_filename = $this->images[$row->image_id]->filename; + + $this->setSize($annotation, $row); + + $annotations[$row->annotation_id] = $annotation; + } + } + + return $annotations; + } + + /** + * Calculate the pixel/sqm size and dimensions for an annotation. + * + * @param StdClass $annotation The annotation object to set the size of. + * @param StdClass $row Object containing information on the annotation from + * the DB. + */ + protected function setSize($annotation, $row) + { + $points = json_decode($row->points); + + // If we can't compute the dimensions or area, leave them blank. + $annotation->width_px = ''; + $annotation->height_px = ''; + $annotation->area_sqpx = ''; + $annotation->width_m = ''; + $annotation->height_m = ''; + $annotation->area_sqm = ''; + + switch ($annotation->shape_id) { + case Shape::circleId(): + // width and height are the diameter + $annotation->width_px = 2 * $points[2]; + $annotation->height_px = $annotation->width_px; + $annotation->area_sqpx = pow($points[2], 2) * M_PI; + break; + + case Shape::rectangleId(): + // A --- B + // | | + // D --- C + + // Distance between A and B. + $dim1 = sqrt(pow($points[0] - $points[2], 2) + pow($points[1] - $points[3], 2)); + // Distance between B and C. + $dim2 = sqrt(pow($points[2] - $points[4], 2) + pow($points[3] - $points[5], 2)); + + $annotation->width_px = max($dim1, $dim2); + $annotation->height_px = min($dim1, $dim2); + $annotation->area_sqpx = $dim1 * $dim2; + break; + + case Shape::polygonId(): + // See: http://www.mathopenref.com/coordpolygonarea.html and + // http://www.mathopenref.com/coordpolygonarea2.html + // For a description of the polygon area algorithm. + $min = [INF, INF]; + $max = [-INF, -INF]; + $area = 0; + $count = count($points); + // The last vertex is the 'previous' one to the first. + // -1 to get the last element and -1 to get the x coordinate. + $j = $count - 2; + + for ($i = 0; $i < $count; $i += 2) { + $area += ($points[$j] + $points[$i]) * ($points[$j + 1] - $points[$i + 1]); + // $j is the previous vertex to $i + $j = $i; + + // Find the minimal and maximal coordinates, too. + $min[0] = min($min[0], $points[$i]); + $min[1] = min($min[1], $points[$i + 1]); + $max[0] = max($max[0], $points[$i]); + $max[1] = max($max[1], $points[$i + 1]); + } + + $annotation->width_px = $max[0] - $min[0]; + $annotation->height_px = $max[1] - $min[1]; + $annotation->area_sqpx = abs($area / 2); + break; + + case Shape::ellipseId(): + // $a and $b are *double* the lengths of the semi-major axis and the + // semi-minor axis, respectively. + // See: https://www.math.hmc.edu/funfacts/ffiles/10006.3.shtml + // ___D___ + // / | \ + // / b \ + // | | | + // A--a--•-----B + // | | | + // \ | / + // \___C___/ + + // Distance between A and B. + $a = sqrt(pow($points[0] - $points[2], 2) + pow($points[1] - $points[3], 2)); + // Distance between C and D. + $b = sqrt(pow($points[4] - $points[6], 2) + pow($points[5] - $points[7], 2)); + + $annotation->width_px = max($a, $b); + $annotation->height_px = min($a, $b); + // Divide by 4 because $a and $b each are double the lengths. + $annotation->area_sqpx = M_PI * $a * $b / 4; + break; + case Shape::lineId(): + $totalPoints = count($points); + $length = 0; + + for ($i = 3; $i < $totalPoints; $i += 2) { + $length += sqrt(pow($points[$i - 3] - $points[$i - 1], 2) + pow($points[$i - 2] - $points[$i], 2)); + } + + // A line has no area so we just set the width to the total length. + $annotation->width_px = $length; + $annotation->height_px = 0; + $annotation->area_sqpx = 0; + break; + default: + // We can't compute the area for this shape. + return; + } + + // If the laserpoint detection module exists and the laserpoint detection was + // performed for the image of the annotation, compute the dimensions and area + // in m (m²) as well. + if (class_exists(LImage::class)) { + $image = $this->images[$row->image_id]; + + // Cache the area and number of pixels in the original image object so we + // don't have to convert the object and fetch the values again for each + // annotation. + if (!property_exists($image, 'area') || !property_exists($image, 'px')) { + $laserpointsImage = LImage::convert($image); + /** @phpstan-ignore property.notFound */ + $image->area = $laserpointsImage->area; + if ($image->width && $image->height) { + /** @phpstan-ignore property.notFound */ + $image->px = $image->width * $image->height; + } + } + + /** @phpstan-ignore property.notFound, property.notFound */ + if (!is_null($image->area) && !is_null($image->px)) { + // If we assume a pixel is a little square then this is the area of a + // single pixel. + $area = $image->area / $image->px; + // And this is the width/height of a single pixel. + $widthHeight = sqrt($area); + + $annotation->width_m = $widthHeight * $annotation->width_px; + $annotation->height_m = $widthHeight * $annotation->height_px; + $annotation->area_sqm = $area * $annotation->area_sqpx; + } + } + } +} diff --git a/app/Services/Reports/Volumes/ImageAnnotations/BasicReportGenerator.php b/app/Services/Reports/Volumes/ImageAnnotations/BasicReportGenerator.php new file mode 100644 index 000000000..86683254b --- /dev/null +++ b/app/Services/Reports/Volumes/ImageAnnotations/BasicReportGenerator.php @@ -0,0 +1,106 @@ +query()->get(); + + if ($this->shouldSeparateLabelTrees() && $labels->isNotEmpty()) { + $labels = $labels->groupBy('label_tree_id'); + $trees = LabelTree::whereIn('id', $labels->keys())->pluck('name', 'id'); + + foreach ($trees as $id => $name) { + $this->tmpFiles[] = $this->createCsv($labels->get($id), $name); + } + } elseif ($this->shouldSeparateUsers() && $labels->isNotEmpty()) { + $labels = $labels->groupBy('user_id'); + $users = User::whereIn('id', $labels->keys()) + ->selectRaw("id, concat(firstname, ' ', lastname) as name") + ->pluck('name', 'id'); + + foreach ($users as $id => $name) { + $this->tmpFiles[] = $this->createCsv($labels->get($id), $name); + } + } else { + $this->tmpFiles[] = $this->createCsv($labels); + } + + $this->executeScript('basic_report', $path); + } + + /** + * Assemble a new DB query for the volume of this report. + * + * @return \Illuminate\Database\Query\Builder + */ + protected function query() + { + $query = $this->initQuery(DB::raw('labels.name, labels.color, count(labels.id) as count')) + ->groupBy('labels.id') + ->orderBy('labels.id'); + + if ($this->shouldSeparateLabelTrees()) { + $query->addSelect('labels.label_tree_id'); + } elseif ($this->shouldSeparateUsers()) { + $query->addSelect('image_annotation_labels.user_id') + ->groupBy('user_id', 'labels.id'); + } + + return $query; + } + + /** + * Create a CSV file for a single plot of this report. + * + * @param \Illuminate\Support\Collection $labels The labels/rows for the CSV + * @param string $title The title to put in the first row of the CSV + * @return CsvFile + */ + protected function createCsv($labels, $title = '') + { + $csv = CsvFile::makeTmp(); + $csv->put($title); + + foreach ($labels as $label) { + $csv->putCsv([$label->name, $label->color, $label->count]); + } + + $csv->close(); + + return $csv; + } +} diff --git a/app/Services/Reports/Volumes/ImageAnnotations/CocoReportGenerator.php b/app/Services/Reports/Volumes/ImageAnnotations/CocoReportGenerator.php new file mode 100644 index 000000000..8d6f064fc --- /dev/null +++ b/app/Services/Reports/Volumes/ImageAnnotations/CocoReportGenerator.php @@ -0,0 +1,144 @@ +query()->get(); + $toZip = []; + + if ($this->shouldSeparateLabelTrees() && $rows->isNotEmpty()) { + $rows = $rows->groupBy('label_tree_id'); + $trees = LabelTree::whereIn('id', $rows->keys())->pluck('name', 'id'); + + foreach ($trees as $id => $name) { + $csv = $this->createCsv($rows->get($id)); + $this->tmpFiles[] = $csv; + $toZip[$csv->getPath()] = $this->sanitizeFilename("{$id}-{$name}", 'json'); + } + } elseif ($this->shouldSeparateUsers() && $rows->isNotEmpty()) { + $rows = $rows->groupBy('user_id'); + $users = User::whereIn('id', $rows->keys()) + ->selectRaw("id, concat(firstname, ' ', lastname) as name") + ->pluck('name', 'id'); + + foreach ($users as $id => $name) { + $csv = $this->createCsv($rows->get($id)); + $this->tmpFiles[] = $csv; + $toZip[$csv->getPath()] = $this->sanitizeFilename("{$id}-{$name}", 'json'); + } + } else { + $csv = $this->createCsv($rows); + $this->tmpFiles[] = $csv; + $toZip[$csv->getPath()] = $this->sanitizeFilename("{$this->source->id}-{$this->source->name}", 'json'); + } + $this->executeScript('to_coco', ''); // the temporary csv files are overwritten with the respective json files therefore the argument is not needed + $this->makeZip($toZip, $path); + } + + /** + * Assemble a new DB query for the volume of this report. + * + * @return \Illuminate\Database\Query\Builder + */ + protected function query() + { + $query = $this + ->initQuery([ + 'image_annotation_labels.id as annotation_label_id', + 'image_annotation_labels.label_id', + 'labels.name as label_name', + 'users.id as user_id', + 'images.id as image_id', + 'images.filename', + 'images.lng as longitude', + 'images.lat as latitude', + 'shapes.name as shape_name', + 'image_annotations.points', + 'images.attrs', + ]) + ->join('shapes', 'image_annotations.shape_id', '=', 'shapes.id') + ->leftJoin('users', 'image_annotation_labels.user_id', '=', 'users.id') + ->orderBy('image_annotation_labels.id'); + + return $query; + } + + /** + * Create a CSV file for this report. + * + * @param \Illuminate\Support\Collection $rows The rows for the CSV + * @return CsvFile + */ + protected function createCsv($rows) + { + $csv = CsvFile::makeTmp(); + // column headers + $csv->putCsv([ + 'annotation_label_id', + 'label_id', + 'label_name', + 'image_id', + 'filename', + 'image_longitude', + 'image_latitude', + 'shape_name', + 'points', + 'attributes', + ]); + + foreach ($rows as $row) { + $csv->putCsv([ + $row->annotation_label_id, + $row->label_id, + $row->label_name, + $row->image_id, + $row->filename, + $row->longitude, + $row->latitude, + $row->shape_name, + $row->points, + $row->attrs, + ]); + } + + $csv->close(); + + return $csv; + } +} diff --git a/app/Services/Reports/Volumes/ImageAnnotations/CsvReportGenerator.php b/app/Services/Reports/Volumes/ImageAnnotations/CsvReportGenerator.php new file mode 100644 index 000000000..9a3d556ee --- /dev/null +++ b/app/Services/Reports/Volumes/ImageAnnotations/CsvReportGenerator.php @@ -0,0 +1,172 @@ +initQuery()->exists(); + $toZip = []; + + if ($this->shouldSeparateLabelTrees() && $exists) { + $treeIds = $this->initQuery() + ->select('labels.label_tree_id') + ->distinct() + ->pluck('label_tree_id'); + + $trees = LabelTree::whereIn('id', $treeIds)->pluck('name', 'id'); + + foreach ($trees as $id => $name) { + $csv = $this->createCsv($this->query()->where('labels.label_tree_id', $id)); + $this->tmpFiles[] = $csv; + $toZip[$csv->getPath()] = $this->sanitizeFilename("{$id}-{$name}", 'csv'); + } + } elseif ($this->shouldSeparateUsers() && $exists) { + $userIds = $this->initQuery() + ->select('user_id') + ->distinct() + ->pluck('user_id'); + + $users = User::whereIn('id', $userIds) + ->selectRaw("id, concat(firstname, ' ', lastname) as name") + ->pluck('name', 'id'); + + foreach ($users as $id => $name) { + $csv = $this->createCsv($this->query()->where('user_id', $id)); + $this->tmpFiles[] = $csv; + $toZip[$csv->getPath()] = $this->sanitizeFilename("{$id}-{$name}", 'csv'); + } + } else { + $csv = $this->createCsv($this->query()); + $this->tmpFiles[] = $csv; + $toZip[$csv->getPath()] = $this->sanitizeFilename("{$this->source->id}-{$this->source->name}", 'csv'); + } + + $this->makeZip($toZip, $path); + } + + /** + * Assemble a new DB query for the volume of this report. + * + * @return \Illuminate\Database\Query\Builder + */ + protected function query() + { + $query = $this + ->initQuery([ + 'image_annotation_labels.id as annotation_label_id', + 'image_annotation_labels.label_id', + 'labels.name as label_name', + 'users.id as user_id', + 'users.firstname', + 'users.lastname', + 'images.id as image_id', + 'images.filename', + 'images.lng as longitude', + 'images.lat as latitude', + 'shapes.id as shape_id', + 'shapes.name as shape_name', + 'image_annotations.points', + 'images.attrs', + 'image_annotations.id as annotation_id', + 'image_annotation_labels.created_at', + ]) + ->join('shapes', 'image_annotations.shape_id', '=', 'shapes.id') + ->leftJoin('users', 'image_annotation_labels.user_id', '=', 'users.id') + ->orderBy('image_annotation_labels.id'); + + return $query; + } + + /** + * Create a CSV file for this report. + * + * @param \Illuminate\Database\Query\Builder $query The query for the CSV rows + * @return CsvFile + */ + protected function createCsv($query) + { + $csv = CsvFile::makeTmp(); + // column headers + $csv->putCsv([ + 'annotation_label_id', + 'label_id', + 'label_name', + 'label_hierarchy', + 'user_id', + 'firstname', + 'lastname', + 'image_id', + 'filename', + 'image_longitude', + 'image_latitude', + 'shape_id', + 'shape_name', + 'points', + 'attributes', + 'annotation_id', + 'created_at', + ]); + + $query->eachById(function ($row) use ($csv) { + $csv->putCsv([ + $row->annotation_label_id, + $row->label_id, + $row->label_name, + $this->expandLabelName($row->label_id), + $row->user_id, + $row->firstname, + $row->lastname, + $row->image_id, + $row->filename, + $row->longitude, + $row->latitude, + $row->shape_id, + $row->shape_name, + $row->points, + $row->attrs, + $row->annotation_id, + $row->created_at, + ]); + }, column: 'image_annotation_labels.id', alias: 'annotation_label_id'); + + $csv->close(); + + return $csv; + } +} diff --git a/app/Services/Reports/Volumes/ImageAnnotations/ExtendedReportGenerator.php b/app/Services/Reports/Volumes/ImageAnnotations/ExtendedReportGenerator.php new file mode 100644 index 000000000..3384852ea --- /dev/null +++ b/app/Services/Reports/Volumes/ImageAnnotations/ExtendedReportGenerator.php @@ -0,0 +1,113 @@ +query()->get(); + + if ($this->shouldSeparateLabelTrees() && $rows->isNotEmpty()) { + $rows = $rows->groupBy('label_tree_id'); + $trees = LabelTree::whereIn('id', $rows->keys())->pluck('name', 'id'); + + foreach ($trees as $id => $name) { + $this->tmpFiles[] = $this->createCsv($rows->get($id), $name); + } + } elseif ($this->shouldSeparateUsers() && $rows->isNotEmpty()) { + $rows = $rows->groupBy('user_id'); + $users = User::whereIn('id', $rows->keys()) + ->selectRaw("id, concat(firstname, ' ', lastname) as name") + ->pluck('name', 'id'); + + foreach ($users as $id => $name) { + $this->tmpFiles[] = $this->createCsv($rows->get($id), $name); + } + } else { + $this->tmpFiles[] = $this->createCsv($rows, $this->source->name); + } + + $this->executeScript('csvs_to_xlsx', $path); + } + + /** + * Assemble a new DB query for the volume of this report. + * + * @return \Illuminate\Database\Query\Builder + */ + protected function query() + { + $query = $this->initQuery() + ->orderBy('images.filename'); + + if ($this->shouldSeparateLabelTrees()) { + $query->selectRaw('images.filename, image_annotation_labels.label_id, count(image_annotation_labels.label_id) as count, labels.label_tree_id') + ->groupBy('image_annotation_labels.label_id', 'images.id', 'labels.label_tree_id'); + } elseif ($this->shouldSeparateUsers()) { + $query->selectRaw('images.filename, image_annotation_labels.label_id, count(image_annotation_labels.label_id) as count, image_annotation_labels.user_id') + ->groupBy('image_annotation_labels.label_id', 'images.id', 'image_annotation_labels.user_id'); + } else { + $query->selectRaw('images.filename, image_annotation_labels.label_id, count(image_annotation_labels.label_id) as count') + ->groupBy('image_annotation_labels.label_id', 'images.id'); + } + + return $query; + } + + /** + * Create a CSV file for a single sheet of the spreadsheet of this report. + * + * @param \Illuminate\Support\Collection $rows The rows for the CSV + * @param string $title The title to put in the first row of the CSV + * @return CsvFile + */ + protected function createCsv($rows, $title = '') + { + $csv = CsvFile::makeTmp(); + $csv->put($title); + $csv->putCsv(['image_filename', 'label_hierarchy', 'annotation_count']); + + foreach ($rows as $row) { + $csv->putCsv([ + $row->filename, + $this->expandLabelName($row->label_id), + $row->count, + ]); + } + + $csv->close(); + + return $csv; + } +} diff --git a/app/Services/Reports/Volumes/ImageAnnotations/FullReportGenerator.php b/app/Services/Reports/Volumes/ImageAnnotations/FullReportGenerator.php new file mode 100644 index 000000000..778dc8b51 --- /dev/null +++ b/app/Services/Reports/Volumes/ImageAnnotations/FullReportGenerator.php @@ -0,0 +1,130 @@ +query()->get(); + + if ($this->shouldSeparateLabelTrees() && $rows->isNotEmpty()) { + $rows = $rows->groupBy('label_tree_id'); + $trees = LabelTree::whereIn('id', $rows->keys())->pluck('name', 'id'); + + foreach ($trees as $id => $name) { + $this->tmpFiles[] = $this->createCsv($rows->get($id), $name); + } + } elseif ($this->shouldSeparateUsers() && $rows->isNotEmpty()) { + $rows = $rows->groupBy('user_id'); + $users = User::whereIn('id', $rows->keys()) + ->selectRaw("id, concat(firstname, ' ', lastname) as name") + ->pluck('name', 'id'); + + foreach ($users as $id => $name) { + $this->tmpFiles[] = $this->createCsv($rows->get($id), $name); + } + } else { + $this->tmpFiles[] = $this->createCsv($rows, $this->source->name); + } + + $this->executeScript('full_report', $path); + } + + /** + * Assemble a new DB query for the volume of this report. + * + * @return \Illuminate\Database\Query\Builder + */ + protected function query() + { + $query = $this + ->initQuery([ + 'images.filename', + 'image_annotations.id as annotation_id', + 'image_annotation_labels.label_id', + 'shapes.name as shape_name', + 'image_annotations.points', + 'images.attrs', + ]) + ->join('shapes', 'image_annotations.shape_id', '=', 'shapes.id') + ->orderBy('image_annotations.id'); + + return $query; + } + + /** + * Create a CSV file for a single sheet of the spreadsheet of this report. + * + * @param \Illuminate\Support\Collection $rows The rows for the CSV + * @param string $title The title to put in the first row of the CSV + * @return CsvFile + */ + protected function createCsv($rows, $title = '') + { + $csv = CsvFile::makeTmp(); + $csv->put($title); + $csv->putCsv(['image filename', 'annotation id', 'annotation shape', 'x/radius', 'y', 'labels', 'image area in m²']); + + foreach ($rows as $row) { + $csv->putCsv([ + $row->filename, + $row->annotation_id, + $this->expandLabelName($row->label_id), + $row->shape_name, + $row->points, + $this->getArea($row->attrs), + ]); + } + + $csv->close(); + + return $csv; + } + + /** + * Parses the image attrs JSON object to retrieve the computed area of the laserpoint detection. + * + * @param string $attrs Image attrs JSON as string + * @return mixed The number or `null` + */ + protected function getArea($attrs) + { + $attrs = json_decode($attrs, true); + if (is_array($attrs)) { + return Arr::get($attrs, 'laserpoints.area'); + } + } +} diff --git a/app/Services/Reports/Volumes/ImageAnnotations/ImageLocationReportGenerator.php b/app/Services/Reports/Volumes/ImageAnnotations/ImageLocationReportGenerator.php new file mode 100644 index 000000000..fd7908ba5 --- /dev/null +++ b/app/Services/Reports/Volumes/ImageAnnotations/ImageLocationReportGenerator.php @@ -0,0 +1,147 @@ +join('images', 'image_annotations.image_id', '=', 'images.id') + ->join('labels', 'image_annotation_labels.label_id', '=', 'labels.id') + ->where('images.volume_id', $this->source->id) + ->when($this->isRestrictedToLabels(), fn ($query) => $this->restrictToLabelsQuery($query, 'image_annotation_labels')) + ->orderBy('labels.id') + ->distinct(); + + $labels = $this->query()->get(); + + $images = $this->source->images() + ->whereNotNull('lng') + ->whereNotNull('lat'); + + if ($this->shouldSeparateLabelTrees() && $labels->isNotEmpty()) { + $labels = $labels->groupBy('label_tree_id'); + $trees = LabelTree::whereIn('id', $labels->keys())->pluck('name', 'id'); + + foreach ($trees as $id => $name) { + $usedLabels = (clone $usedLabelsQuery) + ->where('labels.label_tree_id', $id) + ->pluck('labels.name', 'labels.id'); + + $tmpLabels = $labels->get($id)->groupBy('image_id'); + $file = $this->createNdJSON($images, $usedLabels, $tmpLabels); + $this->tmpFiles[] = $file; + $toZip[$file->getPath()] = $this->sanitizeFilename("{$id}-{$name}", 'ndjson'); + } + } elseif ($this->shouldSeparateUsers() && $labels->isNotEmpty()) { + $usedLabels = $usedLabelsQuery->pluck('labels.name', 'labels.id'); + $labels = $labels->groupBy('user_id'); + $users = User::whereIn('id', $labels->keys()) + ->selectRaw("id, concat(firstname, ' ', lastname) as name") + ->pluck('name', 'id'); + + foreach ($users as $id => $name) { + $tmpLabels = $labels->get($id)->groupBy('image_id'); + $file = $this->createNdJSON($images, $usedLabels, $tmpLabels); + $this->tmpFiles[] = $file; + $toZip[$file->getPath()] = $this->sanitizeFilename("{$id}-{$name}", 'ndjson'); + } + } else { + $usedLabels = $usedLabelsQuery->pluck('labels.name', 'labels.id'); + $labels = $labels->groupBy('image_id'); + $file = $this->createNdJSON($images, $usedLabels, $labels); + $this->tmpFiles[] = $file; + $toZip[$file->getPath()] = $this->sanitizeFilename("{$this->source->id}-{$this->source->name}", 'ndjson'); + } + + $this->makeZip($toZip, $path); + } + + /** + * Assemble a new DB query for the volume of this report. + * + * @return \Illuminate\Database\Query\Builder + */ + public function query() + { + return $this->initQuery([ + 'image_annotations.image_id', + 'image_annotation_labels.label_id', + ]); + } + + /** + * Create the newline delimited GeoJSON file. + * + * @param \Illuminate\Database\Query\Builder $query + * @param \Illuminate\Support\Collection $usedLabels + * @param \Illuminate\Support\Enumerable $labels + * + * @return File + */ + protected function createNdJSON($query, $usedLabels, $labels) + { + $file = File::makeTmp(); + + $query->each(function ($image) use ($usedLabels, $labels, $file) { + $properties = [ + '_id' => $image->id, + '_filename' => $image->filename, + ]; + + foreach ($usedLabels as $id => $name) { + $item = $labels->get($image->id); + if ($item) { + $properties["{$name} (#{$id})"] = $item->where('label_id', $id)->count(); + } else { + $properties["{$name} (#{$id})"] = 0; + } + } + + $feature = new Feature(new Point([$image->lng, $image->lat]), $properties); + $file->put(json_encode($feature)."\n"); + }); + $file->close(); + + return $file; + } +} diff --git a/app/Services/Reports/Volumes/ImageIfdoReportGenerator.php b/app/Services/Reports/Volumes/ImageIfdoReportGenerator.php new file mode 100644 index 000000000..199571a87 --- /dev/null +++ b/app/Services/Reports/Volumes/ImageIfdoReportGenerator.php @@ -0,0 +1,204 @@ +query()->eachById([$this, 'processFile']); + } + + /** + * Assemble a new DB query for the volume of this report. + * + * @return \Illuminate\Database\Query\Builder + */ + protected function query() + { + $relations = [ + 'annotations' => function ($query) { + // This makes the beavior more consistent in tests, too. + $query = $query->orderBy('image_annotations.id'); + + if ($this->isRestrictedToExportArea()) { + return $this->restrictToExportAreaQuery($query); + } + }, + 'annotations.labels' => function ($query) { + if ($this->isRestrictedToNewestLabel()) { + $query = $this->restrictToNewestLabelQuery($query, $this->source); + } + + if ($this->isRestrictedToLabels()) { + $query = $this->restrictToLabelsQuery($query, 'image_annotation_labels'); + } + + return $query; + }, + 'labels' => function ($query) { + if ($this->isRestrictedToLabels()) { + return $query->whereIn('image_labels.label_id', $this->getOnlyLabels()); + } + }, + ]; + + return $this->source->images()->with($relations); + } + + /** + * Get all users who annotated in the volume. + * + * @return \Illuminate\Support\Collection + */ + protected function getUsers() + { + return User::query() + ->whereIn('id', function ($query) { + $query->select('user_id') + ->from('image_annotation_labels') + ->join('image_annotations', 'image_annotations.id', '=', 'image_annotation_labels.annotation_id') + ->join('images', 'image_annotations.image_id', '=', 'images.id') + ->where('images.volume_id', $this->source->id); + }) + ->orWhereIn('id', function ($query) { + $query->select('user_id') + ->from('image_labels') + ->join('images', 'image_labels.image_id', '=', 'images.id') + ->where('images.volume_id', $this->source->id); + }) + ->get(); + } + + /** + * Get all labels that were used in the volume. + * + * @return \Illuminate\Support\Collection + */ + protected function getLabels() + { + return Label::query() + ->whereIn('id', function ($query) { + $query->select('label_id') + ->from('image_annotation_labels') + ->join('image_annotations', 'image_annotations.id', '=', 'image_annotation_labels.annotation_id') + ->join('images', 'image_annotations.image_id', '=', 'images.id') + ->where('images.volume_id', $this->source->id); + }) + ->orWhereIn('id', function ($query) { + $query->select('label_id') + ->from('image_labels') + ->join('images', 'image_labels.image_id', '=', 'images.id') + ->where('images.volume_id', $this->source->id); + }) + ->get(); + } + + /** + * Create the image-set-item entry for an image. + */ + public function processFile(Image $image) + { + // Remove annotations that should not be included because of an "onlyLabels" + // filter. + $annotations = $image->annotations->filter(fn ($a) => $a->labels->isNotEmpty()); + + $annotations = $annotations->map(function ($annotation) { + $labels = $annotation->labels->map(function ($aLabel) { + $user = $this->users->get($aLabel->user_id); + if (!in_array($user, $this->imageAnnotationCreators)) { + $this->imageAnnotationCreators[] = $user; + } + + $label = $this->labels->get($aLabel->label_id); + if (!in_array($label, $this->imageAnnotationLabels)) { + $this->imageAnnotationLabels[] = $label; + } + + if ($this->shouldConvertWormsId($label)) { + $labelId = $this->getWormsUrn($label); + } else { + $labelId = $label->id; + } + + return [ + 'label' => "$labelId", + 'annotator' => $user->uuid, + 'created-at' => $aLabel->created_at->toJson(), + ]; + }); + + return [ + 'shape' => $this->getGeometryName($annotation), + 'coordinates' => [$annotation->points], + 'labels' => $labels->toArray(), + ]; + }); + + $labels = $image->labels->map(function ($iLabel) { + $user = $this->users->get($iLabel->user_id); + if (!in_array($user, $this->imageAnnotationCreators)) { + $this->imageAnnotationCreators[] = $user; + } + + $label = $this->labels->get($iLabel->label_id); + if (!in_array($label, $this->imageAnnotationLabels)) { + $this->imageAnnotationLabels[] = $label; + } + + if ($this->shouldConvertWormsId($label)) { + $labelId = $this->getWormsUrn($label); + } else { + $labelId = $label->id; + } + + return [ + 'shape' => 'whole-image', + 'coordinates' => [[]], + 'labels' => [ + [ + 'label' => "$labelId", + 'annotator' => $user->uuid, + 'created-at' => $iLabel->created_at->toJson(), + ], + ], + ]; + }); + + $this->imageSetItems[$image->filename] = []; + + // Use toBase() because the merge method of Eloquent collections works + // differently. + $imageAnnotations = $annotations->toBase()->merge($labels)->toArray(); + + if (!empty($imageAnnotations)) { + $this->imageSetItems[$image->filename]['image-annotations'] = $imageAnnotations; + } + } +} diff --git a/app/Services/Reports/Volumes/ImageLabels/BasicReportGenerator.php b/app/Services/Reports/Volumes/ImageLabels/BasicReportGenerator.php new file mode 100644 index 000000000..006c819a1 --- /dev/null +++ b/app/Services/Reports/Volumes/ImageLabels/BasicReportGenerator.php @@ -0,0 +1,115 @@ +query()->get(); + + if ($this->shouldSeparateLabelTrees() && $rows->isNotEmpty()) { + $rows = $rows->groupBy('label_tree_id'); + $trees = LabelTree::whereIn('id', $rows->keys())->pluck('name', 'id'); + + foreach ($trees as $id => $name) { + $this->tmpFiles[] = $this->createCsv($rows->get($id), $name); + } + } elseif ($this->shouldSeparateUsers() && $rows->isNotEmpty()) { + $rows = $rows->groupBy('user_id'); + $users = User::whereIn('id', $rows->keys()) + ->selectRaw("id, concat(firstname, ' ', lastname) as name") + ->pluck('name', 'id'); + + foreach ($users as $id => $name) { + $this->tmpFiles[] = $this->createCsv($rows->get($id), $name); + } + } else { + $this->tmpFiles[] = $this->createCsv($rows, $this->source->name); + } + + $this->executeScript('csvs_to_xlsx', $path); + } + + /** + * Assemble a new DB query for the volume of this report. + * + * @return \Illuminate\Database\Query\Builder + */ + public function query() + { + $query = DB::table('image_labels') + ->join('images', 'image_labels.image_id', '=', 'images.id') + ->select('images.id', 'images.filename', 'image_labels.label_id') + ->where('images.volume_id', $this->source->id) + ->when($this->isRestrictedToLabels(), fn ($query) => $this->restrictToLabelsQuery($query, 'image_labels')) + ->orderBy('images.filename'); + + if ($this->shouldSeparateLabelTrees()) { + $query->join('labels', 'labels.id', '=', 'image_labels.label_id') + ->addSelect('labels.label_tree_id'); + } elseif ($this->shouldSeparateusers()) { + $query->addSelect('image_labels.user_id'); + } + + return $query; + } + + /** + * Create a CSV file for a single sheet of the spreadsheet of this report. + * + * @param \Illuminate\Support\Collection $rows The rows for the CSV + * @param string $title The title to put in the first row of the CSV + * @return CsvFile + */ + protected function createCsv($rows, $title = '') + { + $csv = CsvFile::makeTmp(); + $csv->put($title); + $csv->putCsv(['image_id', 'image_filename', 'label_hierarchies']); + + foreach ($rows->groupBy('id') as $row) { + $csv->putCsv([ + $row[0]->id, + $row[0]->filename, + $row->map(fn ($row) => $this->expandLabelName($row->label_id))->implode(', '), + ]); + } + + $csv->close(); + + return $csv; + } +} diff --git a/app/Services/Reports/Volumes/ImageLabels/CsvReportGenerator.php b/app/Services/Reports/Volumes/ImageLabels/CsvReportGenerator.php new file mode 100644 index 000000000..14ee76be0 --- /dev/null +++ b/app/Services/Reports/Volumes/ImageLabels/CsvReportGenerator.php @@ -0,0 +1,157 @@ +query()->get(); + $toZip = []; + + if ($this->shouldSeparateLabelTrees() && $rows->isNotEmpty()) { + $rows = $rows->groupBy('label_tree_id'); + $trees = LabelTree::whereIn('id', $rows->keys())->pluck('name', 'id'); + + foreach ($trees as $id => $name) { + $csv = $this->createCsv($rows->get($id)); + $this->tmpFiles[] = $csv; + $toZip[$csv->getPath()] = $this->sanitizeFilename("{$id}-{$name}", 'csv'); + } + } elseif ($this->shouldSeparateUsers() && $rows->isNotEmpty()) { + $rows = $rows->groupBy('user_id'); + $users = User::whereIn('id', $rows->keys()) + ->selectRaw("id, concat(firstname, ' ', lastname) as name") + ->pluck('name', 'id'); + + foreach ($users as $id => $name) { + $csv = $this->createCsv($rows->get($id)); + $this->tmpFiles[] = $csv; + $toZip[$csv->getPath()] = $this->sanitizeFilename("{$id}-{$name}", 'csv'); + } + } else { + $csv = $this->createCsv($rows); + $this->tmpFiles[] = $csv; + $toZip[$csv->getPath()] = $this->sanitizeFilename("{$this->source->id}-{$this->source->name}", 'csv'); + } + + $this->makeZip($toZip, $path); + } + + /** + * Assemble a new DB query for the volume of this report. + * + * @return \Illuminate\Database\Query\Builder + */ + public function query() + { + $query = DB::table('image_labels') + ->join('images', 'image_labels.image_id', '=', 'images.id') + ->leftJoin('users', 'image_labels.user_id', '=', 'users.id') + ->join('labels', 'labels.id', '=', 'image_labels.label_id') + ->select([ + 'image_labels.id as image_label_id', + 'image_labels.image_id', + 'images.filename', + 'images.lng as longitude', + 'images.lat as latitude', + 'image_labels.user_id', + 'users.firstname', + 'users.lastname', + 'image_labels.label_id', + 'labels.name as label_name', + 'image_labels.created_at', + ]) + ->where('images.volume_id', $this->source->id) + ->when($this->isRestrictedToLabels(), fn ($query) => $this->restrictToLabelsQuery($query, 'image_labels')) + ->orderBy('images.filename'); + + if ($this->shouldSeparateLabelTrees()) { + $query->addSelect('labels.label_tree_id'); + } + + return $query; + } + + /** + * Create a CSV file for this report. + * + * @param \Illuminate\Support\Collection $rows The rows for the CSV + * @return CsvFile + */ + protected function createCsv($rows) + { + $csv = CsvFile::makeTmp(); + // column headers + $csv->putCsv([ + 'image_label_id', + 'image_id', + 'filename', + 'longitude', + 'latitude', + 'user_id', + 'firstname', + 'lastname', + 'label_id', + 'label_name', + 'label_hierarchy', + 'created_at', + ]); + + foreach ($rows as $row) { + $csv->putCsv([ + $row->image_label_id, + $row->image_id, + $row->filename, + $row->longitude, + $row->latitude, + $row->user_id, + $row->firstname, + $row->lastname, + $row->label_id, + $row->label_name, + $this->expandLabelName($row->label_id), + $row->created_at, + ]); + } + + $csv->close(); + + return $csv; + } +} diff --git a/app/Services/Reports/Volumes/ImageLabels/ImageLocationReportGenerator.php b/app/Services/Reports/Volumes/ImageLabels/ImageLocationReportGenerator.php new file mode 100644 index 000000000..d12069d61 --- /dev/null +++ b/app/Services/Reports/Volumes/ImageLabels/ImageLocationReportGenerator.php @@ -0,0 +1,160 @@ +join('labels', 'image_labels.label_id', '=', 'labels.id') + ->where('images.volume_id', $this->source->id) + ->when($this->isRestrictedToLabels(), fn ($query) => $this->restrictToLabelsQuery($query, 'image_labels')) + ->orderBy('labels.id') + ->distinct(); + + $imageLabels = $this->query()->get(); + + $images = $this->source->images() + ->whereNotNull('lng') + ->whereNotNull('lat'); + + if ($this->shouldSeparateLabelTrees() && $imageLabels->isNotEmpty()) { + $imageLabels = $imageLabels->groupBy('label_tree_id'); + $trees = LabelTree::whereIn('id', $imageLabels->keys())->pluck('name', 'id'); + + foreach ($trees as $id => $name) { + $usedImageLabels = (clone $usedImageLabelsQuery) + ->where('labels.label_tree_id', $id) + ->pluck('labels.name', 'labels.id'); + + $tmpImageLabels = $imageLabels->get($id)->groupBy('image_id'); + $file = $this->createNdJSON($images, $usedImageLabels, $tmpImageLabels); + $this->tmpFiles[] = $file; + $toZip[$file->getPath()] = $this->sanitizeFilename("{$id}-{$name}", 'ndjson'); + } + } elseif ($this->shouldSeparateUsers() && $imageLabels->isNotEmpty()) { + $usedImageLabels = $usedImageLabelsQuery->pluck('labels.name', 'labels.id'); + $imageLabels = $imageLabels->groupBy('user_id'); + $users = User::whereIn('id', $imageLabels->keys()) + ->selectRaw("id, concat(firstname, ' ', lastname) as name") + ->pluck('name', 'id'); + + foreach ($users as $id => $name) { + $tmpImageLabels = $imageLabels->get($id)->groupBy('image_id'); + $file = $this->createNdJSON($images, $usedImageLabels, $tmpImageLabels); + $this->tmpFiles[] = $file; + $toZip[$file->getPath()] = $this->sanitizeFilename("{$id}-{$name}", 'ndjson'); + } + } else { + $usedImageLabels = $usedImageLabelsQuery->pluck('labels.name', 'labels.id'); + $imageLabels = $imageLabels->groupBy('image_id'); + $file = $this->createNdJSON($images, $usedImageLabels, $imageLabels); + $this->tmpFiles[] = $file; + $toZip[$file->getPath()] = $this->sanitizeFilename("{$this->source->id}-{$this->source->name}", 'ndjson'); + } + + $this->makeZip($toZip, $path); + } + + /** + * Assemble a new DB query for the volume of this report. + * + * @return \Illuminate\Contracts\Database\Query\Builder + */ + public function query() + { + $query = DB::table('image_labels') + ->join('images', 'image_labels.image_id', '=', 'images.id') + ->select([ + 'image_labels.image_id', + 'image_labels.label_id', + ]) + ->where('images.volume_id', $this->source->id) + ->when($this->isRestrictedToLabels(), fn ($query) => $this->restrictToLabelsQuery($query, 'image_labels')); + + if ($this->shouldSeparateLabelTrees()) { + $query->join('labels', 'labels.id', '=', 'image_labels.label_id') + ->addSelect('labels.label_tree_id'); + } elseif ($this->shouldSeparateUsers()) { + $query->addSelect('image_labels.user_id'); + } + + return $query; + } + + /** + * Create the newline delimited GeoJSON file. + * + * @param \Illuminate\Database\Query\Builder $query + * @param \Illuminate\Support\Collection $usedImageLabels + * @param \Illuminate\Support\Enumerable $imageLabels + * + * @return File + */ + protected function createNdJSON($query, $usedImageLabels, $imageLabels) + { + $file = File::makeTmp(); + + $query->each(function ($image) use ($usedImageLabels, $imageLabels, $file) { + $properties = [ + '_id' => $image->id, + '_filename' => $image->filename, + ]; + + foreach ($usedImageLabels as $id => $name) { + $item = $imageLabels->get($image->id); + if ($item && $item->firstWhere('label_id', $id)) { + $properties["{$name} (#{$id})"] = 1; + } else { + $properties["{$name} (#{$id})"] = 0; + } + } + + $feature = new Feature(new Point([$image->lng, $image->lat]), $properties); + $file->put(json_encode($feature)."\n"); + }); + $file->close(); + + return $file; + } +} diff --git a/app/Services/Reports/Volumes/PythonScriptRunner.php b/app/Services/Reports/Volumes/PythonScriptRunner.php new file mode 100644 index 000000000..ce10cadc9 --- /dev/null +++ b/app/Services/Reports/Volumes/PythonScriptRunner.php @@ -0,0 +1,57 @@ +lines = []; + $this->code = 0; + } + + /** + * Execute the external report parsing Python script. + * + * @param string $scriptName Name of the script to execute (in the `reports.scripts` config namespace) + * @param string $volumeName Name of the volume that belongs to the data + * @param string $path Path to the file to store the generated report to + * @param array $csvs Array of CSV files that should be passed along to the script + * + * @throws Exception If the script returned an error code. + */ + public function run($scriptName, $volumeName, $path, $csvs = []) + { + $python = config('reports.python'); + $script = config("reports.scripts.{$scriptName}"); + + $csvs = implode(' ', array_map(fn ($csv) => $csv->getPath(), $csvs)); + + $command = "{$python} {$script} \"{$volumeName}\" {$path} {$csvs} 2>&1"; + + exec($command, $this->lines, $this->code); + + if ($this->code !== 0) { + throw new Exception("The report script '{$scriptName}' failed with exit code {$this->code}:\n".implode("\n", $this->lines)); + } + } +} diff --git a/app/Services/Reports/Volumes/VideoAnnotations/CsvReportGenerator.php b/app/Services/Reports/Volumes/VideoAnnotations/CsvReportGenerator.php new file mode 100644 index 000000000..07e2846a6 --- /dev/null +++ b/app/Services/Reports/Volumes/VideoAnnotations/CsvReportGenerator.php @@ -0,0 +1,275 @@ +isRestrictedToAnnotationSession()) { + $name = $this->getAnnotationSessionName(); + $restrictions[] = "annotation session {$name}"; + } + + if ($this->isRestrictedToNewestLabel()) { + $restrictions[] = 'newest label of each annotation'; + } + + if (!empty($restrictions)) { + $suffix = implode(', ', $restrictions); + + return "{$this->name} (restricted to {$suffix})"; + } + + return $this->name; + } + + /** + * Get the filename. + * + * @return string + */ + public function getFilename() + { + $restrictions = []; + + if ($this->isRestrictedToAnnotationSession()) { + $name = Str::slug($this->getAnnotationSessionName()); + $restrictions[] = "annotation_session_{$name}"; + } + + if ($this->isRestrictedToNewestLabel()) { + $restrictions[] = 'newest_label'; + } + + if (!empty($restrictions)) { + $suffix = implode('_', $restrictions); + + return "{$this->filename}_restricted_to_{$suffix}"; + } + + return $this->filename; + } + + /** + * Generate the report. + * + * @param string $path Path to the report file that should be generated + */ + public function generateReport($path) + { + $exists = $this->initQuery()->exists(); + $toZip = []; + + if ($this->shouldSeparateLabelTrees() && $exists) { + $treeIds = $this->initQuery() + ->select('labels.label_tree_id') + ->distinct() + ->pluck('label_tree_id'); + $trees = LabelTree::whereIn('id', $treeIds)->pluck('name', 'id'); + + foreach ($trees as $id => $name) { + $csv = $this->createCsv($this->query()->where('labels.label_tree_id', $id)); + $this->tmpFiles[] = $csv; + $toZip[$csv->getPath()] = $this->sanitizeFilename("{$id}-{$name}", 'csv'); + } + } elseif ($this->shouldSeparateUsers() && $exists) { + $userIds = $this->initQuery() + ->select('user_id') + ->distinct() + ->pluck('user_id'); + + $users = User::whereIn('id', $userIds) + ->selectRaw("id, concat(firstname, ' ', lastname) as name") + ->pluck('name', 'id'); + + foreach ($users as $id => $name) { + $csv = $this->createCsv($this->query()->where('user_id', $id)); + $this->tmpFiles[] = $csv; + $toZip[$csv->getPath()] = $this->sanitizeFilename("{$id}-{$name}", 'csv'); + } + } else { + $csv = $this->createCsv($this->query()); + $this->tmpFiles[] = $csv; + $toZip[$csv->getPath()] = $this->sanitizeFilename("{$this->source->id}-{$this->source->name}", 'csv'); + } + + $this->makeZip($toZip, $path); + } + + /** + * Assembles the part of the DB query that is the same for all annotation reports. + * + * @param mixed $columns The columns to select + * @return \Illuminate\Database\Query\Builder + */ + public function initQuery($columns = []) + { + $query = DB::table('video_annotation_labels') + ->join('video_annotations', 'video_annotation_labels.annotation_id', '=', 'video_annotations.id') + ->join('videos', 'video_annotations.video_id', '=', 'videos.id') + ->join('labels', 'video_annotation_labels.label_id', '=', 'labels.id') + ->where('videos.volume_id', $this->source->id) + ->when($this->isRestrictedToAnnotationSession(), [$this, 'restrictToAnnotationSessionQuery']) + ->when($this->isRestrictedToNewestLabel(), fn ($query) => $this->restrictToNewestLabelQuery($query, $this->source)) + ->when($this->isRestrictedToLabels(), fn ($query) => $this->restrictToLabelsQuery($query, 'video_annotation_labels')) + ->select($columns); + + if ($this->shouldSeparateLabelTrees()) { + $query->addSelect('labels.label_tree_id'); + } elseif ($this->shouldSeparateUsers()) { + $query->addSelect('video_annotation_labels.user_id'); + } + + return $query; + } + + /** + * Callback to be used in a `when` query statement that restricts the resulting annotation labels to the annotation session of this report. + * + * @param \Illuminate\Database\Query\Builder $query + * @return \Illuminate\Database\Query\Builder + */ + public function restrictToAnnotationSessionQuery($query) + { + $session = $this->getAnnotationSession(); + + return $query->where(function ($query) use ($session) { + // take only annotations that belong to the time span... + $query->where('video_annotations.created_at', '>=', $session->starts_at) + ->where('video_annotations.created_at', '<', $session->ends_at) + // ...and to the users of the session + ->whereIn('video_annotation_labels.user_id', function ($query) use ($session) { + $query->select('user_id') + ->from('annotation_session_user') + ->where('annotation_session_id', $session->id); + }); + }); + } + + /** + * Assemble a new DB query for the video of this report. + * + * @return \Illuminate\Database\Query\Builder + */ + protected function query() + { + $query = $this + ->initQuery([ + 'video_annotation_labels.id as video_annotation_label_id', + 'video_annotation_labels.label_id', + 'labels.name as label_name', + 'users.id as user_id', + 'users.firstname', + 'users.lastname', + 'videos.id as video_id', + 'videos.filename as video_filename', + 'videos.attrs', + 'shapes.id as shape_id', + 'shapes.name as shape_name', + 'video_annotations.points', + 'video_annotations.frames', + 'video_annotations.id as annotation_id', + 'video_annotation_labels.created_at', + ]) + ->join('shapes', 'video_annotations.shape_id', '=', 'shapes.id') + ->leftJoin('users', 'video_annotation_labels.user_id', '=', 'users.id') + ->orderBy('video_annotation_labels.id'); + + return $query; + } + + /** + * Create a CSV file for this report. + * + * @param \Illuminate\Database\Query\Builder $query The query for the CSV rows + * @return CsvFile + */ + protected function createCsv($query) + { + $csv = CsvFile::makeTmp(); + // column headers + $csv->putCsv([ + 'video_annotation_label_id', + 'label_id', + 'label_name', + 'label_hierarchy', + 'user_id', + 'firstname', + 'lastname', + 'video_id', + 'video_filename', + 'shape_id', + 'shape_name', + 'points', + 'frames', + 'annotation_id', + 'created_at', + 'attributes', + ]); + + $query->eachById(function ($row) use ($csv) { + $csv->putCsv([ + $row->video_annotation_label_id, + $row->label_id, + $row->label_name, + $this->expandLabelName($row->label_id), + $row->user_id, + $row->firstname, + $row->lastname, + $row->video_id, + $row->video_filename, + $row->shape_id, + $row->shape_name, + $row->points, + $row->frames, + $row->annotation_id, + $row->created_at, + $row->attrs, + ]); + }, column: 'video_annotation_labels.id', alias: 'video_annotation_label_id'); + + $csv->close(); + + return $csv; + } +} diff --git a/app/Services/Reports/Volumes/VideoIfdoReportGenerator.php b/app/Services/Reports/Volumes/VideoIfdoReportGenerator.php new file mode 100644 index 000000000..fd13ba0e2 --- /dev/null +++ b/app/Services/Reports/Volumes/VideoIfdoReportGenerator.php @@ -0,0 +1,208 @@ +query()->eachById([$this, 'processFile']); + } + + /** + * Assemble a new DB query for the volume of this report. + * + * @return \Illuminate\Database\Query\Builder + */ + protected function query() + { + $relations = [ + 'annotations' => function ($query) { + // This makes the beavior more consistent in tests, too. + return $query->orderBy('video_annotations.id'); + }, + 'annotations.labels' => function ($query) { + if ($this->isRestrictedToNewestLabel()) { + $query = $this->restrictToNewestLabelQuery($query, $this->source); + } + + if ($this->isRestrictedToLabels()) { + $query = $this->restrictToLabelsQuery($query, 'video_annotation_labels'); + } + + return $query; + }, + 'labels' => function ($query) { + if ($this->isRestrictedToLabels()) { + return $query->whereIn('video_labels.label_id', $this->getOnlyLabels()); + } + + return $query; + }, + ]; + + return $this->source->videos()->with($relations); + } + + /** + * Get all users who annotated in the volume. + * + * @return \Illuminate\Support\Collection + */ + protected function getUsers() + { + return User::query() + ->whereIn('id', function ($query) { + $query->select('user_id') + ->from('video_annotation_labels') + ->join('video_annotations', 'video_annotations.id', '=', 'video_annotation_labels.annotation_id') + ->join('videos', 'video_annotations.video_id', '=', 'videos.id') + ->where('videos.volume_id', $this->source->id); + }) + ->orWhereIn('id', function ($query) { + $query->select('user_id') + ->from('video_labels') + ->join('videos', 'video_labels.video_id', '=', 'videos.id') + ->where('videos.volume_id', $this->source->id); + }) + ->get(); + } + + /** + * Get all labels that were used in the volume. + * + * @return \Illuminate\Support\Collection + */ + protected function getLabels() + { + return Label::query() + ->whereIn('id', function ($query) { + $query->select('label_id') + ->from('video_annotation_labels') + ->join('video_annotations', 'video_annotations.id', '=', 'video_annotation_labels.annotation_id') + ->join('videos', 'video_annotations.video_id', '=', 'videos.id') + ->where('videos.volume_id', $this->source->id); + }) + ->orWhereIn('id', function ($query) { + $query->select('label_id') + ->from('video_labels') + ->join('videos', 'video_labels.video_id', '=', 'videos.id') + ->where('videos.volume_id', $this->source->id); + }) + ->get(); + } + + /** + * Create the image-set-item entry for a video. + */ + public function processFile(Video $video) + { + // Remove annotations that should not be included because of an "onlyLabels" + // filter. + $annotations = $video->annotations->filter(fn ($a) => $a->labels->isNotEmpty()); + + $annotations = $annotations->map(function ($annotation) { + $labels = $annotation->labels->map(function ($aLabel) { + $user = $this->users->get($aLabel->user_id); + if (!in_array($user, $this->imageAnnotationCreators)) { + $this->imageAnnotationCreators[] = $user; + } + + $label = $this->labels->get($aLabel->label_id); + if (!in_array($label, $this->imageAnnotationLabels)) { + $this->imageAnnotationLabels[] = $label; + } + + if ($this->shouldConvertWormsId($label)) { + $labelId = $this->getWormsUrn($label); + } else { + $labelId = $label->id; + } + + return [ + 'label' => "$labelId", + 'annotator' => $user->uuid, + 'created-at' => $aLabel->created_at->toJson(), + ]; + }); + + return [ + 'shape' => $this->getGeometryName($annotation), + 'coordinates' => $annotation->points, + 'frames' => $annotation->frames, + 'labels' => $labels->toArray(), + ]; + }); + + $labels = $video->labels->map(function ($iLabel) { + $user = $this->users->get($iLabel->user_id); + if (!in_array($user, $this->imageAnnotationCreators)) { + $this->imageAnnotationCreators[] = $user; + } + + $label = $this->labels->get($iLabel->label_id); + if (!in_array($label, $this->imageAnnotationLabels)) { + $this->imageAnnotationLabels[] = $label; + } + + if ($this->shouldConvertWormsId($label)) { + $labelId = $this->getWormsUrn($label); + } else { + $labelId = $label->id; + } + + return [ + 'shape' => 'whole-image', + 'coordinates' => [[]], + 'frames' => [], + 'labels' => [ + [ + 'label' => "$labelId", + 'annotator' => $user->uuid, + 'created-at' => $iLabel->created_at->toJson(), + ], + ], + ]; + }); + + $this->imageSetItems[$video->filename] = []; + + // Use toBase() because the merge method of Eloquent collections works + // differently. + $videoAnnotations = $annotations->toBase()->merge($labels)->toArray(); + + if (!empty($videoAnnotations)) { + // In contrast to image items, video items should always be an array. + // However, we only fill the first array entry with this report. + $this->imageSetItems[$video->filename][] = [ + 'image-annotations' => $videoAnnotations, + ]; + } + } +} diff --git a/app/Services/Reports/Volumes/VideoLabels/CsvReportGenerator.php b/app/Services/Reports/Volumes/VideoLabels/CsvReportGenerator.php new file mode 100644 index 000000000..d4d55c040 --- /dev/null +++ b/app/Services/Reports/Volumes/VideoLabels/CsvReportGenerator.php @@ -0,0 +1,153 @@ +query()->get(); + $toZip = []; + + if ($this->shouldSeparateLabelTrees() && $rows->isNotEmpty()) { + $rows = $rows->groupBy('label_tree_id'); + $trees = LabelTree::whereIn('id', $rows->keys())->pluck('name', 'id'); + + foreach ($trees as $id => $name) { + $csv = $this->createCsv($rows->get($id)); + $this->tmpFiles[] = $csv; + $toZip[$csv->getPath()] = $this->sanitizeFilename("{$id}-{$name}", 'csv'); + } + } elseif ($this->shouldSeparateUsers() && $rows->isNotEmpty()) { + $rows = $rows->groupBy('user_id'); + $users = User::whereIn('id', $rows->keys()) + ->selectRaw("id, concat(firstname, ' ', lastname) as name") + ->pluck('name', 'id'); + + foreach ($users as $id => $name) { + $csv = $this->createCsv($rows->get($id)); + $this->tmpFiles[] = $csv; + $toZip[$csv->getPath()] = $this->sanitizeFilename("{$id}-{$name}", 'csv'); + } + } else { + $csv = $this->createCsv($rows); + $this->tmpFiles[] = $csv; + $toZip[$csv->getPath()] = $this->sanitizeFilename("{$this->source->id}-{$this->source->name}", 'csv'); + } + + $this->makeZip($toZip, $path); + } + + /** + * Assemble a new DB query for the volume of this report. + * + * @return \Illuminate\Database\Query\Builder + */ + public function query() + { + $query = DB::table('video_labels') + ->join('videos', 'video_labels.video_id', '=', 'videos.id') + ->leftJoin('users', 'video_labels.user_id', '=', 'users.id') + ->join('labels', 'labels.id', '=', 'video_labels.label_id') + ->select([ + 'video_labels.id as video_label_id', + 'video_labels.video_id', + 'videos.filename', + 'video_labels.user_id', + 'users.firstname', + 'users.lastname', + 'video_labels.label_id', + 'labels.name as label_name', + 'video_labels.created_at', + ]) + ->where('videos.volume_id', $this->source->id) + ->when($this->isRestrictedToLabels(), fn ($query) => $this->restrictToLabelsQuery($query, 'video_labels')) + ->orderBy('videos.filename'); + + if ($this->shouldSeparateLabelTrees()) { + $query->addSelect('labels.label_tree_id'); + } elseif ($this->shouldSeparateUsers()) { + $query->addSelect('video_labels.user_id'); + } + + return $query; + } + + /** + * Create a CSV file for this report. + * + * @param \Illuminate\Support\Collection $rows The rows for the CSV + * @return CsvFile + */ + protected function createCsv($rows) + { + $csv = CsvFile::makeTmp(); + // column headers + $csv->putCsv([ + 'video_label_id', + 'video_id', + 'filename', + 'user_id', + 'firstname', + 'lastname', + 'label_id', + 'label_name', + 'label_hierarchy', + 'created_at', + ]); + + foreach ($rows as $row) { + $csv->putCsv([ + $row->video_label_id, + $row->video_id, + $row->filename, + $row->user_id, + $row->firstname, + $row->lastname, + $row->label_id, + $row->label_name, + $this->expandLabelName($row->label_id), + $row->created_at, + ]); + } + + $csv->close(); + + return $csv; + } +} diff --git a/app/Services/Reports/Volumes/VolumeReportGenerator.php b/app/Services/Reports/Volumes/VolumeReportGenerator.php new file mode 100644 index 000000000..a25d4aa69 --- /dev/null +++ b/app/Services/Reports/Volumes/VolumeReportGenerator.php @@ -0,0 +1,155 @@ +pythonScriptRunner = new PythonScriptRunner; + } + + /** + * Set the Python script runner object. + * + * @param mixed $runner + */ + public function setPythonScriptRunner($runner) + { + $this->pythonScriptRunner = $runner; + } + + /** + * Constructs a label name from the names of all parent labels and the label itself. + * + * Example: `Animalia > Annelida > Polychaeta > Buskiella sp` + * + * @param int $id Label ID + * @return string + */ + public function expandLabelName($id) + { + if (is_null($this->labels)) { + // We expect most of the used labels to belong to a label tree currently + // attached to the volume (through its projects). + $this->labels = $this->getVolumeLabels()->keyBy('id'); + } + + return parent::expandLabelName($id); + } + + /** + * Get all labels that are attached to the volume of this report (through project label trees). + * + * @return \Illuminate\Support\Collection + */ + protected function getVolumeLabels() + { + return Label::select('id', 'name', 'parent_id') + ->whereIn('label_tree_id', function ($query) { + $query->select('label_tree_id') + ->from('label_tree_project') + ->whereIn('project_id', function ($query) { + $query->select('project_id') + ->from('project_volume') + ->where('volume_id', $this->source->id); + }); + }) + ->get(); + } + + /** + * Execute the external report parsing Python script. + * + * @param string $scriptName Name of the script to execute (in the `reports.scripts` config namespace) + * @param string $path Path to the file to store the generated report to + * @throws Exception If the script returned an error code. + */ + protected function executeScript($scriptName, $path) + { + $this->pythonScriptRunner->run($scriptName, $this->source->name, $path, $this->tmpFiles); + } + + /** + * Should this report be restricted an annotation session? + * + * @return bool + */ + protected function isRestrictedToAnnotationSession() + { + return !is_null($this->options->get('annotationSession', null)); + } + + /** + * Returns the annotation session this report should be restricted to. + * + * @return AnnotationSession|null + */ + protected function getAnnotationSession() + { + if (!$this->annotationSession) { + $this->annotationSession = AnnotationSession::find($this->options->get('annotationSession', null)); + } + + return $this->annotationSession; + } + + /** + * Get the name of the annotation session if it exists. + * + * @return string + */ + protected function getAnnotationSessionName() + { + $session = $this->getAnnotationSession(); + + return $session ? $session->name : $this->options->get('annotationSession', ''); + } + + /** + * Determines if this report should take only the newest label of each annotation. + * + * @return bool + */ + protected function isRestrictedToNewestLabel() + { + return $this->options->get('newestLabel', false); + } + + /** + * Callback to be used in a `when` query statement that restricts the results to a specific subset of annotation labels. + * + * @param \Illuminate\Contracts\Database\Query\Builder $query + * @param string $table Name of the annotation/image label DB table + * @return \Illuminate\Contracts\Database\Query\Builder + */ + protected function restrictToLabelsQuery($query, $table) + { + return $query->whereIn("{$table}.label_id", $this->getOnlyLabels()); + } +} diff --git a/app/Traits/RestrictsToExportArea.php b/app/Traits/RestrictsToExportArea.php new file mode 100644 index 000000000..6a8bd872a --- /dev/null +++ b/app/Traits/RestrictsToExportArea.php @@ -0,0 +1,85 @@ +whereNotIn('image_annotations.id', $this->getSkipIds()); + } + + /** + * Should this report be restricted to the export area? + * + * @return bool + */ + protected function isRestrictedToExportArea() + { + return $this->options->get('exportArea', false); + } + + /** + * Returns the annotation IDs to skip as outside of the volume export area. + * + * We collect the IDs to skip rather than the IDs to include since there are probably + * fewer annotations outside of the export area. + * + * @return array Annotation IDs + */ + protected function getSkipIds() + { + $skip = []; + $exportArea = $this->source->exportArea; + + if (!$exportArea) { + // take all annotations if no export area is specified + return $skip; + } + + $exportArea = [ + // min x + min($exportArea[0], $exportArea[2]), + // min y + min($exportArea[1], $exportArea[3]), + // max x + max($exportArea[0], $exportArea[2]), + // max y + max($exportArea[1], $exportArea[3]), + ]; + + $processAnnotation = function ($annotation) use ($exportArea, &$skip) { + $points = json_decode($annotation->points); + $size = sizeof($points); + // Works for circles with 3 elements in $points, too! + for ($x = 0, $y = 1; $y < $size; $x += 2, $y += 2) { + if ($points[$x] >= $exportArea[0] && + $points[$x] <= $exportArea[2] && + $points[$y] >= $exportArea[1] && + $points[$y] <= $exportArea[3]) { + // As long as one point of the annotation is inside the + // area, don't skip it. + return; + } + } + + $skip[] = $annotation->id; + }; + + DB::table('image_annotations') + ->join('images', 'image_annotations.image_id', '=', 'images.id') + ->where('images.volume_id', $this->source->id) + ->select('image_annotations.id as id', 'image_annotations.points') + ->eachById($processAnnotation, 500, 'image_annotations.id', 'id'); + + return $skip; + } +} diff --git a/app/Traits/RestrictsToNewestLabels.php b/app/Traits/RestrictsToNewestLabels.php new file mode 100644 index 000000000..29e190f29 --- /dev/null +++ b/app/Traits/RestrictsToNewestLabels.php @@ -0,0 +1,50 @@ +isVideoVolume()) { + $table = 'video_annotation_labels'; + + $subquery = DB::table($table) + ->selectRaw("distinct on (annotation_id) video_annotation_labels.id") + ->join('video_annotations', 'video_annotations.id', '=', 'video_annotation_labels.annotation_id') + ->join('videos', 'videos.id', '=', 'video_annotations.video_id') + ->where('volume_id', $volume->id) + ->orderBy('video_annotation_labels.annotation_id', 'desc') + ->orderBy('video_annotation_labels.id', 'desc') + ->orderBy('video_annotation_labels.created_at', 'desc'); + } else { + $table = 'image_annotation_labels'; + + $subquery = DB::table($table) + ->selectRaw("distinct on (annotation_id) image_annotation_labels.id") + ->join('image_annotations', 'image_annotations.id', '=', 'image_annotation_labels.annotation_id') + ->join('images', 'images.id', '=', 'image_annotations.image_id') + ->where('volume_id', $volume->id) + ->orderBy('image_annotation_labels.annotation_id', 'desc') + ->orderBy('image_annotation_labels.id', 'desc') + ->orderBy('image_annotation_labels.created_at', 'desc'); + } + + return $query->joinSub($subquery, 'latest_labels', fn ($join) => $join->on("{$table}.id", '=', 'latest_labels.id')); + } +} diff --git a/app/Volume.php b/app/Volume.php index 423a9210d..a2a8d7551 100644 --- a/app/Volume.php +++ b/app/Volume.php @@ -7,6 +7,7 @@ use Cache; use Carbon\Carbon; use DB; +use Exception; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -422,6 +423,36 @@ public function getCreatingAsyncAttribute() return (bool) $this->getJsonAttr('creating_async', false); } + /** + * Return the dynamic attribute for the export area. + * + * @return ?array + */ + public function getExportAreaAttribute() + { + return $this->getJsonAttr('export_area'); + } + + /** + * Set or update the dynamic attribute for the export area. + */ + public function setExportAreaAttribute(?array $value) + { + if ($value !== null) { + if (sizeof($value) !== 4) { + throw new Exception('Malformed export area coordinates!'); + } + + foreach ($value as $coordinate) { + if (!is_int($coordinate)) { + throw new Exception('Malformed export area coordinates!'); + } + } + } + + $this->setJsonAttr('export_area', $value); + } + /** * Check if the there are tiled images in this volume. * diff --git a/composer.json b/composer.json index 3e3182609..ef75bfae8 100644 --- a/composer.json +++ b/composer.json @@ -21,12 +21,15 @@ "ext-json": "*", "ext-pgsql": "*", "ext-soap": "*", + "ext-zip": "*", "biigle/laravel-file-cache": "^5.0", + "biigle/metadata-ifdo": "^1.0", "biigle/largo": "^2.36", "duncan3dc/bom-string": "^1.1", "endroid/qr-code": "^5.0", "guzzlehttp/guzzle": "^7.2", "jcupitt/vips": "^2.4", + "jmikola/geojson": "^1.0", "laravel/framework": "^11.0", "laravel/tinker": "^2.9", "laravel/ui": "^4.0", diff --git a/composer.lock b/composer.lock index 5877e4cde..8c9915a44 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ca97d33c23f33ce70c7ea103a44ded6b", + "content-hash": "b55d0bbec70a0595987d588a9c040c62", "packages": [ { "name": "bacon/bacon-qr-code", @@ -60,6 +60,56 @@ }, "time": "2024-10-01T13:55:55+00:00" }, + { + "name": "biigle/ifdo", + "version": "v1.0.1", + "source": { + "type": "git", + "url": "https://github.com/biigle/ifdo.git", + "reference": "d744b9491a5890391a40a0a1af655ed71ece1dbf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/biigle/ifdo/zipball/d744b9491a5890391a40a0a1af655ed71ece1dbf", + "reference": "d744b9491a5890391a40a0a1af655ed71ece1dbf", + "shasum": "" + }, + "require": { + "justinrainbow/json-schema": "^5.2", + "php": "^8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0 || ^11.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Biigle\\Ifdo\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-3.0-only" + ], + "authors": [ + { + "name": "Christoph Bach", + "email": "bach@intab.pro", + "role": "Developer" + } + ], + "description": "iFDO Parser Package to parse and validate the iFDO JSON Schema", + "homepage": "https://github.com/biigle/ifdo-parser", + "keywords": [ + "biigle", + "ifdo-parser" + ], + "support": { + "issues": "https://github.com/biigle/ifdo/issues", + "source": "https://github.com/biigle/ifdo/tree/v1.0.1" + }, + "time": "2024-11-07T13:46:10+00:00" + }, { "name": "biigle/laravel-file-cache", "version": "v5.0.0", @@ -166,6 +216,58 @@ }, "time": "2024-12-18T08:21:29+00:00" }, + { + "name": "biigle/metadata-ifdo", + "version": "v1.0.1", + "source": { + "type": "git", + "url": "https://github.com/biigle/metadata-ifdo.git", + "reference": "1feda4bc651045a1797c42ad1f25d3e70057601c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/biigle/metadata-ifdo/zipball/1feda4bc651045a1797c42ad1f25d3e70057601c", + "reference": "1feda4bc651045a1797c42ad1f25d3e70057601c", + "shasum": "" + }, + "require": { + "biigle/ifdo": "^0.4 || ^1.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Biigle\\Modules\\MetadataIfdo\\MetadataIfdoServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Biigle\\Modules\\MetadataIfdo\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-3.0-only" + ], + "authors": [ + { + "name": "Martin Zurowietz", + "email": "m.zurowietz@uni-bielefeld.de" + } + ], + "description": "BIIGLE module for iFDO metadata.", + "homepage": "https://biigle.de", + "keywords": [ + "biigle", + "biigle-module" + ], + "support": { + "issues": "https://github.com/biigle/metadata-ifdo/issues", + "source": "https://github.com/biigle/metadata-ifdo" + }, + "time": "2024-12-03T08:33:30+00:00" + }, { "name": "brick/math", "version": "0.12.1", @@ -1502,6 +1604,130 @@ }, "time": "2024-04-09T09:27:18+00:00" }, + { + "name": "jmikola/geojson", + "version": "1.2.0", + "source": { + "type": "git", + "url": "https://github.com/jmikola/geojson.git", + "reference": "e28f3855bb61a91aab32b74c176d76dd0b5658d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jmikola/geojson/zipball/e28f3855bb61a91aab32b74c176d76dd0b5658d7", + "reference": "e28f3855bb61a91aab32b74c176d76dd0b5658d7", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.4 || ^8.0", + "symfony/polyfill-php80": "^1.25" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "scrutinizer/ocular": "^1.8.1", + "slevomat/coding-standard": "^8.0", + "squizlabs/php_codesniffer": "^3.6" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "GeoJson\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jeremy Mikola", + "email": "jmikola@gmail.com" + } + ], + "description": "GeoJSON implementation for PHP", + "homepage": "https://github.com/jmikola/geojson", + "keywords": [ + "geo", + "geojson", + "geospatial" + ], + "support": { + "issues": "https://github.com/jmikola/geojson/issues", + "source": "https://github.com/jmikola/geojson/tree/1.2.0" + }, + "time": "2023-12-04T17:19:43+00:00" + }, + { + "name": "justinrainbow/json-schema", + "version": "5.3.0", + "source": { + "type": "git", + "url": "https://github.com/jsonrainbow/json-schema.git", + "reference": "feb2ca6dd1cebdaf1ed60a4c8de2e53ce11c4fd8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/feb2ca6dd1cebdaf1ed60a4c8de2e53ce11c4fd8", + "reference": "feb2ca6dd1cebdaf1ed60a4c8de2e53ce11c4fd8", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "~2.2.20||~2.15.1", + "json-schema/json-schema-test-suite": "1.2.0", + "phpunit/phpunit": "^4.8.35" + }, + "bin": [ + "bin/validate-json" + ], + "type": "library", + "autoload": { + "psr-4": { + "JsonSchema\\": "src/JsonSchema/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bruno Prieto Reis", + "email": "bruno.p.reis@gmail.com" + }, + { + "name": "Justin Rainbow", + "email": "justin.rainbow@gmail.com" + }, + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + }, + { + "name": "Robert Schönthal", + "email": "seroscho@googlemail.com" + } + ], + "description": "A library to validate a json schema.", + "homepage": "https://github.com/justinrainbow/json-schema", + "keywords": [ + "json", + "schema" + ], + "support": { + "issues": "https://github.com/jsonrainbow/json-schema/issues", + "source": "https://github.com/jsonrainbow/json-schema/tree/5.3.0" + }, + "time": "2024-07-06T21:00:26+00:00" + }, { "name": "laravel/framework", "version": "v11.34.1", @@ -10340,7 +10566,8 @@ "ext-exif": "*", "ext-json": "*", "ext-pgsql": "*", - "ext-soap": "*" + "ext-soap": "*", + "ext-zip": "*" }, "platform-dev": [], "platform-overrides": { diff --git a/config/filesystems.php b/config/filesystems.php index c150663cb..96c1d2f83 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -83,6 +83,11 @@ 'driver' => 'local', 'root' => storage_path('imports'), ], + + 'reports' => [ + 'driver' => 'local', + 'root' => storage_path('reports'), + ], ], /* diff --git a/config/reports.php b/config/reports.php new file mode 100644 index 000000000..2e7ce4ffe --- /dev/null +++ b/config/reports.php @@ -0,0 +1,49 @@ + '/usr/bin/python3', + + /* + | Paths to the python scripts. + */ + 'scripts' => [ + 'basic_report' => __DIR__.'/../resources/scripts/reports/basic_report.py', + 'csvs_to_xlsx' => __DIR__.'/../resources/scripts/reports/csvs_to_xlsx.py', + 'full_report' => __DIR__.'/../resources/scripts/reports/full_report.py', + 'to_coco' => __DIR__.'/../resources/scripts/reports/to_coco.py', + ], + + /** + * Storage disk to store the report files to. + */ + 'storage_disk' => env('REPORTS_STORAGE_DISK', 'reports'), + + /* + | Directory to store temporary files to + */ + 'tmp_storage' => sys_get_temp_dir(), + + 'notifications' => [ + /* + | Set the way notifications for new reports are sent by default. + | + | Available are: "email", "web" + */ + 'default_settings' => 'email', + + /* + | Choose whether users are allowed to change their notification settings. + | If set to false the default settings will be used for all users. + */ + 'allow_user_settings' => true, + ], + + /* + | Specifies which queue should be used for which job. + */ + 'generate_report_queue' => env('REPORTS_GENERATE_REPORT_QUEUE', 'high'), +]; diff --git a/database/factories/ReportFactory.php b/database/factories/ReportFactory.php new file mode 100644 index 000000000..cac47ed5c --- /dev/null +++ b/database/factories/ReportFactory.php @@ -0,0 +1,34 @@ + User::factory(), + 'type_id' => fn () => ReportType::imageAnnotationsCsvId(), + 'source_id' => Volume::factory(), + 'source_type' => Volume::class, + ]; + } +} diff --git a/database/factories/ReportTypeFactory.php b/database/factories/ReportTypeFactory.php new file mode 100644 index 000000000..e496159e3 --- /dev/null +++ b/database/factories/ReportTypeFactory.php @@ -0,0 +1,28 @@ + $this->faker->username(), + ]; + } +} diff --git a/database/migrations/2017_06_09_092100_create_reports_table.php b/database/migrations/2017_06_09_092100_create_reports_table.php new file mode 100644 index 000000000..51b87293a --- /dev/null +++ b/database/migrations/2017_06_09_092100_create_reports_table.php @@ -0,0 +1,72 @@ +increments('id'); + $table->string('name', 128)->index(); + $table->unique('name'); + }); + + // Must be compatible with the namespaces of the report generators in + // \Biigle\Modules\Reports\Support\Reports. + DB::table('report_types')->insert([ + ['name' => 'Annotations\Area'], + ['name' => 'Annotations\Basic'], + ['name' => 'Annotations\Csv'], + ['name' => 'Annotations\Extended'], + ['name' => 'Annotations\Full'], + ['name' => 'ImageLabels\Basic'], + ['name' => 'ImageLabels\Csv'], + ]); + + Schema::create('reports', function (Blueprint $table) { + $table->increments('id'); + + $table->unsignedInteger('user_id'); + $table->foreign('user_id') + ->references('id') + ->on('users') + ->onDelete('cascade'); + + $table->unsignedInteger('type_id'); + $table->foreign('type_id') + ->references('id') + ->on('report_types') + ->onDelete('restrict'); + + // Columns for the polymorphic relationship to either volumes or projects. + $table->morphs('source'); + + // Store the source name so can still be displayed even if the source has + // been deleted. + $table->string('source_name'); + + $table->json('options')->nullable(); + + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('reports'); + Schema::drop('report_types'); + } +} diff --git a/database/migrations/2018_01_08_093041_add_reports_ready_at_column.php b/database/migrations/2018_01_08_093041_add_reports_ready_at_column.php new file mode 100644 index 000000000..6edf525cc --- /dev/null +++ b/database/migrations/2018_01_08_093041_add_reports_ready_at_column.php @@ -0,0 +1,37 @@ +timestamp('ready_at')->nullable(); + }); + + // Set ready_at of existing reports to equal updated_at. + DB::table('reports') + ->whereNull('ready_at') + ->update(['ready_at' => DB::raw('updated_at')]); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('reports', function (Blueprint $table) { + $table->dropColumn('ready_at'); + }); + } +} diff --git a/database/migrations/2019_03_04_103345_add_video_annotation_report_type.php b/database/migrations/2019_03_04_103345_add_video_annotation_report_type.php new file mode 100644 index 000000000..f02afdd21 --- /dev/null +++ b/database/migrations/2019_03_04_103345_add_video_annotation_report_type.php @@ -0,0 +1,30 @@ +insert([ + ['name' => 'VideoAnnotations\Csv'], + ]); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + DB::table('report_types') + ->where('name', 'VideoAnnotations\Csv') + ->delete(); + } +} diff --git a/database/migrations/2019_09_04_142200_add_abundance_report_type.php b/database/migrations/2019_09_04_142200_add_abundance_report_type.php new file mode 100644 index 000000000..5031d6726 --- /dev/null +++ b/database/migrations/2019_09_04_142200_add_abundance_report_type.php @@ -0,0 +1,30 @@ +insert([ + ['name' => 'Annotations\Abundance'], + ]); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + DB::table('report_types') + ->where('name', 'Annotations\Abundance') + ->delete(); + } +} diff --git a/database/migrations/2020_07_11_135400_rename_video_source_type.php b/database/migrations/2020_07_11_135400_rename_video_source_type.php new file mode 100644 index 000000000..06842bace --- /dev/null +++ b/database/migrations/2020_07_11_135400_rename_video_source_type.php @@ -0,0 +1,29 @@ +update(['source_type' => 'Biigle\Video']); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Report::where('source_type', 'Biigle\Video') + ->update(['source_type' => 'Biigle\Modules\Videos\Video']); + } +} diff --git a/database/migrations/2020_07_31_155800_rename_report_types.php b/database/migrations/2020_07_31_155800_rename_report_types.php new file mode 100644 index 000000000..f7379c5bc --- /dev/null +++ b/database/migrations/2020_07_31_155800_rename_report_types.php @@ -0,0 +1,59 @@ +update(['name' => 'ImageAnnotations\Area']); + + ReportType::where('name', 'Annotations\Basic') + ->update(['name' => 'ImageAnnotations\Basic']); + + ReportType::where('name', 'Annotations\Csv') + ->update(['name' => 'ImageAnnotations\Csv']); + + ReportType::where('name', 'Annotations\Extended') + ->update(['name' => 'ImageAnnotations\Extended']); + + ReportType::where('name', 'Annotations\Full') + ->update(['name' => 'ImageAnnotations\Full']); + + ReportType::where('name', 'Annotations\Abundance') + ->update(['name' => 'ImageAnnotations\Abundance']); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + ReportType::where('name', 'ImageAnnotations\Area') + ->update(['name' => 'Annotations\Area']); + + ReportType::where('name', 'ImageAnnotations\Basic') + ->update(['name' => 'Annotations\Basic']); + + ReportType::where('name', 'ImageAnnotations\Csv') + ->update(['name' => 'Annotations\Csv']); + + ReportType::where('name', 'ImageAnnotations\Extended') + ->update(['name' => 'Annotations\Extended']); + + ReportType::where('name', 'ImageAnnotations\Full') + ->update(['name' => 'Annotations\Full']); + + ReportType::where('name', 'ImageAnnotations\Abundance') + ->update(['name' => 'Annotations\Abundance']); + } +} diff --git a/database/migrations/2020_08_06_085100_add_video_label_report_type.php b/database/migrations/2020_08_06_085100_add_video_label_report_type.php new file mode 100644 index 000000000..535f1d36b --- /dev/null +++ b/database/migrations/2020_08_06_085100_add_video_label_report_type.php @@ -0,0 +1,30 @@ +insert([ + ['name' => 'VideoLabels\Csv'], + ]); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + DB::table('report_types') + ->where('name', 'VideoLabels\Csv') + ->delete(); + } +} diff --git a/database/migrations/2020_12_15_142500_add_location_report_types.php b/database/migrations/2020_12_15_142500_add_location_report_types.php new file mode 100644 index 000000000..70097871e --- /dev/null +++ b/database/migrations/2020_12_15_142500_add_location_report_types.php @@ -0,0 +1,36 @@ +insert([ + ['name' => 'ImageLabels\ImageLocation'], + ['name' => 'ImageAnnotations\ImageLocation'], + ['name' => 'ImageAnnotations\AnnotationLocation'], + ]); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + DB::table('report_types') + ->whereIn('name', [ + 'ImageLabels\ImageLocation', + 'ImageAnnotations\ImageLocation', + 'ImageAnnotations\AnnotationLocation', + ]) + ->delete(); + } +} diff --git a/database/migrations/2022_02_10_123200_add_image_ifdo_report_type.php b/database/migrations/2022_02_10_123200_add_image_ifdo_report_type.php new file mode 100644 index 000000000..aed19a4a0 --- /dev/null +++ b/database/migrations/2022_02_10_123200_add_image_ifdo_report_type.php @@ -0,0 +1,30 @@ +insert([ + ['name' => 'ImageIfdo'], + ]); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + DB::table('report_types') + ->where('name', 'ImageIfdo') + ->delete(); + } +} diff --git a/database/migrations/2022_04_07_111600_add_video_ifdo_report_type.php b/database/migrations/2022_04_07_111600_add_video_ifdo_report_type.php new file mode 100644 index 000000000..3c7a049a8 --- /dev/null +++ b/database/migrations/2022_04_07_111600_add_video_ifdo_report_type.php @@ -0,0 +1,29 @@ +insert([ + ['name' => 'VideoIfdo'], + ]); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + DB::table('report_types') + ->where('name', 'VideoIfdo') + ->delete(); + } +}; diff --git a/database/migrations/2022_04_14_161000_add_report_file_extensions.php b/database/migrations/2022_04_14_161000_add_report_file_extensions.php new file mode 100644 index 000000000..23998b7dd --- /dev/null +++ b/database/migrations/2022_04_14_161000_add_report_file_extensions.php @@ -0,0 +1,55 @@ +getReportGenerator(); + try { + $disk->move($report->id, $report->id.'.'.$generator->extension); + } catch (FilesystemOperationFailed $e) { + // ignore missing report files and continue + } + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + try { + $disk = Storage::disk(config('reports.storage_disk')); + } catch (Exception $e) { + // Do not migrate if storage disk is not configured. + return; + } + + Report::eachById(function ($report) use ($disk) { + $generator = $report->getReportGenerator(); + try { + $disk->move($report->id.'.'.$generator->extension, $report->id); + } catch (FilesystemOperationFailed $e) { + // ignore missing report files and continue + } + }); + } +}; diff --git a/database/migrations/2022_07_07_153500_add_image_coco_report_type.php b/database/migrations/2022_07_07_153500_add_image_coco_report_type.php new file mode 100644 index 000000000..d7dea7325 --- /dev/null +++ b/database/migrations/2022_07_07_153500_add_image_coco_report_type.php @@ -0,0 +1,30 @@ +insert( + ['name' => 'ImageAnnotations\Coco'], + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + DB::table('report_types') + ->where('name', 'ImageAnnotations\Coco') + ->delete(); + } +} diff --git a/resources/assets/js/annotations/api/exportArea.js b/resources/assets/js/annotations/api/exportArea.js new file mode 100644 index 000000000..71c1db590 --- /dev/null +++ b/resources/assets/js/annotations/api/exportArea.js @@ -0,0 +1,16 @@ +/** + * Resource for editing the export area of a volume + * + * let resource = biigle.$require('reports.api.volumes'); + * + * Get the export area: + * resource.get({id: volumeId}).then(...); + * + * Create/update an export area: + * resource.save({id: volumeId}, {coordinates: [10, 10, 100, 100]}).then(...); + * + * Delete the export area: + * resource.delete({id: columeId}).then(...); + * + */ +export default Vue.resource('/api/v1/volumes{/id}/export-area'); diff --git a/resources/assets/js/annotations/components/exportArea.vue b/resources/assets/js/annotations/components/exportArea.vue new file mode 100644 index 000000000..367e2e860 --- /dev/null +++ b/resources/assets/js/annotations/components/exportArea.vue @@ -0,0 +1,240 @@ + diff --git a/resources/assets/js/annotations/components/settingsTab.vue b/resources/assets/js/annotations/components/settingsTab.vue index 1dcf56d1f..71056c1c5 100644 --- a/resources/assets/js/annotations/components/settingsTab.vue +++ b/resources/assets/js/annotations/components/settingsTab.vue @@ -3,6 +3,7 @@ import Keyboard from '../../core/keyboard'; import PowerToggle from '../../core/components/powerToggle'; import ScreenshotButton from './screenshotButton'; import Settings from '../stores/settings'; +import ExportArea from './exportArea'; /** * Additional components that can be dynamically added by other Biigle modules via @@ -22,6 +23,7 @@ export default { components: { screenshotButton: ScreenshotButton, powerToggle: PowerToggle, + exportArea: ExportArea, }, props: { image: { diff --git a/resources/assets/js/main.js b/resources/assets/js/main.js index 6ee849c79..4a9bc1e71 100644 --- a/resources/assets/js/main.js +++ b/resources/assets/js/main.js @@ -8,3 +8,4 @@ import './volumes/main'; import './annotations/main'; import './videos/main'; import './sync/main'; +import './reports/main'; diff --git a/resources/assets/js/reports/api/projectReports.js b/resources/assets/js/reports/api/projectReports.js new file mode 100644 index 000000000..90ed04737 --- /dev/null +++ b/resources/assets/js/reports/api/projectReports.js @@ -0,0 +1,15 @@ +/** + * Resource for requesting reports for projects + * + * let resource = biigle.$require('reports.api.projectReports'); + * + * Request a basic annotation report: + * + * resource.save({id: 1}, { + * type_id: 2, + * export_area: 1, + * separate_label_trees: 0, + * }).then(...) + * + */ +export default Vue.resource('/api/v1/projects{/id}/reports'); diff --git a/resources/assets/js/reports/api/volumeReports.js b/resources/assets/js/reports/api/volumeReports.js new file mode 100644 index 000000000..779047f17 --- /dev/null +++ b/resources/assets/js/reports/api/volumeReports.js @@ -0,0 +1,16 @@ +/** + * Resource for requesting reports for volumes + * + * let resource = biigle.$require('reports.api.volumeReports'); + * + * Request a basic annotation report: + * + * resource.save({id: 1}, { + * type_id: 2, + * export_area: 1, + * separate_label_trees: 0, + * annotation_session_id: 23, + * }).then(...) + * + */ +export default Vue.resource('/api/v1/volumes{/id}/reports'); diff --git a/resources/assets/js/reports/main.js b/resources/assets/js/reports/main.js new file mode 100644 index 000000000..cad963ca7 --- /dev/null +++ b/resources/assets/js/reports/main.js @@ -0,0 +1,5 @@ +import ProjectForm from './projectForm'; +import VolumeForm from './volumeForm'; + +biigle.$mount('project-report-form', ProjectForm); +biigle.$mount('volume-report-form', VolumeForm); diff --git a/resources/assets/js/reports/mixins/reportForm.vue b/resources/assets/js/reports/mixins/reportForm.vue new file mode 100644 index 000000000..dddf274a9 --- /dev/null +++ b/resources/assets/js/reports/mixins/reportForm.vue @@ -0,0 +1,192 @@ + diff --git a/resources/assets/js/reports/projectForm.vue b/resources/assets/js/reports/projectForm.vue new file mode 100644 index 000000000..780b92c82 --- /dev/null +++ b/resources/assets/js/reports/projectForm.vue @@ -0,0 +1,62 @@ + diff --git a/resources/assets/js/reports/volumeForm.vue b/resources/assets/js/reports/volumeForm.vue new file mode 100644 index 000000000..5fd4bb0e4 --- /dev/null +++ b/resources/assets/js/reports/volumeForm.vue @@ -0,0 +1,67 @@ + diff --git a/resources/assets/sass/_reports.scss b/resources/assets/sass/_reports.scss new file mode 100644 index 000000000..195092afd --- /dev/null +++ b/resources/assets/sass/_reports.scss @@ -0,0 +1,5 @@ +.request-labels-well { + // Set absolute height so the flex positioning and scroll of the label trees + // component works (fixed search field and scrollable label trees list). + height: 500px; +} diff --git a/resources/assets/sass/main.scss b/resources/assets/sass/main.scss index 2a1318631..53d680685 100644 --- a/resources/assets/sass/main.scss +++ b/resources/assets/sass/main.scss @@ -56,4 +56,5 @@ $font-family-monospace: monospace; @import "videos/main"; @import "volumes/main"; @import "sync/main"; +@import "reports"; @import "vue"; diff --git a/resources/scripts/reports/basic_report.py b/resources/scripts/reports/basic_report.py new file mode 100755 index 000000000..0237fa341 --- /dev/null +++ b/resources/scripts/reports/basic_report.py @@ -0,0 +1,73 @@ +import matplotlib +matplotlib.use('Agg') +import matplotlib.pyplot as plt +from matplotlib.backends.backend_pdf import PdfPages +# import matplotlib.image as mpimg +import datetime +import sys +import numpy as np +import csv + +# See: https://github.com/biigle/reports/issues/79 +csv.field_size_limit(sys.maxsize) + +title = sys.argv[1] +target_file = sys.argv[2] +data_csvs = sys.argv[3:] + +def TitleSlide(text): + fig = plt.figure(figsize=(10, 4)) + plt.subplot2grid((3, 3), (0, 0), colspan=3) + mid = plt.subplot2grid((3, 3), (0, 0), colspan=3) + mid.axis('off') + btleft = plt.subplot2grid((3, 3), (2, 0)) + btleft.axis('off') + btmid = plt.subplot2grid((3, 3), (2, 1)) + btmid.axis('off') + btright = plt.subplot2grid((3, 3), (2, 2)) + btright.axis('off') + mid.text(0.5, 0.5, text, fontsize=15, horizontalalignment='center') + btmid.text(0.423, 0.5, datetime.date.today(), fontsize=9) + return fig + +pdf = PdfPages(target_file) +fig = TitleSlide("BIIGLE basic report for volume\n" + title) +pdf.savefig(fig) +width = 1. + +for path in data_csvs: + f = open(path, 'r') + data_csv = csv.reader(f) + plot_title = next(data_csv) + rows = np.array(list(data_csv), dtype=str) + f.close() + if rows.shape[0] == 0: + continue + # rows have the content: label_name, label_color, label_count + counts = rows[:, 2].astype(int) + ind = np.arange(rows.shape[0]) + + fig, ax = plt.subplots(figsize=(10, 6)) + fig.subplots_adjust(bottom=0.33) + + # '#'-characters to prepend to the hex color codes + hashes = np.full(rows.shape[0], '#', dtype=str) + + ax.bar(ind, counts, width, color=np.char.add(hashes, rows[:, 1]), log=counts.max() > 100) + + ax.set_xticks(ind + width / 2) + labels = [label for label in rows[:, 0]] + ax.set_xticklabels(labels, rotation=45, fontsize=8, ha = 'right') + if plot_title: + plt.title(plot_title[0]) + plt.xlim([0, ind.size]) + pdf.savefig() + +d = pdf.infodict() +d['Title'] = "BIIGLE basic report for volume " + title +d['Author'] = 'Biodata Mining Group, Bielefeld University' +d['Subject'] = 'Histogram of label distribution of the volume' +d['Keywords'] = '' +d['CreationDate'] = datetime.datetime.today() +d['ModDate'] = datetime.datetime.today() +pdf.close() diff --git a/resources/scripts/reports/csvs_to_xlsx.py b/resources/scripts/reports/csvs_to_xlsx.py new file mode 100755 index 000000000..aafdc1d14 --- /dev/null +++ b/resources/scripts/reports/csvs_to_xlsx.py @@ -0,0 +1,35 @@ +import sys +from pyexcelerate import Workbook, Style, Font +import csv + +# See: https://github.com/biigle/reports/issues/79 +csv.field_size_limit(sys.maxsize) + +target_file = sys.argv[2] +csvs = sys.argv[3:] + +workbook = Workbook() +numSheets = 0 + +for path in csvs: + f = open(path, 'r') + rows = list(csv.reader(f)) + f.close() + # Volume name is the first row, column titles are in the second row. + # So if we only have two rows, the volume is empty. + if len(rows) == 2: + continue + numSheets += 1 + # rows have the content: image_filename, label_name, label_count + + # Excel does not permit worksheet names longer than 31 characters + ws = workbook.new_sheet("sheet " + str(numSheets), data=rows) + + # bold font for titles + ws.set_row_style(1, Style(font=Font(bold=True))) + ws.set_row_style(2, Style(font=Font(bold=True))) + +if not numSheets: + ws = workbook.new_sheet("No labels found", data=[['No labels found']]) + +workbook.save(target_file) diff --git a/resources/scripts/reports/full_report.py b/resources/scripts/reports/full_report.py new file mode 100644 index 000000000..45bddbf6d --- /dev/null +++ b/resources/scripts/reports/full_report.py @@ -0,0 +1,63 @@ +import sys +from pyexcelerate import Workbook, Style, Font +import csv +import json + +# See: https://github.com/biigle/reports/issues/79 +csv.field_size_limit(sys.maxsize) + +target_file = sys.argv[2] +csvs = sys.argv[3:] + +workbook = Workbook() +numSheets = 0 + +def add_sheet(csv_path, index): + with open(csv_path, 'r') as f: + csv_reader = csv.reader(f) + + csv_title = next(csv_reader)[0] + csv_column_labels = next(csv_reader) + + + images = {} + + for row in csv_reader: + if row[0] not in images: + images[row[0]] = { + 'annotations': {}, + 'area': row[5], + } + + image = images[row[0]] + + if row[1] not in image['annotations']: + image['annotations'][row[1]] = { + 'id': row[1], + 'shape': row[3], + 'points': json.loads(row[4]), + 'labels': [], + } + + image['annotations'][row[1]]['labels'].append(row[2]) + + # rows have the content: image_filename, annotation_id, label_name, shape_name, points, image area + celldata = [[csv_title], csv_column_labels] + + for filename in sorted(images): + image = images[filename] + for annotation_id, annotation in image['annotations'].items(): + points = iter(annotation['points']) + labels = ' ,'.join(annotation['labels']) + celldata.append([filename, annotation_id, annotation['shape'], next(points), next(points), labels, image['area']]) + for point in points: + celldata.append(['', '', '', point, next(points, ''), '', '']) + + ws = workbook.new_sheet("sheet {}".format(index), data=celldata) + ws.set_row_style(1, Style(font=Font(bold=True))) + ws.set_row_style(2, Style(font=Font(bold=True))) + +for i, csv_path in enumerate(csvs): + add_sheet(csv_path, i) + +workbook.save(target_file) diff --git a/resources/scripts/reports/to_coco.py b/resources/scripts/reports/to_coco.py new file mode 100644 index 000000000..9944f382d --- /dev/null +++ b/resources/scripts/reports/to_coco.py @@ -0,0 +1,143 @@ +import pandas as pd +import numpy +from shapely.geometry import Point +from shapely.affinity import scale, rotate +import warnings +import sys +import math +import json +import ast + +# the path to the CSV file +paths = sys.argv[2:] + + +def check_shape_and_attributes(row): + # check that only accepted shapes are passed on + shape = row.shape_name + attrs = row.attributes + valid = True + if not(shape == "LineString" or shape == "Polygon" or shape == "Rectangle" or shape == "Circle" or shape == "Ellipse"): + warnings.warn('The shape %s is not supported !' % (shape)) + valid = False + try: + desired_dict=json.loads(attrs) + desired_dict["height"] + desired_dict["width"] + except TypeError: + warnings.warn('Attributes of %s cannot be read! It might be empty.'% (row.filename)) + valid = False + except KeyError: + warnings.warn('Height or width of %s is not listed as an attribute. If you added the file recently please try again later. Otherwise the file may be corrupt.'%(row.filename)) + valid = False + return valid + + +def image(row): + image = {} + # row.attributes is a string, we want to transform it in a dict + desired_dict = json.loads(row.attributes) + image["height"] = desired_dict["height"] + image["width"] = desired_dict["width"] + image["id"] = int(row.image_id) + image["file_name"] = row.filename + image["longitude"] = float(row.image_longitude) if not math.isnan( + row.image_longitude) else None + image["latitude"] = float(row.image_latitude) if not math.isnan( + row.image_latitude) else None + return image + + +def category(row): + category = {} + category["id"] = row.label_id + category["name"] = row.label_name + return category + + +def annotation(row): + annotation = {} + desired_array = ast.literal_eval(row.points) + # convert Circle to Polygon using shapely + if row.shape_name == "Circle": + x, y, r = desired_array + r = max(r, 1) # catch case for zero or negative raidus + circlePolygon = Point(x, y).buffer(r) + desired_array = numpy.array( + list(zip(*circlePolygon.exterior.coords.xy))).flatten().astype(float).tolist() + # convert Ellipse to Polygon using shapely + elif row.shape_name == "Ellipse": + m1x, m1y, mi1x, mi1y, m2x, m2y, mi2x, mi2y = desired_array + x = (m1x+mi1x+m2x+mi2x)/4 + y = (m1y+mi1y+m2y+mi2y)/4 + lm = math.sqrt((m1x-m2x)**2+(m1y-m2y)**2)/2 + lmi = math.sqrt((mi1x-mi2x)**2+(mi1y-mi2y)**2)/2 + angle=0 + if m1x-m2x==0: + angle=math.pi/2 + else: + angle = math.atan((m1y-m2y)/(m1x-m2x)) + # create Circle and... + circlePolygon = Point(x, y).buffer(1) + # scale it to an ellipse and... + circlePolygon = scale(circlePolygon, lm, lmi) + # rotate it. + circlePolygon = rotate(circlePolygon, angle, use_radians=True) + desired_array = numpy.array( + list(zip(*circlePolygon.exterior.coords.xy))).flatten().astype(float).tolist() + # convert Rectangle to Polygon + elif row.shape_name == "Rectangle": + # Rectangle only has 4 points as it does not close the polygon. Add the first point again so that it gets closed + desired_array.extend(desired_array[:2]) + + # x = even - start at the beginning at take every second item + x_coord = desired_array[::2] + # y = odd - start at second item and take every second item + y_coord = desired_array[1::2] + xmax = max(x_coord) + ymax = max(y_coord) + xmin = min(x_coord) + ymin = min(y_coord) + area = (xmax - xmin)*(ymax - ymin) + annotation["segmentation"] = [desired_array] + annotation["iscrowd"] = 0 + annotation["area"] = area + annotation["image_id"] = int(row.image_id) + annotation["bbox"] = [xmin, ymin, xmax - xmin, ymax-ymin] + annotation["category_id"] = row.label_id + annotation["id"] = int(row.annotation_label_id) + return annotation + + +for path in paths: + data = pd.read_csv(path) + # delete rows with not supported shapes + for index, row in data.iterrows(): + if not check_shape_and_attributes(row): + data.drop(index, inplace=True) + + images = [] + categories = [] + annotations = [] + + data['fileid'] = data['filename'].astype('category').cat.codes + data['categoryid'] = pd.Categorical(data['label_id'], ordered=True).codes + data['categoryid'] = data['categoryid']+1 + imagedf = data.drop_duplicates(subset=['fileid']).sort_values(by='fileid') + catdf = data.drop_duplicates( + subset=['categoryid']).sort_values(by='categoryid') + + for row in data.itertuples(): + annotations.append(annotation(row)) + + for row in imagedf.itertuples(): + images.append(image(row)) + + for row in catdf.itertuples(): + categories.append(category(row)) + + data_coco = {} + data_coco["images"] = images + data_coco["categories"] = categories + data_coco["annotations"] = annotations + pretty_json = json.dump(data_coco, open(path, "w"), indent=4) diff --git a/resources/views/annotations/show.blade.php b/resources/views/annotations/show.blade.php index cca57c664..f16cf010b 100644 --- a/resources/views/annotations/show.blade.php +++ b/resources/views/annotations/show.blade.php @@ -19,7 +19,7 @@ biigle.$declare('annotations.isEditor', @can('add-annotation', $image) true @else false @endcan); biigle.$declare('annotations.userId', {!! $user->id !!}); biigle.$declare('annotations.isAdmin', @can('update', $volume) true @else false @endcan); - + biigle.$declare('annotations.exportArea', {!! json_encode($volume->exportArea) !!}); @mixin('annotationsScripts') @endpush diff --git a/resources/views/annotations/show/tabs/settings.blade.php b/resources/views/annotations/show/tabs/settings.blade.php index a9fece479..598a0341a 100644 --- a/resources/views/annotations/show/tabs/settings.blade.php +++ b/resources/views/annotations/show/tabs/settings.blade.php @@ -46,6 +46,19 @@ Measure Tooltip + + + + @mixin('annotationsSettingsTab') diff --git a/resources/views/manual/index.blade.php b/resources/views/manual/index.blade.php index f9d4edd38..75db8e481 100644 --- a/resources/views/manual/index.blade.php +++ b/resources/views/manual/index.blade.php @@ -233,6 +233,32 @@ Advanced configuration of the video annotation tool.

+

Reports

+

+ Reports schema +

+ +

+ A description of the file formats of the different available reports. +

+ +

+ Image location reports +

+ +

+ A detailed description of image location reports with a short introduction to QGIS. +

+ +

+ Annotation location reports +

+ +

+ A detailed description of the annotation position estimation of the annotation location report. +

+ + @mixin('manualTutorial') diff --git a/resources/views/manual/tutorials/annotations/sidebar.blade.php b/resources/views/manual/tutorials/annotations/sidebar.blade.php index 999e99ad4..5b6c54d56 100644 --- a/resources/views/manual/tutorials/annotations/sidebar.blade.php +++ b/resources/views/manual/tutorials/annotations/sidebar.blade.php @@ -151,6 +151,12 @@ +

Export Area

+ +

+ The export area can be used to restrict generated reports to a specific area of the images of a volume. Click edit to draw or edit the export area. The area can be drawn similar to a rectangle annotation and will be permanently displayed on all images of the volume. If a report is set to be restricted to the export area, only image annotations inside this area will be considered. Delete the export area with the delete button. Set the export area opacity to 0 to hide it. +

+ @mixin('annotationsManualSidebarSettings') @endsection diff --git a/resources/views/manual/tutorials/reports/annotation-location-reports.blade.php b/resources/views/manual/tutorials/reports/annotation-location-reports.blade.php new file mode 100644 index 000000000..385a69853 --- /dev/null +++ b/resources/views/manual/tutorials/reports/annotation-location-reports.blade.php @@ -0,0 +1,121 @@ +@extends('manual.base') + +@section('manual-title') Annotation location reports @stop + +@section('manual-content') +
+

+ A detailed description of the annotation position estimation of the annotation location report. +

+

+ The annotation location report contains estimated annotation positions on a world map. The report file format is newline delimited GeoJSON which can be imported in a GIS software such as QGIS. +

+

+ The annotation location report requires several fields for image metadata to compute the estimated annotation positions. In addition, the positions are computed based on a number of assumptions which are described below. +

+ +
+
+ The annotation positions are only an estimate and will likely not reflect the actual real-world positions! +
+
+ +

Required metadata

+ +

+ The annotation location report requires the following image metadata fields. Images or volumes where this information is not available will be ignored for the report. +

+ +
    +
  • latitude and longitude
  • +
  • distance to ground
  • +
  • yaw
  • +
  • width and height1
  • +
+ +

+ 1The image width and height is determined automatically by BIIGLE when a new volume is created. It may take a few minutes for all images to be processed. +

+ +

Assumptions

+ +

+ The estimated annotation positions are calculated based on the following assumptions. Please bear in mind that these assumptions are never 100% met in a real environment and therefore the annotation positions are just estimates. +

+ +
    +
  1. + Image coordinates: The image latitude and longitude is assumed to specify the position of the image center. +
  2. +
  3. + Camera opening angle: The camera opening angle is assumed to be 90°. +
  4. +
  5. + Camera orientation: The camera is assumed to point straight down to the ground. +
  6. +
  7. + Yaw: A yaw of 0° is assumed to point north, 90° to point east. +
  8. +
  9. + Pixel shape: A pixel of the image is assumed to have a square shape. +
  10. +
+ +

Position estimation

+ +

+ The annotation position estimation is performed in multiple steps: +

+ +
    +
  1. +

    + The annotation position relative to the image center is calculated (annotation offset) and then rotated according to the yaw around the image center. +

    +
  2. +
  3. +

    + The annotation offset in pixels is transformed to the offset in meters. For this, the assumptions about the camera opening angle and orientation are used. If the opening angle is 90° and the camera points straight down, the width of the image content can be assumed to be twice the distance of the camera to the sea floor (as the camera viewport is a right-angled triangle). The image width in meters determines the width of a single pixel (which is assumed to be a square) in meters, which in turn is used to transform the annotation offset to meters. +

    +
  4. +
  5. +

    + The final annotation position on the world map is determined by shifting the image center latitude/longitude coordinates by the previously calculated offset of the annotation in meters. The coordinate shift is calculated using the following simplified flat earth calculation in pseudo code (reference): +

    +
    +// Position, decimal degrees.
    +lat
    +lon
    +
    +// Offsets in meters (north, east).
    +dn
    +de
    +
    +// Earth's radius, sphere.
    +R = 6378137
    +
    +// Coordinate offsets in radians.
    +dLat = dn / R
    +dLon = de / (R * COS( PI * lat / 180 ))
    +
    +// Offset position, decimal degrees.
    +latO = lat + dLat * 180 / PI
    +lonO = lon + dLon * 180 / PI
    +
    +

    + If the previously mentioned assumptions are met, the displacement error introduced by this calculation should be quite small, as the offset from the image center position should be very small as well. +

    +
  6. +
+ +

Filtering in QGIS

+ +

+ Filtering of an annotation location report is done in the same way than filtering of an image annotation image location report. However, the annotation location report contains a different and fixed set of properties for each annotation. The most important properties are probably "_label_name" and "_label_id", which allow you to filter the annotation positions based on the label that is attached to the annotation. For example, a query to show only positions of annotations that have the "Sponge" label attached may look like this: +

+
"_label_name" = 'Sponge'
+

+ Please note the different use of "" to enclose a field identifier and '' to enclose a fixed string. +

+
+@endsection diff --git a/resources/views/manual/tutorials/reports/image-location-reports.blade.php b/resources/views/manual/tutorials/reports/image-location-reports.blade.php new file mode 100644 index 000000000..10d83a5d5 --- /dev/null +++ b/resources/views/manual/tutorials/reports/image-location-reports.blade.php @@ -0,0 +1,82 @@ +@extends('manual.base') + +@section('manual-title') Image location reports @stop + +@section('manual-content') +
+

+ A detailed description of image location reports with a short introduction to QGIS. +

+

+ Image location reports contain image positions as points on a world map. The report file format is newline delimited GeoJSON which can be imported in a GIS software such as QGIS. +

+ +

+ The image location reports require image metadata for latitude and longitude coordinates which are either automatically obtained by BIIGLE or manually provided. BIIGLE expects these coordinates to be provided in the EPSG:4326 coordinate reference system. Read more on image metadata here. +

+ +

+ Each report includes information on annotation or image labels for each image as properties of a GeoJSON feature. These properties can be used to filter or style the feature display in a GIS. Below you can find some examples using QGIS version 3.10. +

+ +

Import GeoJSON in QGIS

+ +

+ To import an image location report in QGIS, add it as a new vector layer. Press Ctrl+Shift+V to open the data source manager for a new vector layer. There, select "File" as source type, "Automatic" as encoding and choose the .ndjson report file as source below. Finally, click on the "Add" button. +

+ +

+ Now the image locations appear as a new layer in the layers list. Next, you have to make sure that the vector features are displayed with the correct coordinate reference system. Right click on the layer and select "Set CRS" > "Set Layer CRS". There, choose the EPSG:4326 CRS. +

+ +

+ Image location reports contain additional properties or attributes for each image. To view these attributes, select "Open Attribute Table" in the right click menu of the vector layer. +

+ +

Filter an annotation image location report

+ +

+ The annotation image location report contains information on the number of annotations with a certain label that belong to a certain image. This information can be used to filter the vector features that represent the images in the GIS. Here is how to enable a filtering for the following example query: "Show all positions for images that contain at least one annotations with the label Sponge". +

+ +

+ To create a new filtering, right click on the vector layer and select "Filter...", which will open the Query Builder window. There you can see the list of available fields on the left. In this case, the fields will include a list of the labels that have been used throughout the volume. in our example, this list should also include the "Sponge" label, which we select with a double click. Now we choose the ">" operator from the list of available operators below. Finally, we add "0" to the existing query, which should now look similar to this: +

+
"Sponge (#6427)" > 0
+

+ You can test if your filter query is correct with a click on the "Test" button. If everything is well, click "OK" to enable the new filtering. +

+

+ If you apply one ore more filter rules like in the example above, you can reproduce the behavior of the image volume map in the GIS. However, the GIS query builder is much more powerful. +

+ +

Style an annotation image location report

+ +

+ The annotation count information of the annotation image location report can also be used to adjust the style of the vector features that represent the image positions. By default, the features are displayed as circle markers. Here is how you can adjust the size and/or color of the markers based on the annotation count. +

+ +

Adjust the size

+ +

+ To adjust the size of the circle markers, select "Properties..." in the right click menu of the vector layer. This will open the Layer Properties dialog. There, choose "Symbology" in the sidebar on the left. Here, select the "Simple marker" at the top. Now you can edit the style of the marker. To enable a dynamic size, click on the context menu icon at the right of the "Size" form field and choose "Assistant...". +

+ +

+ In the marker size assistant window, choose the label on which the marker size should be based from the "Source" dropdown input (e.g. "Sponge (#6427)" from the example above). Now click on the "Fetch value range from layer" button at the right of the "Values from ... to" input fields to automatically determine the value range. Finally, click "OK" and then "Apply" to apply the new style. +

+ +

Adjust the color

+ +

+ Adjusting the color of the circle markers is very similar to adjusting the size. Instead of the marker size assistant, you open the marker fill color assistant in the layer properties dialog. There, choose the source and value range in the same way than described above. Finally, click "OK" and then "Apply" to apply the new style. +

+ +

Filter an image label image location report

+ +

+ Filtering of an image label image location report is done in the same way than filtering of an image annotation image location report. However, the image label report contains different properties for each image. In this report the properties of an image specify whether a certain label is attached to the image or not. A property is 1 if a label is attached and 0 of it is not attached. This can be used to filter the vector features to display only those positions of images that have (or don't have) a specific label attached. For example, a query to show only positions of images that do not have the "Unusable" label attached may look like this: +

+
"Unusable (#1337)" != 1
+
+@endsection diff --git a/resources/views/manual/tutorials/reports/reports-schema.blade.php b/resources/views/manual/tutorials/reports/reports-schema.blade.php new file mode 100644 index 000000000..4b13dc982 --- /dev/null +++ b/resources/views/manual/tutorials/reports/reports-schema.blade.php @@ -0,0 +1,392 @@ +@extends('manual.base') + +@section('manual-title') Reports schema @stop + +@section('manual-content') +
+

+ A description of the file formats of the different available reports. +

+

Project and volume reports

+

+ Most report types can be requested for a whole project as well as for individual volumes. A project report is a convenience feature which requests reports for all individual volumes of the project at once and provides a ZIP file containing the volume reports for download. However, not all configuration options may be available for project reports. +

+

+ The following sections describe the different types of volume reports but, per definition, apply for project reports as well. +

+ + + + +

Image annotation reports

+

Abundance

+ +

+ Similar to the extended report, this report is an XLSX spreadsheet that contains the abundances of each label and image. In this report, there is one row for each image and one column for each label. If the annotations should be separated by label tree or user, there will be one worksheet for each label tree or user that was used. +

+

+ For a single worksheet (not separated by label tree or user) the first line contains the volume name. For multiple worksheets the first lines contain the name of the respective label tree or user. The second line always contains the column headers. The columns are as follows: +

+
    +
  1. Image filename
  2. +
  3. label name 1
  4. +
  5. label name 2
  6. +
  7. ...
  8. +
+ +

+ If "aggregate child labels" was enabled for this report, the abundances of all child labels will be added to the abundance of the highest parent label and the child labels will be excluded from the report. +

+ +

AnnotationLocation

+ +

+ The image annotation annotation location report is a newline delimited GeoJSON file that contains the estimated positions of image annotations on a world map. This report can be used to import annotations in a GIS software such as QGIS. You can find a description of how to import and use a GeoJSON report in QGIS here. +

+ +

+ The annotation position estimation is based on several assumptions. You can find a detailed description here. +

+ +

+ The report contains one GeoJSON feature for each annotation label. This means that there may be multiple features for a single annotation if the annotation has multiple labels attached. The following properties are included for each feature: +

+
    +
  • _id
    The annotation label ID (unique for a GeoJSON feature).
  • +
  • _image_id
    The ID of the image to which the annotation belongs.
  • +
  • _image_filename
    The filename of the image to which the annotation belongs.
  • +
  • _image_latitude
    The latitude coordinate of the image to which the annotation belongs.
  • +
  • _image_longitude
    The longitude coordinate of the image to which the annotation belongs.
  • +
  • _label_name
    The name of the label that belongs to the annotation label.
  • +
  • _label_id
    The ID of the label that belongs to the annotation label.
  • +
+ +
+
+ The GeoJSON format does not support circle features. Circle annotations are converted to point features in this report. +
+
+ +

Area

+ +

+ The image annotation area report is an XLSX spreadsheet of all area annotations (rectangle, circle, ellipse and polygon) with their width and height in pixels (px) and their area in px². Line string annotations are included, too, with the "width" set to the total length of the line string. If a laser point detection was performed, the width and height in m and the area in m² is included as well. +

+
+
+ The computed area of self-intersecting polygons like these will not be correct! + + + + + + + +
+
+

+ For a single worksheet (not separated by label tree or user) the first line contains the volume name. For multiple worksheets the first lines contain the name of the respective label tree or user. The second line always contains the column headers. The columns are as follows: +

+
    +
  1. Annotation ID
  2. +
  3. Annotation shape ID
  4. +
  5. Annotation shape name
  6. +
  7. Label IDs comma separated list of IDs of all labels that are attached to the annotation
  8. +
  9. Label names comma separated list of names of all labels that are attached to the annotation
  10. +
  11. Image ID
  12. +
  13. Image filename
  14. +
  15. Annotation width (m) Rectangle: the longer edge. Circle: the diameter. Ellipse: Length of the major axis. Polygon: width of the minimum (non-rotated) bounding rectangle. Line string: total length.
  16. +
  17. Annotation height (m) Rectangle: the shorter edge. Circle: the diameter. Ellipse: Length of the minor axis. Polygon: height of the minimum (non-rotated) bounding rectangle. Line string: always 0.
  18. +
  19. Annotation area (m²)
  20. +
  21. Annotation width (px) See the width in m for the interpretation of this value for different shapes.
  22. +
  23. Annotation height (px) See the height in m for the interpretation of this value for different shapes.
  24. +
  25. Annotation area (px²)
  26. +
+ +

Basic

+

+ The basic image annotation report contains a graphical plot of abundances of the different annotation labels (annotations can have multiple labels by different users). If the annotations should be separated by label tree or user, there will be one plot for each label tree or user. +

+

+ Example plot: +

+ + + +
+

+

+ The bars of the plot are color-coded based on the colors of the labels they represent. If any label occurs more than a hundred times, a logarithmic scale is applied. +

+ +

CSV

+

+ The CSV report is intended for subsequent processing. If you want the data in a machine readable format, choose this report. The report is a ZIP archive, containing a CSV file. The CSV file name consists of the volume ID and the volume name (cleaned up so it can be a file name) separated by an underscore. If the image annotations should be separated by label tree or user, there will be one CSV file for each label tree or user and the CSV file name will consist of the label tree or user ID and name instead. +

+

+ Each CSV file contains one row for each annotation label. Since an annotation can have multiple labels, there may be multiple rows for a single annotation. The first row always contains the column headers. The columns are as follows: +

+
    +
  1. Annotation label ID (not the annotation ID)
  2. +
  3. Label ID
  4. +
  5. Label name
  6. +
  7. Label hierarchy (see the extended report on how to interpret a label hierarchy)
  8. +
  9. ID of the user who created/attached the annotation label
  10. +
  11. User firstname
  12. +
  13. User lastname
  14. +
  15. Image ID
  16. +
  17. Image filename
  18. +
  19. Image longitude
  20. +
  21. Image latitude
  22. +
  23. Annotation shape ID
  24. +
  25. Annotation shape name
  26. +
  27. + Annotation points +

    + The annotation points are encoded as a JSON array of alternating x and y values (e.g. [x1,y1,x2,y2,...]). For circles, the third value of the points array is the radius of the circle. +

    +
  28. +
  29. + Additional attributes of the image +

    + The additional attributes of the image are encoded as a JSON object. The content may vary depending on the BIIGLE modules that are installed and the operations performed on the image (e.g. a laser point detection to calculate the area of an image). +

    +
  30. +
  31. Annotation ID
  32. +
  33. Creation date (of the annotation label)
  34. +
+ +

Extended

+ +

+ The extended image annotation report is an XLSX spreadsheet which contains a list of the abundances of each label and image. If the annotations should be separated by label tree or user, there will be one worksheet for each label tree or user. +

+

+ For a single worksheet (not separated by label tree or user) the first line contains the volume name. For multiple worksheets the first lines contain the name of the respective label tree or user. The second line always contains the column headers. The columns are as follows: +

+
    +
  1. Image filename
  2. +
  3. + Label hierarchy +

    + The label hierarchy contains all label names from the root label to the child label, separated by a >. If we have the following label tree: +

    +Animalia
    +└─ Annelida
    +   └─ Polychaeta
    +      └─ Buskiella sp
    +
    + Then the content of the "label hierarchy" column for annotations with the label "Buskiella sp" will be Animalia > Annelida > Polychaeta > Buskiella sp. +

    +
  4. +
  5. Label abundance
  6. +
+ +

Coco

+

+ The Coco file format is a common format for machine learning applications. The data is stored in a JSON file, which is readable by most deep learning frameworks. For more information please have a look at this article. Point annotations are incompatible and will not be included in this report. All remaining annotations will be transformed to polygons which might cause slight changes in their appearance. +

+ +

Full

+ +

+ The full image annotation report is an XLSX spreadsheet similar to the extended report. It contains a list of all annotations and their labels. +

+

+ The columns are as follows: +

+
    +
  1. Image filename
  2. +
  3. Annotation ID
  4. +
  5. Annotation shape name
  6. +
  7. X-Coordinate(s) of the annotation (may span multiple lines)
  8. +
  9. Y-Coordinate(s) of the annotation (may span multiple lines)
  10. +
  11. Comma separated list of label hierarchies (see the extended report on how to interpret a label hierarchy)
  12. +
  13. The area of the image in m² if available
  14. +
+

+ For the different annotation shapes, the coordinates are interpreted as follows: +

+
    +
  • + Point: The x and y coordinates are the location of the point on the image. +
  • +
  • + Rectangle: Each line contains the x and y coordinates of one of the four vertices describing the rectangle. +
  • +
  • + Circle: The first line contains the x and y coordinates of the center of the circle. The x value of the second line is the radius of the circle. +
  • +
  • + Ellipse: Similar to the rectangle. The first two vertices are the end points of the major axis. The next two vertices are the end points of the minor axis. +
  • +
  • + Line string: Each line contains the x and y coordinates of one of the vertices describing the line string. +
  • +
  • + Polygon: Each line contains the x and y coordinates of one of the vertices describing the polygon. +
  • +
+ +

ImageLocation

+ +

+ The image annotation image location report is a newline delimited GeoJSON file that contains image positions as points on a world map. This report can be used to import image positions in a GIS software such as QGIS. You can find a description of how to import and use a GeoJSON report in QGIS here. +

+ +

+ The report contains one GeoJSON feature for each image. The following properties are included for each feature: +

+
    +
  • _id
    The image ID (unique for a GeoJSON feature).
  • +
  • _filename
    The filename of the image.
  • +
  • Additional properties list the number of annotations with a certain label for each image. The format of the property title is "label_name (#label_id)"
  • +
+ +

Image label reports

+

Basic

+

+ The basic image label report is an XLSX spreadsheet similar to the extended annotation report. It contains a list of all labels attached to each image of the volume. The columns are as follows: +

+
    +
  1. Image ID
  2. +
  3. Image filename
  4. +
  5. Comma separated list of label hierarchies (see the extended annotation report on how to interpret a label hierarchy)
  6. +
+ +

CSV

+

+ The CSV report is similar to the annotation CSV report. If you want the data in a machine readable format, choose this report. +

+

+ Each CSV file contains one row for each image label. Since an image can have multiple different labels, there may be multiple rows for a single image. The columns are as follows: +

+
    +
  1. Image label ID
  2. +
  3. Image ID
  4. +
  5. Image filename
  6. +
  7. Image longitude
  8. +
  9. Image latitude
  10. +
  11. ID of the user who attached the image label
  12. +
  13. User firstname
  14. +
  15. User lastname
  16. +
  17. Label ID
  18. +
  19. Label name
  20. +
  21. Label hierarchy (see the extended annotation report on how to interpret a label hierarchy)
  22. +
  23. Creation date
  24. +
+ +

ImageLocation

+ +

+ The image label image location report is a newline delimited GeoJSON file that contains image positions as points on a world map. This report can be used to import image positions in a GIS software such as QGIS. You can find a description of how to import and use a GeoJSON report in QGIS here. +

+ +

+ The report contains one GeoJSON feature for each image. The following properties are included for each feature: +

+
    +
  • _id
    The image ID (unique for a GeoJSON feature).
  • +
  • _filename
    The filename of the image.
  • +
  • Additional properties list the image labels that have been used in the volume and whether a label was attached to an image (1) or not (0). The format of the property title is "label_name (#label_id)"
  • +
+ +

Video annotation reports

+

CSV

+

+ The CSV report is similar to the annotation CSV report. +

+

+ Each CSV file contains one row for each video annotation label. Since a video annotation can have multiple different labels, there may be multiple rows for a single video annotation. The columns are as follows: +

+
    +
  1. Video annotation label ID (not the video annotation ID)
  2. +
  3. Label ID
  4. +
  5. Label name
  6. +
  7. Label hierarchy (see the extended report on how to interpret a label hierarchy)
  8. +
  9. ID of the user who created/attached the video annotation label
  10. +
  11. User firstname
  12. +
  13. User lastname
  14. +
  15. Video ID
  16. +
  17. Video filename
  18. +
  19. Video annotation shape ID
  20. +
  21. Video annotation shape name
  22. +
  23. + Video annotation points +

    + The video annotation points are encoded as nested JSON arrays of alternating x and y values (e.g. [[x11,y11,x12,y12,...],[x21,y21,...],...]). Each array describes the video annotation for a specific key frame (time). For circles, the third value of the points array is the radius of the circle. An empty array means there is a gap in the video annotation. +

    +
  24. +
  25. + Video annotation key frames +

    + The key frames are encoded as a JSON array. Each key frame represents a time in seconds that corresponds to the ponts array at the same index. null means there is a gap in the video annotation. +

    +
  26. +
  27. Video annotation ID
  28. +
  29. Creation date (of the video annotation label)
  30. +
  31. + Additional attributes of the video +

    + The additional attributes of the video are encoded as a JSON object. The content may vary depending on the BIIGLE modules that are installed and the available metadata for the video. (e.g. MIME type, size, width and height). +

    +
  32. +
+ +

Video label reports

+

CSV

+

+ Each CSV file contains one row for each video label. Since a video can have multiple different labels, there may be multiple rows for a single video. The columns are as follows: +

+
    +
  1. Video label ID
  2. +
  3. Video ID
  4. +
  5. Video filename
  6. +
  7. ID of the user who attached the video label
  8. +
  9. User firstname
  10. +
  11. User lastname
  12. +
  13. Label ID
  14. +
  15. Label name
  16. +
  17. Label hierarchy (see the extended annotation report on how to interpret a label hierarchy)
  18. +
  19. Creation date
  20. +
+ +

iFDO reports

+ +

+ iFDO reports can be requested if an iFDO file has been uploaded for a volume. The iFDO report will be generated as the original file with additional information on the annotations and image/video labels that were created in BIIGLE. Optionally, annotations and image/video labels of the original file can be excluded from the report. +

+
+@endsection diff --git a/resources/views/partials/reportTypeInfo.blade.php b/resources/views/partials/reportTypeInfo.blade.php new file mode 100644 index 000000000..45a8cdd2e --- /dev/null +++ b/resources/views/partials/reportTypeInfo.blade.php @@ -0,0 +1,48 @@ +
+ The basic image annotation report contains graphical plots of abundances of the different annotation labels (as PDF). See the manual for the report schema. +
+
+ The extended image annotation report lists the abundances of annotation labels for each image and label (as XLSX). See the manual for the report schema. +
+
+ The Coco image annotation report lists all annotations as (approximated polygons). Point annotations will not be included. See the manual for the report schema. +
+
+ The abundance image annotation report lists the abundances of annotation labels for each image (as XLSX). Abundances can be aggregated to parent labels. See the manual for the report schema. +
+
+ The full image annotation report lists the labels, shape and coordinates of all annotations (as XLSX). See the manual for the report schema. +
+
+ The CSV image annotation report is intended for subsequent processing and lists the annotation labels at the highest possible resolution (as CSV files in a ZIP archive). See the manual for the report schema. +
+
+ The image annotation area report lists all rectangle, circle, ellipse or polygon annotations with their dimensions and area in pixels (as XLSX). If a laser point detection was performed, the dimensions in m and area in m² is included, too. See the manual for the report schema. +
+
+ The image annotation annotation location report returns the estimated annotation positions on a world map in the newline delimited GeoJSON format. See the manual for the report schema. +
+
+ The image annotation image location report returns the image positions as points on a world map in the newline delimited GeoJSON format. See the manual for the report schema. +
+
+ The basic image label report lists the image labels of all images (as XLSX). See the manual for the report schema. +
+
+ The CSV image label report is intended for subsequent processing and lists the image labels at the highest possible resolution (as CSV files in a ZIP archive). See the manual for the report schema. +
+
+ The image label image location report returns the image positions as points on a world map in the newline delimited GeoJSON format. See the manual for the report schema. +
+
+ The CSV video annotation report is intended for subsequent processing and lists the video annotation labels at the highest possible resolution (as CSV files in a ZIP archive). See the manual for the report schema. +
+
+ The CSV video label report lists the video labels at the highest possible resolution (as CSV files in a ZIP archive). See the manual for the report schema. +
+
+ The iFDO report returns the iFDO file that was attached to a volume with added information about annotations and image labels. See the iFDO specification for more information. +
+
+ The iFDO report returns the iFDO file that was attached to a volume with added information about annotations and video labels. See the iFDO specification for more information. +
diff --git a/resources/views/partials/restrictLabels.blade.php b/resources/views/partials/restrictLabels.blade.php new file mode 100644 index 000000000..9c045a58f --- /dev/null +++ b/resources/views/partials/restrictLabels.blade.php @@ -0,0 +1,14 @@ +
+
+ +
+
+
+ Restrict the report to specific labels. Select one or more labels below. Hold Ctrl to select all child labels and/or Alt to select all sibling labels. +
+
+ +
+
diff --git a/resources/views/projects/reports.blade.php b/resources/views/projects/reports.blade.php new file mode 100644 index 000000000..f17b8013c --- /dev/null +++ b/resources/views/projects/reports.blade.php @@ -0,0 +1,158 @@ +@extends('projects.show.base') + +@section('title', "Reports for {$project->name}") + +@push('scripts') + +@endpush + +@section('project-content') +
+

+ Request a project report to consolidate data of image or video volumes of the project into downloadable files. +

+
+
+
+
+ + @if ($hasImageVolume) +
+
+ +
+
+ +
+
+ @if ($hasIfdos) + + @else + + @endif +
+
+ @endif + @if ($hasImageVolume && $hasVideoVolume) +
+
+ @endif + @if ($hasVideoVolume) +
+
+ +
+
+ +
+
+ @if ($hasIfdos) + + @else + + @endif +
+
+ @endif +
+
+
+ + +
+ @include('partials.reportTypeInfo') +
+
+
+ The requested report will be prepared. You will get notified when it is ready. +
+
+ +
+
+
+
+
+ @if ($hasExportArea) + + @else + + @endif +
+
+
+ Annotations that are outside of the export area will be discarded for this report. +
+
+
+
+ +
+
+
+ Only the newest label of each annotation will be included in the report. +
+
+
+
+ +
+
+
+ Aggregate the abundance of child labels to their parent label. +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+ Split the report to separate files/sheets for label trees or users. +
+
+
+
+ +
+
+
+ Only include BIIGLE annotations in the iFDO files. +
+
+ @include('partials.restrictLabels') +
+
+
+
+@endsection diff --git a/resources/views/projects/show/tabs.blade.php b/resources/views/projects/show/tabs.blade.php index 82faa7198..b55b3c081 100644 --- a/resources/views/projects/show/tabs.blade.php +++ b/resources/views/projects/show/tabs.blade.php @@ -11,5 +11,10 @@ + @if ($project->volumes()->exists()) + + @endif @mixin('projectsShowTabs') diff --git a/resources/views/search/index.blade.php b/resources/views/search/index.blade.php index 344f28755..1bbe990bf 100644 --- a/resources/views/search/index.blade.php +++ b/resources/views/search/index.blade.php @@ -27,6 +27,7 @@ @include("search.volumes-tab") @include("search.annotations-tab") @include("search.videos-tab") + @include("search.reports-tab") @foreach (Modules::getViewMixins('searchTab') as $module => $nested) @include("{$module}::searchTab") @endforeach @@ -37,6 +38,7 @@ @include("search.volumes-content") @include("search.annotations-content") @include("search.videos-content") + @include("search.reports-content") @foreach (Modules::getViewMixins('searchTabContent') as $module => $nested) @include("{$module}::searchTabContent") @endforeach diff --git a/resources/views/search/reports-content.blade.php b/resources/views/search/reports-content.blade.php new file mode 100644 index 000000000..30cff989f --- /dev/null +++ b/resources/views/search/reports-content.blade.php @@ -0,0 +1,39 @@ +@if($type === 'reports') +

{{number_format($reportResultCount)}} report results

+ +@if ($results->isEmpty()) +

+ We couldn't find any reports + @if ($query) + matching '{{$query}}'. + @else + for you. + @endif +

+@endif +@endif diff --git a/resources/views/search/reports-tab.blade.php b/resources/views/search/reports-tab.blade.php new file mode 100644 index 000000000..02765e7e4 --- /dev/null +++ b/resources/views/search/reports-tab.blade.php @@ -0,0 +1,3 @@ + diff --git a/resources/views/settings/notifications.blade.php b/resources/views/settings/notifications.blade.php index 722bd0b58..d4b1bd4de 100644 --- a/resources/views/settings/notifications.blade.php +++ b/resources/views/settings/notifications.blade.php @@ -9,16 +9,51 @@ Choose how you receive notifications. Email will send you an email for each new notification. Web will send the notification to your BIIGLE notification center.

- @forelse ($modules->getMixins('settings.notifications') as $module => $nestedMixins) -
    +
      +
    • +

      Report notifications

      +

      + Notifications when a report that you requested is ready for download. +

      +
      +
      + @if (config('reports.notifications.allow_user_settings')) + + + + + + + + + + @else + {{ucfirst(config('reports.notifications.default_settings'))}} (fixed by admin) + @endif +
      +
      +
    • + @foreach ($modules->getMixins('settings.notifications') as $module => $nestedMixins)
    • @include($module.'::settings.notifications')
    • -
    - @empty -
    - There are no notification settings that you can change right now. -
    - @endforelse + @endforeach +
@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/volumes/reports.blade.php b/resources/views/volumes/reports.blade.php new file mode 100644 index 000000000..ceb02c301 --- /dev/null +++ b/resources/views/volumes/reports.blade.php @@ -0,0 +1,176 @@ +@extends('app') + +@section('title', "Reports for {$volume->name}") + +@push('scripts') + +@endpush + +@section('navbar') + +@endsection + +@section('content') +
+
+
+

Request report for {{$volume->name}}

+

+ Request a volume report to consolidate data of the volume into downloadable files. +

+
+
+ +
+ @if ($volume->isImageVolume()) +
+ +
+
+ +
+
+ @if ($hasIfdo) + + @else + + @endif +
+ @else +
+ +
+
+ +
+
+ @if ($hasIfdo) + + @else + + @endif +
+ @endif +
+
+
+
+ + +
+ @include('partials.reportTypeInfo') +
+
+ @if ($annotationSessions->count() > 0) +
+ + +
+
+ Only annotations that were created during the selected annotation session will be included in the report. +
+
+ @endif + @if ($volume->isImageVolume()) +
+
+ @if ($volume->exportArea) + + @else + + @endif +
+
+
+ Annotations that are outside of the export area will be discarded for this report. +
+
+ @endif +
+
+ +
+
+
+ Only the newest label of each annotation will be included in the report. +
+
+
+
+ +
+
+
+ Aggregate the abundance of child labels to their parent label. +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+ Split the report to separate files/sheets for label trees or users. +
+
+
+
+ +
+
+
+ Only include BIIGLE annotations in the iFDO file. +
+
+ @include('partials.restrictLabels') +
+ The requested report will be prepared. You will get notified when it is ready. Now you can request a new report or go back to the volume. +
+
+ back + +
+
+

+ ProTip: You can request reports for a whole project from the project overview page. +

+
+
+
+@endsection diff --git a/resources/views/volumes/show.blade.php b/resources/views/volumes/show.blade.php index e28c73cea..8b6fdceb3 100644 --- a/resources/views/volumes/show.blade.php +++ b/resources/views/volumes/show.blade.php @@ -57,6 +57,7 @@ @include('volumes.show.sorting') + @mixin('volumesSidebar')