diff --git a/app/AnnotationGuideline.php b/app/AnnotationGuideline.php new file mode 100644 index 000000000..3090bc764 --- /dev/null +++ b/app/AnnotationGuideline.php @@ -0,0 +1,59 @@ + + */ + protected $casts = [ + 'id' => 'int', + 'project' => 'int', + 'description' => 'string', + ]; + + protected $fillable = [ + 'project', + 'description', + ]; + + /** + * Don't maintain timestamps for this model. + * + * @var bool + */ + public $timestamps = false; + + /** + * The project this guideline belongs to. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function project() + { + return $this->belongsTo(Project::class, 'project'); + } + + /** + * The labels within this guideline. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function guidelineLabels() + { + return $this->hasMany(AnnotationGuidelineLabel::class, 'annotation_guideline'); + } +} diff --git a/app/AnnotationGuidelineLabel.php b/app/AnnotationGuidelineLabel.php new file mode 100644 index 000000000..9c63a0ec7 --- /dev/null +++ b/app/AnnotationGuidelineLabel.php @@ -0,0 +1,58 @@ + + */ + protected $casts = [ + 'annotation_guideline' => 'int', + 'label' => 'int', + 'shape' => 'int', + 'description' => 'string', + 'reference_image' => 'boolean', + ]; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'annotation_guideline', + 'label', + 'shape', + 'description', + 'reference_image', + ]; + /** + * Don't maintain timestamps for this model. + * + * @var bool + */ + public $timestamps = false; + + /** + * The labels that have a guideline for their annotation + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function label() + { + return $this->belongsTo(Label::class, 'label'); + } +} diff --git a/app/Http/Controllers/Api/Projects/AnnotationGuidelineController.php b/app/Http/Controllers/Api/Projects/AnnotationGuidelineController.php new file mode 100644 index 000000000..3c0630275 --- /dev/null +++ b/app/Http/Controllers/Api/Projects/AnnotationGuidelineController.php @@ -0,0 +1,110 @@ +authorize('update', $project); + $guideline = AnnotationGuideline::where(['project'=> $id]) + ->firstOrFail(); + $guidelineLabels = $guideline + ->guidelineLabels() + ->select() + ->with('label') + ->get(); + return ['annotation_guideline' => $guideline, 'annotation_guideline_labels' => $guidelineLabels]; + + } + + /** + * Update the annotation guideline for the given project + * + * @api {post} projects/:pid/annotation-guideline Update the annotation guideline for the given project + * @apiGroup Projects + * @apiName AnnotationGuideline + * @apiParam {Number} id The Project ID + * @apiParam {String} description A description on how to annotate the guideline + * @apiPermission projectAdmin + * @apiDescription Edit the annotation guideline associated with the given ID + * + * @param int $id Project ID + */ + public function update(Request $request, int $id) + { + $request->validate([ + 'description' => 'nullable|string|min:1', + ]); + + $project = Project::findOrFail($id); + $this->authorize('update', $project); + + AnnotationGuideline::updateOrCreate( + ['project' => $project->id], + ['description' => $request->description] + ); + + } + + /** + * Delete the annotation guideline for the given project + * + * @api {delete} projects/:pid/annotation-guideline Delete the annotation guideline for the given project + * @apiGroup Projects + * @apiName AnnotationGuideline + * @apiParam {Number} id The Project ID + * @apiPermission projectAdmin + * @apiDescription Delete the annotation guideline associated with the given ID + */ + public function delete(Request $request) + { + $project = Project::findOrFail($request->id); + $this->authorize('update', $project); + $annotationGuideline = AnnotationGuideline::where(['project'=> $project->id])->firstOrFail(); + $annotationGuideline->delete(); + + //Cleanup the directory + $disk = Storage::disk(config('projects.annotation_guideline_storage_disk')); + $url = "$project->id/"; + if ($disk->exists($url)) { + $disk->deleteDirectory($url); + } + } +} diff --git a/app/Http/Controllers/Api/Projects/AnnotationGuidelineLabelController.php b/app/Http/Controllers/Api/Projects/AnnotationGuidelineLabelController.php new file mode 100644 index 000000000..f79d4e305 --- /dev/null +++ b/app/Http/Controllers/Api/Projects/AnnotationGuidelineLabelController.php @@ -0,0 +1,128 @@ +project->id; + $annotationGuideline = AnnotationGuideline::where(['project' => $projectId])->firstOrFail(); + $validated = $request->validated(); + + $labelId = $validated['label']; + $label = Label::findOrFail($labelId); + + $shapeId = $validated['shape'] ?? null; + if (!is_null($shapeId)) { + $shapeId = Shape::findOrFail($shapeId)->id; + } + + $description = $validated['description'] ?? null; + $referenceImage = $validated['reference_image'] ?? null; + + $disk = Storage::disk(config('projects.annotation_guideline_storage_disk')); + + $hasReferenceImage = !is_null($referenceImage); + if ($hasReferenceImage) { + $disk->putFileAs("$projectId", $referenceImage, "$label->id.jpg"); + } + + //If image already exists, avoid problems + $imageExists = $disk->exists("$projectId/$label->id.jpg"); + AnnotationGuidelineLabel::updateOrCreate( + [ + 'annotation_guideline' => $annotationGuideline->id, + 'label' => $label->id, + ], + [ + 'shape' => $shapeId, + 'description' => $description, + 'reference_image' => $imageExists, + ] + ); + } + + /** + * Delete a label from a guideline. + * + * @api {delete} projects/:id/annotation-guideline-labels/delete-image Delete a reference image + * @apiGroup Projects + * @apiName DeleteReferenceImage + * @apiPermission projectAdmin + * + * @apiParam {Integer} id The ID of the project for the annotation guideline for the labels + * + */ + public function delete(Request $request) + { + $project = Project::findOrFail($request->id); + $label = Label::findOrFail($request->label); + $annotationGuideline = AnnotationGuideline::where(['project' => $project->id])->firstOrFail(); + $annotationGuidelineLabel = $annotationGuideline->guidelineLabels()->where(['label' => $label->id])->firstOrFail(); + + $this->authorize('update', $project); + + $annotationGuidelineLabel->delete(); + + $disk = Storage::disk(config('projects.annotation_guideline_storage_disk')); + $url = "$project->id/$label->id.jpg"; + if ($disk->exists($url)) { + $disk->delete($url); + } + } + + /** + * Delete a reference image. + * + * @api {delete} projects/:id/annotation-guideline-labels/delete-image Delete a reference image + * @apiGroup Projects + * @apiName DeleteReferenceImage + * @apiPermission projectAdmin + * + * @apiParam {Integer} id The ID of the project for the annotation guideline for the labels + * + */ + public function deleteReferenceImage(Request $request) + { + $project = Project::findOrFail($request->id); + $label = Label::findOrFail($request->label); + $annotationGuideline = AnnotationGuideline::where(['project' => $project->id])->firstOrFail(); + $annotationGuidelineLabel = $annotationGuideline->guidelineLabels()->where(['label' => $label->id])->firstOrFail(); + + $this->authorize('update', $project); + + $annotationGuidelineLabel->update(['reference_image' => false]); + + $disk = Storage::disk(config('projects.annotation_guideline_storage_disk')); + $url = "$project->id/$label->id.jpg"; + if ($disk->exists($url)) { + $disk->delete($url); + } + } +} diff --git a/app/Http/Controllers/Views/Projects/AnnotationGuidelineController.php b/app/Http/Controllers/Views/Projects/AnnotationGuidelineController.php new file mode 100644 index 000000000..b50e921a4 --- /dev/null +++ b/app/Http/Controllers/Views/Projects/AnnotationGuidelineController.php @@ -0,0 +1,85 @@ +user(); + + if (!$user->can('sudo')) { + $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(); + + $annotationGuideline = AnnotationGuideline::where(['project' => $id])->first(); + + $isAdmin = $user->can('update', $project); + + $labelTrees = $project->labelTrees()->with('labels', 'version')->get(); + + $shapes = Shape::pluck('name', 'id'); + + if (!$annotationGuideline) { + if ($isAdmin) { + return view('projects.show.annotation-guideline', [ + 'project' => $project, + 'user' => $user, + 'annotationGuideline' => [], + 'annotationGuidelineLabels' => [], + 'isMember' => $isMember, + 'isAdmin' => $isAdmin, + 'isPinned' => $isPinned, + 'canPin' => $canPin, + 'activeTab' => 'guideline', + 'labelTrees' => $labelTrees, + 'availableShapes' => $shapes, + ]); + } + abort(Response::HTTP_NOT_FOUND); + } + + $annotationGuidelineLabels = $annotationGuideline->guidelineLabels() + ->with('label') + ->get() + ->toArray(); + + return view('projects.show.annotation-guideline', [ + 'project' => $project, + 'annotationGuideline' => $annotationGuideline->toArray(), + 'annotationGuidelineLabels' => $annotationGuidelineLabels, + 'user' => $user, + 'isMember' => $isMember, + 'isAdmin' => $isAdmin, + 'isPinned' => $isPinned, + 'canPin' => $canPin, + 'activeTab' => 'guideline', + 'labelTrees' => $labelTrees, + 'availableShapes' => $shapes, + ]); + } +} diff --git a/app/Http/Controllers/Views/Projects/ProjectLabelTreeController.php b/app/Http/Controllers/Views/Projects/ProjectLabelTreeController.php index e9e89276b..6b9cea4e5 100644 --- a/app/Http/Controllers/Views/Projects/ProjectLabelTreeController.php +++ b/app/Http/Controllers/Views/Projects/ProjectLabelTreeController.php @@ -2,6 +2,7 @@ namespace Biigle\Http\Controllers\Views\Projects; +use Biigle\AnnotationGuideline; use Biigle\Http\Controllers\Views\Controller; use Biigle\Project; use Illuminate\Http\Request; @@ -32,6 +33,8 @@ public function show(Request $request, $id) ->wherePivot('pinned', true) ->count(); + $annotationGuideline = AnnotationGuideline::where('project', $project->id)->first(); + return view('projects.show.label-trees', [ 'project' => $project, 'isMember' => $isMember, @@ -39,6 +42,7 @@ public function show(Request $request, $id) 'canPin' => $canPin, 'activeTab' => 'label-trees', 'labelTrees' => $labelTrees, + 'annotationGuideline' => $annotationGuideline, ]); } } diff --git a/app/Http/Controllers/Views/Projects/ProjectReportsController.php b/app/Http/Controllers/Views/Projects/ProjectReportsController.php index f3b891190..e9707ed5f 100644 --- a/app/Http/Controllers/Views/Projects/ProjectReportsController.php +++ b/app/Http/Controllers/Views/Projects/ProjectReportsController.php @@ -2,6 +2,7 @@ namespace Biigle\Http\Controllers\Views\Projects; +use Biigle\AnnotationGuideline; use Biigle\Http\Controllers\Views\Controller; use Biigle\Modules\MetadataIfdo\IfdoParser; use Biigle\Project; @@ -59,6 +60,8 @@ protected function show(Request $request, $id) } } + $annotationGuideline = AnnotationGuideline::where('project', $project->id)->first(); + return view('projects.reports', [ 'project' => $project, 'isMember' => $isMember, @@ -71,6 +74,7 @@ protected function show(Request $request, $id) 'hasVideoVolume' => $hasVideoVolume, 'labelTrees' => $labelTrees, 'hasIfdos' => $hasIfdos, + 'annotationGuideline' => $annotationGuideline, ]); } } diff --git a/app/Http/Controllers/Views/Projects/ProjectStatisticsController.php b/app/Http/Controllers/Views/Projects/ProjectStatisticsController.php index 05937e519..d065b8109 100644 --- a/app/Http/Controllers/Views/Projects/ProjectStatisticsController.php +++ b/app/Http/Controllers/Views/Projects/ProjectStatisticsController.php @@ -2,6 +2,7 @@ namespace Biigle\Http\Controllers\Views\Projects; +use Biigle\AnnotationGuideline; use Biigle\Http\Controllers\Views\Controller; use Biigle\Image; use Biigle\MediaType; @@ -37,6 +38,7 @@ public function show(Request $request, $id) ->orderBy('created_at', 'desc') ->get(); + $annotationGuideline = AnnotationGuideline::where('project', $project->id)->first(); $totalImages = Image::whereIn('images.volume_id', fn ($query) => $query->select('volume_id') ->from('project_volume') @@ -58,6 +60,7 @@ public function show(Request $request, $id) ->where('media_type_id', MediaType::videoId()) ->get(); + return view('projects.show.statistics', [ 'project' => $project, 'isMember' => $isMember, @@ -65,6 +68,7 @@ public function show(Request $request, $id) 'canPin' => $canPin, 'activeTab' => 'charts', 'volumes' => $volumes, + 'annotationGuideline' => $annotationGuideline, // IMAGES 'annotatedImages' => $imageVolumeStatistics['annotatedFiles'], 'annotationLabels' => $imageVolumeStatistics['annotationLabels'], diff --git a/app/Http/Controllers/Views/Projects/ProjectUserController.php b/app/Http/Controllers/Views/Projects/ProjectUserController.php index d05475897..fc6aeb119 100644 --- a/app/Http/Controllers/Views/Projects/ProjectUserController.php +++ b/app/Http/Controllers/Views/Projects/ProjectUserController.php @@ -2,6 +2,7 @@ namespace Biigle\Http\Controllers\Views\Projects; +use Biigle\AnnotationGuideline; use Biigle\Http\Controllers\Views\Controller; use Biigle\Project; use Biigle\Role; @@ -48,6 +49,8 @@ public function show(Request $request, $id) ->wherePivot('pinned', true) ->count(); + $annotationGuideline = AnnotationGuideline::where('project', $project->id)->first(); + return view('projects.show.members', [ 'project' => $project, 'isMember' => $isMember, @@ -57,6 +60,7 @@ public function show(Request $request, $id) 'roles' => $roles, 'members' => $members, 'invitations' => $project->invitations, + 'annotationGuideline' => $annotationGuideline, ]); } } diff --git a/app/Http/Controllers/Views/Projects/ProjectsController.php b/app/Http/Controllers/Views/Projects/ProjectsController.php index a294f00b6..f411eb338 100644 --- a/app/Http/Controllers/Views/Projects/ProjectsController.php +++ b/app/Http/Controllers/Views/Projects/ProjectsController.php @@ -2,6 +2,7 @@ namespace Biigle\Http\Controllers\Views\Projects; +use Biigle\AnnotationGuideline; use Biigle\Http\Controllers\Views\Controller; use Biigle\Project; use Illuminate\Http\Request; @@ -59,6 +60,7 @@ protected function show(Request $request, $id) ->projects() ->wherePivot('pinned', true) ->count(); + $annotationGuideline = AnnotationGuideline::where(['project' => $id])->first(); return view('projects.show.volumes', [ 'project' => $project, @@ -67,6 +69,7 @@ protected function show(Request $request, $id) 'canPin' => $canPin, 'activeTab' => 'volumes', 'volumes' => $volumes, + 'annotationGuideline' => $annotationGuideline, ]); } } diff --git a/app/Http/Requests/StoreAnnotationGuidelineLabel.php b/app/Http/Requests/StoreAnnotationGuidelineLabel.php new file mode 100644 index 000000000..428e0a290 --- /dev/null +++ b/app/Http/Requests/StoreAnnotationGuidelineLabel.php @@ -0,0 +1,50 @@ +project = Project::findOrFail($this->route('id')); + + return $this->user()->can('update', $this->project); + } + + /** + * The rules that the request should follow + * + * @return array + */ + public function rules(): array + { + return [ + 'label' => ['required', 'integer'], + 'description' => ['nullable', 'string'], + 'shape' => ['nullable', 'integer'], + 'reference_image' => ['nullable', 'file', 'mimes:jpg,png,webp', 'max:5120', 'dimensions:max_width=300,max_height=300'], + ]; + } +} diff --git a/config/filesystems.php b/config/filesystems.php index 6b5ba1901..eba76a618 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -14,9 +14,7 @@ | Supported: "local", "ftp", "sftp", "s3" | */ - 'default' => env('FILESYSTEM_DISK', env('FILESYSTEM_DRIVER', 'local')), - /* |-------------------------------------------------------------------------- | Filesystem Disks @@ -95,6 +93,13 @@ 'url' => env('APP_URL').'/storage/largo-patches', 'visibility' => 'public', ], + + 'annotation-guideline' => [ + 'driver' => 'local', + 'root' => storage_path('app/public/annotation-guideline-reference-images'), + 'url' => env('APP_URL').'/storage/annotation-guideline-reference-images', + 'visibility' => 'public', + ], ], /* diff --git a/database/migrations/2026_03_02_103908_create_annotation_guideline_tables.php b/database/migrations/2026_03_02_103908_create_annotation_guideline_tables.php new file mode 100644 index 000000000..2e7793521 --- /dev/null +++ b/database/migrations/2026_03_02_103908_create_annotation_guideline_tables.php @@ -0,0 +1,55 @@ +id(); + $table->foreignId('project') + ->constrained() + ->onDelete('cascade'); + $table->unique('project'); + $table->text('description') + ->nullable(true); + }); + + Schema::create('annotation_guideline_labels', function (Blueprint $table) { + $table->id(); + $table->foreignId('annotation_guideline') + ->constrained() + ->onDelete('cascade'); + + $table->foreignId('label') + ->constrained() + ->onDelete('cascade'); + + $table->foreignId('shape') + ->nullable(true) + ->constrained() + ->onDelete('set null'); + + $table->text('description') + ->nullable(true); + + $table->boolean('reference_image'); + + $table->unique(['annotation_guideline', 'label']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('annotation_guideline_labels'); + Schema::dropIfExists('annotation_guidelines'); + } +}; diff --git a/resources/assets/js/label-trees/components/labelTree.vue b/resources/assets/js/label-trees/components/labelTree.vue index 13f5f5f4c..8388976c4 100644 --- a/resources/assets/js/label-trees/components/labelTree.vue +++ b/resources/assets/js/label-trees/components/labelTree.vue @@ -43,6 +43,8 @@ :flat="flat" :showFavouriteShortcuts="showFavouriteShortcuts" :position="index" + :labels-in-guideline="labelsInGuideline" + :filter-by-guideline="filterByGuideline" @select="emitSelect" @deselect="emitDeselect" @save="emitSave" @@ -149,6 +151,14 @@ export default { type: Boolean, default: false, }, + labelsInGuideline: { + type: Set, + default: () => new Set(), + }, + filterByGuideline: { + type: Boolean, + default: false, + }, }, computed: { labelMap() { @@ -186,11 +196,23 @@ export default { label.open = false; } }); + + const hasChildrenInGuideline = (children) => { + return children?.some(child => this.labelsInGuideline.has(child.id) || hasChildrenInGuideline(child.children)); + }; + + this.labels.forEach(function (label) { + label.childrenInGuideline = hasChildrenInGuideline(label.children); + }); } return compiled; }, rootLabels() { + if (this.filterByGuideline) { + return this.compiledLabels[null]?.filter(label => this.labelsInGuideline.has(label.id) || label.childrenInGuideline); + } + return this.compiledLabels[null]; }, collapseTitle() { @@ -385,7 +407,7 @@ export default { }, created() { // Set the reactive label properties - this.labels.forEach(function (label) { + this.labels.forEach((label) => { if (!label.hasOwnProperty('open')) { label.open = false; } diff --git a/resources/assets/js/label-trees/components/labelTreeLabel.vue b/resources/assets/js/label-trees/components/labelTreeLabel.vue index 5f19b4abf..1a4d82e45 100644 --- a/resources/assets/js/label-trees/components/labelTreeLabel.vue +++ b/resources/assets/js/label-trees/components/labelTreeLabel.vue @@ -1,6 +1,6 @@ @@ -83,9 +98,17 @@ export default { type: Boolean, default: false, }, - position:{ + position: { type: Number, - default:-1, + default: -1, + }, + labelsInGuideline: { + type: Set, + default: () => new Set(), + }, + filterByGuideline: { + type: Boolean, + default: false, }, }, computed: { @@ -120,6 +143,11 @@ export default { 'selected': this.label.favourite, }; }, + nameClass() { + return { + 'text-muted': this.hasGuideline && !this.isInGuideline, + }; + }, favouriteTitle() { return (this.label.favourite ? 'Remove' : 'Add') + ' as favourite'; }, @@ -130,14 +158,33 @@ export default { return 'Remove label ' + this.label.name; }, expandable() { - return !this.flat && !!this.label.children; + return !this.flat && (this.filteredChildren && this.filteredChildren.length > 0); }, showEditButton() { return this.editable && this.hover && !this.editing; }, actualPosition() { return (this.position + 1) % MAX_FAVOURITES; - } + }, + hasGuideline() { + return this.labelsInGuideline.size > 0; + }, + isInGuideline() { + return this.labelsInGuideline.has(this.label.id); + }, + shouldBeFilteredByGuideline() { + return this.filterByGuideline && !this.isInGuideline && !this.label.childrenInGuideline; + }, + showGuidelineIcon() { + return this.hasGuideline && this.isInGuideline && !this.filterByGuideline + }, + filteredChildren() { + if (this.filterByGuideline) { + return this.label.children?.filter(child => this.labelsInGuideline.has(child.id) || child.childrenInGuideline); + } + + return this.label.children; + }, }, methods: { toggleSelect(e) { diff --git a/resources/assets/js/label-trees/components/labelTrees.vue b/resources/assets/js/label-trees/components/labelTrees.vue index e726f1672..3a9e2933a 100644 --- a/resources/assets/js/label-trees/components/labelTrees.vue +++ b/resources/assets/js/label-trees/components/labelTrees.vue @@ -21,18 +21,30 @@ :items="labels" @select="handleSelect" > +
[], + }, + enforceGuideline: { + type: Boolean, + default: false, + }, }, computed: { customOrderStorageKeys() { @@ -163,6 +186,12 @@ export default { return false; }, + labelsInGuidelineSet() { + return new Set(this.labelsInGuideline); + }, + hasGuideline() { + return this.labelsInGuidelineSet.size > 0; + }, // All labels of all label trees in a flat, sorted list. labels() { let labels = []; @@ -170,8 +199,12 @@ export default { Array.prototype.push.apply(labels, tree.labels); }); + if (this.filterByGuideline) { + labels = labels.filter(label => this.labelsInGuidelineSet.has(label.id)); + } + if (this.localeCompareSupportsLocales) { - // Use this to sort label names "natuarally". This is only supported in + // Use this to sort label names "naturally". This is only supported in // modern browsers, though. let collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'}); labels.sort(function (a, b) { @@ -191,9 +224,19 @@ export default { canHaveMoreFavourites() { return this.favourites.length < MAX_FAVOURITES; }, + filteredFavourites() { + if (this.filterByGuideline) { + return this.favourites.filter(label => this.labelsInGuidelineSet.has(label.id)); + } + + return this.favourites; + }, hasFavourites() { return this.favourites.length > 0; }, + hasFilteredFavourites() { + return this.filteredFavourites.length > 0; + }, ownId() { if (this.id) { return this.id; @@ -215,7 +258,15 @@ export default { }, treeIds() { return this.trees.map(tree => tree.id); - } + }, + filterByGuideline() { + return this.filterByGuidelineInternal && this.hasGuideline; + }, + filterByGuidelineClass() { + return { + 'active btn-info': this.filterByGuideline, + }; + }, }, methods: { handleSelect(label, e) { @@ -257,8 +308,8 @@ export default { } }, selectFavourite(index) { - if (this.favourites[index]) { - this.handleSelect(this.favourites[index]); + if (this.filteredFavourites[index]) { + this.handleSelect(this.filteredFavourites[index]); } }, on(key, fn) { @@ -283,7 +334,10 @@ export default { this.customOrderStorageKeys.forEach(function (storageKey) { localStorage.setItem(storageKey, JSON.stringify(newCustomOrder)); }); - } + }, + toggleFilterLabelsByGuideline() { + this.filterByGuidelineInternal = !this.filterByGuidelineInternal; + }, }, watch: { trees: { diff --git a/resources/assets/js/projects/annotationGuidelineContainer.vue b/resources/assets/js/projects/annotationGuidelineContainer.vue new file mode 100644 index 000000000..a9375d9ca --- /dev/null +++ b/resources/assets/js/projects/annotationGuidelineContainer.vue @@ -0,0 +1,9 @@ + diff --git a/resources/assets/js/projects/api/annotationGuideline.js b/resources/assets/js/projects/api/annotationGuideline.js new file mode 100644 index 000000000..0ca08c24d --- /dev/null +++ b/resources/assets/js/projects/api/annotationGuideline.js @@ -0,0 +1,12 @@ +import {Resource} from 'vue-resource'; + +/** + * Resource for project guideline. + * + * Create or update a guideline. + * resource.save({id: projectId, description: description}, {...}).then(...); + * + * Delete a guideline. + * resource.delete({id: projectId}).then(...); + */ +export default Resource('api/v1/projects{/id}/annotation-guideline') diff --git a/resources/assets/js/projects/api/annotationGuidelineLabel.js b/resources/assets/js/projects/api/annotationGuidelineLabel.js new file mode 100644 index 000000000..76b01c783 --- /dev/null +++ b/resources/assets/js/projects/api/annotationGuidelineLabel.js @@ -0,0 +1,19 @@ +import {Resource} from 'vue-resource'; + +/** + * Resource for the labels within annotation guidelines. + * + * Create or update a label in a guideline. + * resource.save({id: projectId}, {description: description, label: label, reference_image: reference_image}).then(...); + * + * Delete a reference image + * resource.delete_image({id: projectId}, {label: labelId).then(...); + * + * + */ +export default Resource('api/v1/projects{/id}/annotation-guideline-label',{}, { + delete_image: { + method: 'DELETE', + url: 'api/v1/projects{/id}/annotation-guideline-label/delete-image', + } +}) diff --git a/resources/assets/js/projects/components/annotation_guideline/annotationGuideline.vue b/resources/assets/js/projects/components/annotation_guideline/annotationGuideline.vue new file mode 100644 index 000000000..31da4c0c9 --- /dev/null +++ b/resources/assets/js/projects/components/annotation_guideline/annotationGuideline.vue @@ -0,0 +1,300 @@ + + diff --git a/resources/assets/js/projects/components/annotation_guideline/annotationGuidelineLabel.vue b/resources/assets/js/projects/components/annotation_guideline/annotationGuidelineLabel.vue new file mode 100644 index 000000000..74a93c6d4 --- /dev/null +++ b/resources/assets/js/projects/components/annotation_guideline/annotationGuidelineLabel.vue @@ -0,0 +1,303 @@ + + diff --git a/resources/assets/js/projects/components/annotation_guideline/annotationGuidelineLabelImage.vue b/resources/assets/js/projects/components/annotation_guideline/annotationGuidelineLabelImage.vue new file mode 100644 index 000000000..f87b1afbd --- /dev/null +++ b/resources/assets/js/projects/components/annotation_guideline/annotationGuidelineLabelImage.vue @@ -0,0 +1,102 @@ + + diff --git a/resources/assets/js/projects/components/annotation_guideline/resizeImage.js b/resources/assets/js/projects/components/annotation_guideline/resizeImage.js new file mode 100644 index 000000000..193ba73a9 --- /dev/null +++ b/resources/assets/js/projects/components/annotation_guideline/resizeImage.js @@ -0,0 +1,58 @@ +/** + * Resizes an image and returns a Blob object + */ +export const resizeImage = ( + file, + { maxWidth = 300, maxHeight = 300, quality = 0.9 } = {}, +) => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + + reader.onload = (event) => { + const img = new Image(); + img.src = event.target.result; + img.onerror = (err) => reject(err); + + img.onload = () => { + const canvas = document.createElement('canvas'); + let width = img.width; + let height = img.height; + + if (width > maxWidth || height > maxHeight) { + const scale = Math.min( + maxWidth / width, + maxHeight / height, + 1, + ); + width = Math.round(width * scale); + height = Math.round(height * scale); + } + width = Math.max(width, 50); + height = Math.max(height, 50); + + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0, width, height); + + canvas.toBlob( + (blob) => { + if (blob) { + resolve(blob); + } else { + reject( + new Error( + 'An error occurred when manipulating the image', + ), + ); + } + }, + 'image/jpeg', + quality, + ); + }; + }; + reader.onerror = (err) => reject(err); + }); +}; diff --git a/resources/assets/js/projects/main.js b/resources/assets/js/projects/main.js index de9ff7116..298d2a825 100644 --- a/resources/assets/js/projects/main.js +++ b/resources/assets/js/projects/main.js @@ -7,6 +7,7 @@ import StatisticsContainer from './statisticsContainer.vue'; import Title from './title.vue'; import VolumesContainer from './volumesContainer.vue'; import VolumesCount from './volumesCount.vue'; +import AnnotationGuidelineContainer from './annotationGuidelineContainer.vue'; biigle.$mount('project-label-trees-count', LabelTreesCount); biigle.$mount('project-members-count', MembersCount); @@ -17,3 +18,4 @@ biigle.$mount('projects-show-members', MembersContainer); biigle.$mount('projects-show-statistics', StatisticsContainer); biigle.$mount('projects-show-volumes', VolumesContainer); biigle.$mount('projects-title', Title); +biigle.$mount('annotation-guideline-container', AnnotationGuidelineContainer); diff --git a/resources/assets/sass/label-trees/components/_labelTrees.scss b/resources/assets/sass/label-trees/components/_labelTrees.scss index 3d01d13d0..560b22058 100644 --- a/resources/assets/sass/label-trees/components/_labelTrees.scss +++ b/resources/assets/sass/label-trees/components/_labelTrees.scss @@ -20,6 +20,10 @@ &:not(:first-child) { margin-left: 10px; } + + &:not(:last-child) { + margin-right: 10px; + } } .label-tree { diff --git a/resources/assets/sass/projects/_show.scss b/resources/assets/sass/projects/_show.scss index d81af7d0b..f2fd189a4 100644 --- a/resources/assets/sass/projects/_show.scss +++ b/resources/assets/sass/projects/_show.scss @@ -114,3 +114,9 @@ } } } + +.annotation-guideline-label-options { + height: 250px; + padding: 5px; + border-style: outset; +} diff --git a/resources/assets/sass/projects/main.scss b/resources/assets/sass/projects/main.scss index 53ee3535b..22ec9e8e2 100644 --- a/resources/assets/sass/projects/main.scss +++ b/resources/assets/sass/projects/main.scss @@ -22,6 +22,51 @@ margin-top: 5em; } +.form-control.annotation-guideline-description { + height: 200px; + box-sizing: border-box; + text-align: left; + align-content: top; +} + +textarea.guideline-description { + width: 100%; + resize: none; + min-height: 150px; + max-height: 300px; + margin-top: 3px; + margin-bottom: 3px; + box-sizing: border-box; +} + +.annotation-guideline-label { + padding-top: 5px; + padding-bottom: 5px; + margin-top: 5px; + margin-bottom: 5px; + border-top: 1px solid white; +} + +.center-container { + display: flex; + justify-content: center; +} + +.btn-asl { + margin: 3px; +} + +.reference-image { + max-width: 300px; + min-width: 50px; + max-height: 300px; + min-height: 50px; +} + +.guideline-description-text { + white-space: pre-line; +} + @import 'components/previewThumbnail'; @import 'show'; @import 'charts'; diff --git a/resources/views/manual/tutorials/projects/about.blade.php b/resources/views/manual/tutorials/projects/about.blade.php index 6ba15f37e..fc686d572 100644 --- a/resources/views/manual/tutorials/projects/about.blade.php +++ b/resources/views/manual/tutorials/projects/about.blade.php @@ -102,5 +102,13 @@ Finally, project admins can delete a project with a click on in the dropdown menu. This will detach all label trees and volumes from the project. All volumes that are not attached to another project will be deleted. Be very careful when you want to delete a project since you can destroy lots of annotations with a single action!

+

Annotation guideline

+ +

+ The annotation guideline tab contains the annotation guideline applied to its project. Each project can have only one guideline, which consists of a description and optional label instructions. Label instructions can include a description, a preferred shape, and a reference image for each label. +

+

+ Annotation guidelines can be viewed by any user with project access, but can only be created or modified by admins. To create a guideline, click the tab, enter a description, and save it using the button. To remove the guideline entirely, click the button. To add a label to the guideline, click on it below the guideline description; this allows you to enter a description, select a preferred shape, and upload a reference image for that label. Save the label using the button, or remove it with the button. +

@endsection diff --git a/resources/views/projects/show/annotation-guideline.blade.php b/resources/views/projects/show/annotation-guideline.blade.php new file mode 100644 index 000000000..9840b8d07 --- /dev/null +++ b/resources/views/projects/show/annotation-guideline.blade.php @@ -0,0 +1,23 @@ +@extends('projects.show.base') + +@push('scripts') + +@endpush + +@section('project-content') +
+
+
+ + +
+
+
+@endsection diff --git a/resources/views/projects/show/tabs.blade.php b/resources/views/projects/show/tabs.blade.php index 25878f1eb..3db671196 100644 --- a/resources/views/projects/show/tabs.blade.php +++ b/resources/views/projects/show/tabs.blade.php @@ -22,5 +22,11 @@ @endif + @if ((($user->can('update', $project) || $user->can('sudo'))) || (isset($annotationGuideline) && $annotationGuideline != null)) + + @endif + @mixin('projectsShowTabs') diff --git a/routes/api.php b/routes/api.php index 626a70cae..e6a400848 100644 --- a/routes/api.php +++ b/routes/api.php @@ -284,6 +284,30 @@ 'uses' => 'Projects\ProjectAnnotationLabels@getProjectAnnotationLabelCounts', ]); +$router->get('projects/{id}/annotation-guideline', [ + 'uses' => 'Projects\AnnotationGuidelineController@index', +]); + +$router->post('projects/{id}/annotation-guideline', [ + 'uses' => 'Projects\AnnotationGuidelineController@update', +]); + +$router->delete('projects/{id}/annotation-guideline', [ + 'uses' => 'Projects\AnnotationGuidelineController@delete', +]); + +$router->post('projects/{id}/annotation-guideline-label', [ + 'uses' => 'Projects\AnnotationGuidelineLabelController@update', +]); + +$router->delete('projects/{id}/annotation-guideline-label', [ + 'uses' => 'Projects\AnnotationGuidelineLabelController@delete', +]); + +$router->delete('projects/{id}/annotation-guideline-label/delete-image', [ + 'uses' => 'Projects\AnnotationGuidelineLabelController@deleteReferenceImage', +]); + $router->get('public-export/label-trees/{id}', [ 'as' => 'get-public-label-tree-export', 'uses' => 'Export\PublicLabelTreeExportController@show', diff --git a/routes/web.php b/routes/web.php index 76db0eb0e..a85bbc64d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -280,6 +280,11 @@ 'as' => 'projectsLargo', 'uses' => 'LargoController@index', ]); + + $router->get('{id}/annotation-guideline', [ + 'as' => 'annotation-guideline', + 'uses' => 'AnnotationGuidelineController@show', + ]); }); $router->group(['namespace' => 'Volumes', 'prefix' => 'pending-volumes'], function ($router) { diff --git a/tests/php/Http/Controllers/Api/Projects/AnnotationGuidelineControllerTest.php b/tests/php/Http/Controllers/Api/Projects/AnnotationGuidelineControllerTest.php new file mode 100644 index 000000000..dee00a2c6 --- /dev/null +++ b/tests/php/Http/Controllers/Api/Projects/AnnotationGuidelineControllerTest.php @@ -0,0 +1,149 @@ +project()->id; + $path = "/api/v1/projects/{$id}/annotation-guideline"; + $this->doTestApiRoute('GET', $path); + + $this->beGuest(); + $this->get($path) + ->assertStatus(403); + + $this->beUser(); + $this->get($path) + ->assertStatus(403); + + $this->beEditor(); + $this->get($path) + ->assertStatus(403); + + $this->beAdmin(); + $this->get($path) + ->assertStatus(404); + + $as1 = AnnotationGuideline::create(['project' => $id, 'description' => 'someDescription']); + + $this->get($path) + ->assertStatus(200) + ->assertJson( + [ + 'annotation_guideline' => + ['id' => $as1->id, 'project' => $id, 'description' => 'someDescription'], + 'annotation_guideline_labels' => [], + ] + ); + $label = Label::factory()->create(); + + $asl1 = AnnotationGuidelineLabel::create( + [ + 'annotation_guideline' => $as1->id, + 'label' => $label->id, + 'shape' => Shape::polygonId(), + 'description' => 'labelDescription', + 'reference_image' => false, + ] + ); + + $this->get($path) + ->assertStatus(200) + ->assertJson( + [ + 'annotation_guideline' => + ['id' => $as1->id, 'project' => $id, 'description' => 'someDescription'], + 'annotation_guideline_labels' => [[ + 'description' => 'labelDescription', + 'label' => $label->toArray(), + 'shape' => Shape::polygonId(), + ]], + ] + ); + } + + public function testUpdate() + { + $id = $this->project()->id; + $path = "/api/v1/projects/{$id}/annotation-guideline"; + $this->doTestApiRoute('POST', $path); + + $data = ['description' => 'someDescription']; + + $this->beGuest(); + $this->json('POST', $path, $data) + ->assertStatus(403); + + $this->beUser(); + $this->json('POST', $path, $data) + ->assertStatus(403); + + $this->beEditor(); + $this->json('POST', $path, $data) + ->assertStatus(403); + + $this->beAdmin(); + $this->json('POST', $path, $data) + ->assertStatus(200); + + $as1 = AnnotationGuideline::where(['project' => $id])->first(); + + $this->assertSame($as1->project, $id); + $this->assertSame($as1->description, 'someDescription'); + + $this->json('POST', $path, ['project' => $id, 'description' => 'someNewDescription']) + ->assertStatus(200); + + $as2 = AnnotationGuideline::where(['project' => $id])->first(); + + $this->assertSame($as2->id, $as1->id); + $this->assertSame($as2->project, $id); + $this->assertSame($as2->description, 'someNewDescription'); + } + + public function testDelete() + { + $id = $this->project()->id; + config(['projects.annotation_guideline_storage_disk' => 'annotation_storage']); + $disk = Storage::fake('annotation_storage'); + + $path = "/api/v1/projects/{$id}/annotation-guideline"; + $this->doTestApiRoute('DELETE', $path); + + $this->beGuest(); + $this->delete($path) + ->assertStatus(403); + + $this->beUser(); + $this->delete($path) + ->assertStatus(403); + + $this->beEditor(); + $this->delete($path) + ->assertStatus(403); + + $this->beAdmin(); + $this->delete($path) + ->assertStatus(404); + + AnnotationGuideline::create(['project' => $id, 'description' => 'someDescription']); + + //We also test that the files are cleared + $disk->put("$id/somefile.png", 'content'); + + $this->delete($path) + ->assertStatus(200); + + $this->assertEmpty(AnnotationGuideline::where(['project' => $id])->first()); + $disk->assertMissing("$id/somefile.png"); + } +} diff --git a/tests/php/Http/Controllers/Api/Projects/AnnotationGuidelineLabelControllerTest.php b/tests/php/Http/Controllers/Api/Projects/AnnotationGuidelineLabelControllerTest.php new file mode 100644 index 000000000..172ab943b --- /dev/null +++ b/tests/php/Http/Controllers/Api/Projects/AnnotationGuidelineLabelControllerTest.php @@ -0,0 +1,220 @@ +project()->id; + $as = AnnotationGuideline::create(['project' => $id, 'description' => 'someDescription']); + $path = "/api/v1/projects/{$id}/annotation-guideline-label"; + $label1 = Label::factory()->create(); + + config(['projects.annotation_guideline_storage_disk' => 'annotation_storage']); + $disk = Storage::fake('annotation_storage'); + + $filename = 'test-image.png'; + + $fileDir = __DIR__."/../../../../../files/"; + $imageFile = new UploadedFile($fileDir.$filename, $filename, test: true); + $data = [ + 'annotation_guideline' => $as->id, + 'label' => $label1->id, + 'shape' => Shape::polygonId(), + 'description' => 'labelDescription', + 'reference_image' =>$imageFile, + ]; + + try { + $this->doTestApiRoute('POST', $path); + + $this->beGuest(); + $this->post($path, $data) + ->assertStatus(403); + + $this->beUser(); + $this->post($path, $data) + ->assertStatus(403); + + $this->beEditor(); + $this->post($path, $data) + ->assertStatus(403); + + $this->beAdmin(); + $this->post($path, $data) + ->assertStatus(200); + + $asl = AnnotationGuidelineLabel::where(['annotation_guideline' => $as->id]) + ->get() + ->toArray(); + + + $expected = [[ + 'id' => $asl[0]['id'], + 'annotation_guideline' => $as->id, + 'label' => $label1->id, + 'shape' => Shape::polygonId(), + 'description' => 'labelDescription', + 'reference_image' => true, + ]]; + + $disk->assertExists("{$id}/{$label1->id}.jpg"); + + $this->assertEquals($asl, $expected); + + //Bad file + $filename = 'file.txt'; + $fakeFile = UploadedFile::fake()->create($filename); + $data = [ + 'annotation_guideline' => $as['id'], + 'label' => $label1->id, + 'shape' => Shape::polygonId(), + 'description' => 'labelDescription', + 'reference_image' => $fakeFile, + ]; + + $this->post($path, $data) + ->assertStatus(302); + + //Update two other labels, the first label should not be there. + $label2 = Label::factory()->create(); + $label3 = Label::factory()->create(); + + $data = [ + 'annotation_guideline' => $as->id, + 'label' => $label2->id, + 'shape' => null, + 'description' => null, + 'reference_image' => $imageFile, + ]; + + $this->post($path, $data) + ->assertStatus(200); + + $data = [ + 'annotation_guideline' => $as->id, + 'label' => $label3->id, + 'shape' => Shape::circleId(), + 'description' => 'aDifferentDescription', + 'reference_image' => null, + ]; + + $this->post($path, $data) + ->assertStatus(200); + + + $asl2 = AnnotationGuidelineLabel::where(['annotation_guideline' => $as->id]) + ->orderBy('label') + ->get() + ->toArray(); + + $this->assertCount(3, $asl2); + + $expected = [ + [ + 'id' => $asl2[0]['id'], + 'annotation_guideline' => $as->id, + 'label' => $label1->id, + 'shape' => Shape::polygonId(), + 'description' => 'labelDescription', + 'reference_image' => true, + ], + [ + 'id' => $asl2[1]['id'], + 'annotation_guideline' => $as->id, + 'label' => $label2->id, + 'shape' => null, + 'description' => null, + 'reference_image' => true, + ], + [ + 'id' => $asl2[2]['id'], + 'annotation_guideline' => $as->id, + 'label' => $label3->id, + 'shape' => Shape::circleId(), + 'description' => 'aDifferentDescription', + 'reference_image' => false, + ], + ]; + + $this->assertEquals($asl2, $expected); + + + //Should have been uploaded + $disk->assertExists("{$id}/{$label1->id}.jpg"); + $disk->assertExists("{$id}/{$label2->id}.jpg"); + + //Should not exist + $disk->assertMissing("{$id}/{$label3->id}.jpg"); + + $path = "/api/v1/projects/{$id}/annotation-guideline-label"; + $data = ['label' => $label1->id]; + + $this->beGuest(); + $this->delete($path, $data) + ->assertStatus(403); + + $this->beUser(); + $this->delete($path, $data) + ->assertStatus(403); + + $this->beEditor(); + $this->delete($path, $data) + ->assertStatus(403); + + $this->beAdmin(); + $this->delete($path, $data) + ->assertStatus(200); + + $asl = AnnotationGuidelineLabel::where(['annotation_guideline' => $as->id, 'label' => $label1->id]) + ->get() + ->toArray(); + + $this->assertEmpty($asl); + + $disk->assertMissing("{$id}/{$label1->id}.jpg"); + + //delete image + $path = "/api/v1/projects/{$id}/annotation-guideline-label/delete-image"; + + $data = ['label' => $label2->id]; + + $this->beGuest(); + $this->delete($path, $data) + ->assertStatus(403); + + $this->beUser(); + $this->delete($path, $data) + ->assertStatus(403); + + $this->beEditor(); + $this->delete($path, $data) + ->assertStatus(403); + + $this->beAdmin(); + $this->delete($path, $data) + ->assertStatus(200); + + $disk->assertMissing("{$id}/{$label2->id}.jpg"); + } finally { + if (isset($label1) && $disk->exists("{$id}/{$label1->id}.jpg")) { + $disk->delete("{$id}/{$label1->id}.jpg"); + } + if (isset($label2) && $disk->exists("{$id}/{$label2->id}.jpg")) { + $disk->delete("{$id}/{$label2->id}.jpg"); + } + if (isset($label3) && $disk->exists("{$id}/{$label3->id}.jpg")) { + $disk->delete("{$id}/{$label3->id}.jpg"); + } + } + } +} diff --git a/tests/php/Http/Controllers/Views/Projects/AnnotationGuidelineControllerTest.php b/tests/php/Http/Controllers/Views/Projects/AnnotationGuidelineControllerTest.php new file mode 100644 index 000000000..91d4bb3b9 --- /dev/null +++ b/tests/php/Http/Controllers/Views/Projects/AnnotationGuidelineControllerTest.php @@ -0,0 +1,43 @@ +project()->id; + + $path = "projects/{$id}/annotation-guideline"; + + $this->beGuest(); + $this->get($path)->assertStatus(404); + + $this->beEditor(); + $this->get($path)->assertStatus(404); + + //Admins can create annotation guidelines + $this->beAdmin(); + $this->get($path)->assertStatus(200); + + $this->beGlobalAdmin(); + $this->get($path)->assertStatus(200); + + AnnotationGuideline::create(['project' => $id, 'description' => 'someDescription']); + + $this->beGuest(); + $this->get($path)->assertStatus(200); + + $this->beEditor(); + $this->get($path)->assertStatus(200); + + $this->beAdmin(); + $this->get($path)->assertStatus(200); + + $this->beGlobalAdmin(); + $this->get($path)->assertStatus(200); + + } +}