diff --git a/app/Http/Controllers/Api/ImageAnnotationController.php b/app/Http/Controllers/Api/ImageAnnotationController.php index a8b435159..d8a96c9ee 100644 --- a/app/Http/Controllers/Api/ImageAnnotationController.php +++ b/app/Http/Controllers/Api/ImageAnnotationController.php @@ -86,6 +86,13 @@ public function index(Request $request, $id) } else { $yieldAnnotations = function () use ($image, $load): Generator { foreach ($image->annotations()->with($load)->lazy() as $annotation) { + // Delete saved unlabeled LabelBOT annotations + // Such annotations are only created when LabelBOT returns no results + // and the user refreshes/closes the BIIGLE session without interacting with the empty LabelBOT popup. + if ($annotation->labels->isEmpty()) { + $annotation->delete(); + continue; + } yield $annotation; } }; @@ -236,15 +243,19 @@ public function store(StoreImageAnnotation $request, LabelBotService $labelBotSe } } - $this->authorize('attach-label', [$annotation, $label]); + if ($label) { + $this->authorize('attach-label', [$annotation, $label]); + } DB::transaction(function () use ($annotation, $request, $label) { $annotation->save(); - $annotationLabel = new ImageAnnotationLabel; - $annotationLabel->label_id = $label->id; - $annotationLabel->user_id = $request->user()->id; - $annotationLabel->confidence = $request->input('confidence'); - $annotation->labels()->save($annotationLabel); + if ($label) { + $annotationLabel = new ImageAnnotationLabel; + $annotationLabel->label_id = $label->id; + $annotationLabel->user_id = $request->user()->id; + $annotationLabel->confidence = $request->input('confidence'); + $annotation->labels()->save($annotationLabel); + } }); $annotation->load('labels.label', 'labels.user'); diff --git a/app/Http/Controllers/Api/VideoAnnotationController.php b/app/Http/Controllers/Api/VideoAnnotationController.php index 76a4c260f..de9a87642 100644 --- a/app/Http/Controllers/Api/VideoAnnotationController.php +++ b/app/Http/Controllers/Api/VideoAnnotationController.php @@ -241,15 +241,19 @@ public function store(StoreVideoAnnotation $request, LabelBotService $labelBotSe } } - $this->authorize('attach-label', [$annotation, $label]); + if ($label) { + $this->authorize('attach-label', [$annotation, $label]); + } $annotation = DB::transaction(function () use ($annotation, $request, $label) { $annotation->save(); - VideoAnnotationLabel::create([ - 'label_id' => $label->id, - 'user_id' => $request->user()->id, - 'annotation_id' => $annotation->id, - ]); + if ($label) { + VideoAnnotationLabel::create([ + 'label_id' => $label->id, + 'user_id' => $request->user()->id, + 'annotation_id' => $annotation->id, + ]); + } return $annotation; }); diff --git a/app/Services/LabelBot/LabelBotService.php b/app/Services/LabelBot/LabelBotService.php index 804504bed..ca801d568 100644 --- a/app/Services/LabelBot/LabelBotService.php +++ b/app/Services/LabelBot/LabelBotService.php @@ -15,7 +15,6 @@ use Illuminate\Http\Request; use InvalidArgumentException; use Pgvector\Laravel\Vector; -use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException; class LabelBotService @@ -53,7 +52,7 @@ public function getLabelsForAnnotation( } if (empty($topNLabels)) { - throw new NotFoundHttpException("LabelBOT could not find similar annotations."); + return []; } $labelModels = Label::whereIn('id', $topNLabels)->get()->keyBy('id'); diff --git a/resources/assets/js/annotations/annotatorContainer.vue b/resources/assets/js/annotations/annotatorContainer.vue index ff5596dcf..259dc3f13 100644 --- a/resources/assets/js/annotations/annotatorContainer.vue +++ b/resources/assets/js/annotations/annotatorContainer.vue @@ -449,6 +449,10 @@ export default { promise.then((annotation) => { if (imageId === this.imageId) { this.showLabelbotPopup(annotation); + } else if (!annotation.labels || annotation.labels?.length === 0) { + // We delete the unlabeled annotation if the image has been switched + // while LabelBOT is computing + this.handleDeleteAnnotation(annotation); } }); } else { @@ -495,9 +499,19 @@ export default { .then(() => { if (lastLabel) { this.handleDetachAnnotationLabel(annotation, lastLabel); + } else { + // In case LabelBOT returned no results and the user selected a label by themselves, there is no label to detach. + // In this case, we need to refresh the annotation to show the attached label. + this.refreshSingleAnnotation(annotation); } }) - .catch(handleErrorResponse); + .catch((error) => { + if (annotation.labels.length === 0) { + this.handleDeleteAnnotation(annotation); + } + + handleErrorResponse(error); + }); } }, handleForceSwapLabel(annotation, label) { @@ -544,6 +558,8 @@ export default { Promise.all(toCache).catch(function () {}); }, setLastCreatedAnnotation(annotation) { + if (annotation.labels?.length === 0) return; + if (this.lastCreatedAnnotationTimeout) { window.clearTimeout(this.lastCreatedAnnotationTimeout); } diff --git a/resources/assets/js/annotations/components/labelbotPopup.vue b/resources/assets/js/annotations/components/labelbotPopup.vue index 18fc6800f..3ddba5c4d 100644 --- a/resources/assets/js/annotations/components/labelbotPopup.vue +++ b/resources/assets/js/annotations/components/labelbotPopup.vue @@ -10,7 +10,12 @@ @@ -112,6 +117,7 @@ export default { dragging: false, dragStartMousePosition: [0, 0], dragStartOverlayOffset: [0, 0], + deletePendingLabelbotAnnotation: true, // needed when LabelBOT returns no suggestions }; }, computed: { @@ -147,7 +153,13 @@ export default { return this.popupKey === this.focusedPopupKey; }, labels() { - return [this.annotation.labels[0].label].concat(this.annotation.labelBOTLabels); + const suggestedLabels = this.annotation.labelBOTLabels || []; + const top1 = this.annotation.labels?.[0]?.label; + + return top1 ? [top1, ...suggestedLabels] : suggestedLabels; + }, + noLabels() { + return this.labels.length === 0; }, classObject() { return { @@ -184,13 +196,19 @@ export default { methods: { updateAndClose(label) { // Top 1 label is already attached/selected - if (this.selectedLabel.id !== label.id) { - this.$emit('update', {label: label, annotation: this.annotation}); + if (this.noLabels || this.selectedLabel.id !== label.id) { + this.$emit('update', {label: label, annotation: this.annotation}) + this.deletePendingLabelbotAnnotation = false; } this.emitClose(); }, confirmAndClose() { + if (this.noLabels) { + this.deleteLabelAnnotation(); + return; + } + this.emitClose(); Events.emit('labelbot.chose_label_1'); }, @@ -344,7 +362,13 @@ export default { const line = new LineString([popupPosition, popupPosition]); this.lineFeature = markRaw(new Feature(line)); this.lineFeature.set('unselectable', true); - this.lineFeature.set('color', this.labels[0].color); + + if (this.noLabels) { + annotationFeature.setStyle(Styles.editing); + } else { + this.lineFeature.set('color', this.labels[0].color); + } + this.lineFeature.setStyle(Styles.editing); this.lineFeature._updateCoordinates = () => { @@ -415,12 +439,24 @@ export default { this.createOverlay(this.$parent); this.$refs.popupTypeahead?.$refs.input?.addEventListener("keydown", this.handleTypeaheadKey); + + // Autofocus the typeahead input if no labels were suggested by LabelBOT + if (this.noLabels) { + this.$nextTick(() => { + this.$refs.popupTypeahead?.$refs.input?.focus(); + this.typeaheadFocused = true; + }); + } }, beforeUnmount() { this.$parent.map.removeOverlay(this.overlay); this.$parent.labelbotSource.removeFeature(this.lineFeature); this.listenerKeys.forEach(unByKey); + if (this.noLabels && this.deletePendingLabelbotAnnotation) { + this.$emit('delete', this.annotation); + } + Keyboard.off('Escape', this.handleEsc, 'labelbot'); Keyboard.off('Backspace', this.deleteLabelAnnotation, 'labelbot'); Keyboard.off('Tab', this.enterTypeahead, 'labelbot'); diff --git a/resources/assets/sass/annotations/components/_labelbot-popup.scss b/resources/assets/sass/annotations/components/_labelbot-popup.scss index 8c9f16d4f..4cb7085a8 100644 --- a/resources/assets/sass/annotations/components/_labelbot-popup.scss +++ b/resources/assets/sass/annotations/components/_labelbot-popup.scss @@ -18,6 +18,14 @@ background-color: $body-bg; } +.labelbot-popup__message { + list-style-type: none; + max-width: 300px; + margin: 0; + padding: $padding-small-vertical; + background-color: $body-bg; +} + .labelbot-label__color { position: absolute; top: 14px; diff --git a/tests/php/Http/Controllers/Api/ImageAnnotationControllerTest.php b/tests/php/Http/Controllers/Api/ImageAnnotationControllerTest.php index 35150a3c9..44ee0ac9b 100644 --- a/tests/php/Http/Controllers/Api/ImageAnnotationControllerTest.php +++ b/tests/php/Http/Controllers/Api/ImageAnnotationControllerTest.php @@ -335,7 +335,21 @@ public function testStoreWithFeatureVectorWithoutHNSW() $label = LabelTest::create(); // Label must be attached to a label tree $this->project()->labelTrees()->attach($label->label_tree_id); - // Save it in DB + + // Run with empty database + $response = $this->json('POST', "/api/v1/images/{$this->image->id}/annotations", [ + 'shape_id' => Shape::pointId(), + 'feature_vector' => range(1, 384), + 'confidence' => 0.5, + 'points' => [10, 11], + ]); + $response->assertSuccessful(); + + // We expect an empty array + $response->assertJsonPath('labels', []); + $response->assertJsonPath('labelBOTLabels', []); + + // Save label in DB ImageAnnotationLabelFeatureVector::factory()->create([ 'volume_id' => $this->volume()->id, 'label_id' => $label->id,