From 41ff1ebc5fe40843e92938b736d453ce944f43fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Langenk=C3=A4mper?= Date: Wed, 23 Jul 2025 11:26:50 +0200 Subject: [PATCH] implements a size filter for Largo --- ...ilterImageAnnotationsByLabelController.php | 78 ++++++++++- ...ilterVideoAnnotationsByLabelController.php | 78 ++++++++++- ...ilterImageAnnotationsByLabelController.php | 78 ++++++++++- ...ilterVideoAnnotationsByLabelController.php | 78 ++++++++++- .../Views/Volumes/VolumeChartsController.php | 0 app/Services/AnnotationSizeCalculator.php | 129 ++++++++++++++++++ .../js/largo/components/annotationFilter.vue | 13 +- .../manual/tutorials/largo/largo.blade.php | 1 + .../AnnotationSizeCalculatorBasicTest.php | 29 ++++ .../Services/AnnotationSizeCalculatorTest.php | 85 ++++++++++++ .../AnnotationSizeCalculatorUnitTest.php | 64 +++++++++ .../Services/AnnotationSizeCalculatorTest.php | 115 ++++++++++++++++ 12 files changed, 742 insertions(+), 6 deletions(-) create mode 100644 app/Http/Controllers/Views/Volumes/VolumeChartsController.php create mode 100644 app/Services/AnnotationSizeCalculator.php create mode 100644 tests/php/Services/AnnotationSizeCalculatorBasicTest.php create mode 100644 tests/php/Services/AnnotationSizeCalculatorTest.php create mode 100644 tests/php/Services/AnnotationSizeCalculatorUnitTest.php create mode 100644 tests/php/unit/Services/AnnotationSizeCalculatorTest.php diff --git a/app/Http/Controllers/Api/Projects/FilterImageAnnotationsByLabelController.php b/app/Http/Controllers/Api/Projects/FilterImageAnnotationsByLabelController.php index 04a9a09b66..d8dc34befa 100644 --- a/app/Http/Controllers/Api/Projects/FilterImageAnnotationsByLabelController.php +++ b/app/Http/Controllers/Api/Projects/FilterImageAnnotationsByLabelController.php @@ -23,6 +23,7 @@ class FilterImageAnnotationsByLabelController extends Controller * @apiParam (Optional arguments) {Number} take Number of image annotations to return. If this parameter is present, the most recent annotations will be returned first. Default is unlimited. * @apiParam (Optional arguments) {Array} shape_id Array of shape ids to use to filter images * @apiParam (Optional arguments) {Array} user_id Array of user ids to use to filter values + * @apiParam (Optional arguments) {Array} annotation_size Array of annotation size category ids to use to filter values * @apiParam (Optional arguments) {Boolean} union Whether the filters should be considered inclusive (OR) or exclusive (AND) * @apiPermission projectMember * @apiDescription Returns a map of image annotation IDs to their image UUIDs. @@ -43,6 +44,8 @@ public function index(Request $request, $pid, $lid) 'shape_id.*' => 'integer', 'user_id' => 'array', 'user_id.*' => 'integer', + 'annotation_size' => 'array', + 'annotation_size.*' => 'integer', 'union' => 'boolean', ]); @@ -50,6 +53,7 @@ public function index(Request $request, $pid, $lid) $filters = [ 'shape_id' => $request->input('shape_id'), 'user_id' => $request->input('user_id'), + 'annotation_size' => $request->input('annotation_size'), ]; $filters = array_filter($filters); $union = $request->input('union', false); @@ -63,10 +67,82 @@ public function index(Request $request, $pid, $lid) }) ->when(!is_null($take), fn ($query) => $query->take($take)) ->where('image_annotation_labels.label_id', $lid) - ->when(!empty($filters), fn ($query) => $this->compileFilterConditions($query, $union, $filters)) + ->when(!empty($filters), function ($query) use ($union, $filters) { + // Handle annotation size filtering separately as it requires calculation + if (isset($filters['annotation_size'])) { + $sizeValues = $filters['annotation_size']; + + if ($union) { + $query->where(function ($q) use ($sizeValues) { + foreach ($sizeValues as $value) { + if ($value < 0) { + $q->orWhereRaw($this->getAnnotationSizeQuery(abs($value), true)); + } else { + $q->orWhereRaw($this->getAnnotationSizeQuery($value, false)); + } + } + }); + } else { + foreach ($sizeValues as $value) { + if ($value < 0) { + $query->whereRaw($this->getAnnotationSizeQuery(abs($value), true)); + } else { + $query->whereRaw($this->getAnnotationSizeQuery($value, false)); + } + } + } + + // Remove annotation_size from filters array to avoid double processing + unset($filters['annotation_size']); + } + + // Process remaining filters normally + if (!empty($filters)) { + $this->compileFilterConditions($query, $union, $filters); + } + }) ->select('images.uuid', 'image_annotations.id') ->distinct() ->orderBy('image_annotations.id', 'desc') ->pluck('images.uuid', 'image_annotations.id'); } + + /** + * Get the SQL query for filtering annotations by size category. + * + * @param int $categoryId The size category ID + * @param bool $negate Whether to negate the condition + * @return string The SQL condition + */ + private function getAnnotationSizeQuery(int $categoryId, bool $negate = false): string + { + $operator = $negate ? '!=' : '='; + + return "CASE + WHEN image_annotations.shape_id = " . \Biigle\Shape::circleId() . " THEN + CASE + WHEN POW((image_annotations.points->>2)::float, 2) * PI() = 0 THEN 0 + WHEN POW((image_annotations.points->>2)::float, 2) * PI() < 100 THEN 1 + WHEN POW((image_annotations.points->>2)::float, 2) * PI() < 1000 THEN 2 + WHEN POW((image_annotations.points->>2)::float, 2) * PI() < 10000 THEN 3 + WHEN POW((image_annotations.points->>2)::float, 2) * PI() < 100000 THEN 4 + ELSE 5 + END + WHEN image_annotations.shape_id = " . \Biigle\Shape::rectangleId() . " THEN + CASE + WHEN SQRT(POW((image_annotations.points->>0)::float - (image_annotations.points->>2)::float, 2) + POW((image_annotations.points->>1)::float - (image_annotations.points->>3)::float, 2)) * SQRT(POW((image_annotations.points->>2)::float - (image_annotations.points->>4)::float, 2) + POW((image_annotations.points->>3)::float - (image_annotations.points->>5)::float, 2)) = 0 THEN 0 + WHEN SQRT(POW((image_annotations.points->>0)::float - (image_annotations.points->>2)::float, 2) + POW((image_annotations.points->>1)::float - (image_annotations.points->>3)::float, 2)) * SQRT(POW((image_annotations.points->>2)::float - (image_annotations.points->>4)::float, 2) + POW((image_annotations.points->>3)::float - (image_annotations.points->>5)::float, 2)) < 100 THEN 1 + WHEN SQRT(POW((image_annotations.points->>0)::float - (image_annotations.points->>2)::float, 2) + POW((image_annotations.points->>1)::float - (image_annotations.points->>3)::float, 2)) * SQRT(POW((image_annotations.points->>2)::float - (image_annotations.points->>4)::float, 2) + POW((image_annotations.points->>3)::float - (image_annotations.points->>5)::float, 2)) < 1000 THEN 2 + WHEN SQRT(POW((image_annotations.points->>0)::float - (image_annotations.points->>2)::float, 2) + POW((image_annotations.points->>1)::float - (image_annotations.points->>3)::float, 2)) * SQRT(POW((image_annotations.points->>2)::float - (image_annotations.points->>4)::float, 2) + POW((image_annotations.points->>3)::float - (image_annotations.points->>5)::float, 2)) < 10000 THEN 3 + WHEN SQRT(POW((image_annotations.points->>0)::float - (image_annotations.points->>2)::float, 2) + POW((image_annotations.points->>1)::float - (image_annotations.points->>3)::float, 2)) * SQRT(POW((image_annotations.points->>2)::float - (image_annotations.points->>4)::float, 2) + POW((image_annotations.points->>3)::float - (image_annotations.points->>5)::float, 2)) < 100000 THEN 4 + ELSE 5 + END + WHEN image_annotations.shape_id IN (" . \Biigle\Shape::pointId() . ", " . \Biigle\Shape::lineId() . ") THEN 0 + ELSE + CASE + WHEN json_array_length(image_annotations.points) < 6 THEN 0 + ELSE 2 + END + END {$operator} {$categoryId}"; + } } diff --git a/app/Http/Controllers/Api/Projects/FilterVideoAnnotationsByLabelController.php b/app/Http/Controllers/Api/Projects/FilterVideoAnnotationsByLabelController.php index 4404e04cf1..e2994357ba 100644 --- a/app/Http/Controllers/Api/Projects/FilterVideoAnnotationsByLabelController.php +++ b/app/Http/Controllers/Api/Projects/FilterVideoAnnotationsByLabelController.php @@ -23,6 +23,7 @@ class FilterVideoAnnotationsByLabelController extends Controller * @apiParam (Optional arguments) {Number} take Number of video annotations to return. If this parameter is present, the most recent annotations will be returned first. Default is unlimited. * @apiParam (Optional arguments) {Array} shape_id Array of shape ids to use to filter images * @apiParam (Optional arguments) {Array} user_id Array of user ids to use to filter values + * @apiParam (Optional arguments) {Array} annotation_size Array of annotation size category ids to use to filter values * @apiParam (Optional arguments) {Boolean} union Whether the filters should be considered inclusive (OR) or exclusive (AND) * @apiPermission projectMember * @apiDescription Returns a map of video annotation IDs to their video UUIDs. @@ -43,6 +44,8 @@ public function index(Request $request, $pid, $lid) 'shape_id.*' => 'integer', 'user_id' => 'array', 'user_id.*' => 'integer', + 'annotation_size' => 'array', + 'annotation_size.*' => 'integer', 'union' => 'boolean', ]); @@ -50,6 +53,7 @@ public function index(Request $request, $pid, $lid) $filters = [ 'shape_id' => $request->input('shape_id'), 'user_id' => $request->input('user_id'), + 'annotation_size' => $request->input('annotation_size'), ]; $filters = array_filter($filters); $union = $request->input('union', false); @@ -62,11 +66,83 @@ public function index(Request $request, $pid, $lid) ->where('project_id', $pid); }) ->where('video_annotation_labels.label_id', $lid) - ->when(!empty($filters), fn ($query) => $this->compileFilterConditions($query, $union, $filters)) + ->when(!empty($filters), function ($query) use ($union, $filters) { + // Handle annotation size filtering separately as it requires calculation + if (isset($filters['annotation_size'])) { + $sizeValues = $filters['annotation_size']; + + if ($union) { + $query->where(function ($q) use ($sizeValues) { + foreach ($sizeValues as $value) { + if ($value < 0) { + $q->orWhereRaw($this->getAnnotationSizeQuery(abs($value), true)); + } else { + $q->orWhereRaw($this->getAnnotationSizeQuery($value, false)); + } + } + }); + } else { + foreach ($sizeValues as $value) { + if ($value < 0) { + $query->whereRaw($this->getAnnotationSizeQuery(abs($value), true)); + } else { + $query->whereRaw($this->getAnnotationSizeQuery($value, false)); + } + } + } + + // Remove annotation_size from filters array to avoid double processing + unset($filters['annotation_size']); + } + + // Process remaining filters normally + if (!empty($filters)) { + $this->compileFilterConditions($query, $union, $filters); + } + }) ->when(!is_null($take), fn ($query) => $query->take($take)) ->select('videos.uuid', 'video_annotations.id') ->distinct() ->orderBy('video_annotations.id', 'desc') ->pluck('videos.uuid', 'video_annotations.id'); } + + /** + * Get the SQL query for filtering video annotations by size category. + * + * @param int $categoryId The size category ID + * @param bool $negate Whether to negate the condition + * @return string The SQL condition + */ + private function getAnnotationSizeQuery(int $categoryId, bool $negate = false): string + { + $operator = $negate ? '!=' : '='; + + return "CASE + WHEN video_annotations.shape_id = " . \Biigle\Shape::circleId() . " THEN + CASE + WHEN POW((video_annotations.points->0->>2)::float, 2) * PI() = 0 THEN 0 + WHEN POW((video_annotations.points->0->>2)::float, 2) * PI() < 100 THEN 1 + WHEN POW((video_annotations.points->0->>2)::float, 2) * PI() < 1000 THEN 2 + WHEN POW((video_annotations.points->0->>2)::float, 2) * PI() < 10000 THEN 3 + WHEN POW((video_annotations.points->0->>2)::float, 2) * PI() < 100000 THEN 4 + ELSE 5 + END + WHEN video_annotations.shape_id = " . \Biigle\Shape::rectangleId() . " THEN + CASE + WHEN SQRT(POW((video_annotations.points->0->>0)::float - (video_annotations.points->0->>2)::float, 2) + POW((video_annotations.points->0->>1)::float - (video_annotations.points->0->>3)::float, 2)) * SQRT(POW((video_annotations.points->0->>2)::float - (video_annotations.points->0->>4)::float, 2) + POW((video_annotations.points->0->>3)::float - (video_annotations.points->0->>5)::float, 2)) = 0 THEN 0 + WHEN SQRT(POW((video_annotations.points->0->>0)::float - (video_annotations.points->0->>2)::float, 2) + POW((video_annotations.points->0->>1)::float - (video_annotations.points->0->>3)::float, 2)) * SQRT(POW((video_annotations.points->0->>2)::float - (video_annotations.points->0->>4)::float, 2) + POW((video_annotations.points->0->>3)::float - (video_annotations.points->0->>5)::float, 2)) < 100 THEN 1 + WHEN SQRT(POW((video_annotations.points->0->>0)::float - (video_annotations.points->0->>2)::float, 2) + POW((video_annotations.points->0->>1)::float - (video_annotations.points->0->>3)::float, 2)) * SQRT(POW((video_annotations.points->0->>2)::float - (video_annotations.points->0->>4)::float, 2) + POW((video_annotations.points->0->>3)::float - (video_annotations.points->0->>5)::float, 2)) < 1000 THEN 2 + WHEN SQRT(POW((video_annotations.points->0->>0)::float - (video_annotations.points->0->>2)::float, 2) + POW((video_annotations.points->0->>1)::float - (video_annotations.points->0->>3)::float, 2)) * SQRT(POW((video_annotations.points->0->>2)::float - (video_annotations.points->0->>4)::float, 2) + POW((video_annotations.points->0->>3)::float - (video_annotations.points->0->>5)::float, 2)) < 10000 THEN 3 + WHEN SQRT(POW((video_annotations.points->0->>0)::float - (video_annotations.points->0->>2)::float, 2) + POW((video_annotations.points->0->>1)::float - (video_annotations.points->0->>3)::float, 2)) * SQRT(POW((video_annotations.points->0->>2)::float - (video_annotations.points->0->>4)::float, 2) + POW((video_annotations.points->0->>3)::float - (video_annotations.points->0->>5)::float, 2)) < 100000 THEN 4 + ELSE 5 + END + WHEN video_annotations.shape_id IN (" . \Biigle\Shape::pointId() . ", " . \Biigle\Shape::lineId() . ") THEN 0 + ELSE + CASE + WHEN json_array_length(video_annotations.points->0) < 6 THEN 0 + ELSE 2 + END + END {$operator} {$categoryId}"; + } } diff --git a/app/Http/Controllers/Api/Volumes/FilterImageAnnotationsByLabelController.php b/app/Http/Controllers/Api/Volumes/FilterImageAnnotationsByLabelController.php index bd2b1804ce..fc1f843e55 100644 --- a/app/Http/Controllers/Api/Volumes/FilterImageAnnotationsByLabelController.php +++ b/app/Http/Controllers/Api/Volumes/FilterImageAnnotationsByLabelController.php @@ -22,6 +22,7 @@ class FilterImageAnnotationsByLabelController extends Controller * @apiParam (Optional arguments) {Number} take Number of image annotations to return. If this parameter is present, the most recent annotations will be returned first. Default is unlimited. * @apiParam (Optional arguments) {Array} shape_id Array of shape ids to use to filter images * @apiParam (Optional arguments) {Array} user_id Array of user ids to use to filter values + * @apiParam (Optional arguments) {Array} annotation_size Array of annotation size category ids to use to filter values * @apiParam (Optional arguments) {Boolean} union Whether the filters should be considered inclusive (OR) or exclusive (AND) * @apiPermission projectMember * @apiDescription Returns a map of image annotation IDs to their image UUIDs. If there is an active annotation session, annotations hidden by the session are not returned. Only available for image volumes. @@ -42,6 +43,8 @@ public function index(Request $request, $vid, $lid) 'shape_id.*' => 'integer', 'user_id' => 'array', 'user_id.*' => 'integer', + 'annotation_size' => 'array', + 'annotation_size.*' => 'integer', 'union' => 'boolean', ]); @@ -50,6 +53,7 @@ public function index(Request $request, $vid, $lid) $filters = [ 'shape_id' => $request->input('shape_id'), 'user_id' => $request->input('user_id'), + 'annotation_size' => $request->input('annotation_size'), ]; $filters = array_filter($filters); $union = $request->input('union', false); @@ -67,7 +71,40 @@ public function index(Request $request, $vid, $lid) ->where('images.volume_id', $vid) ->where('image_annotation_labels.label_id', $lid) ->when(!is_null($take), fn ($query) => $query->take($take)) - ->when(!empty($filters), fn ($query) => $this->compileFilterConditions($query, $union, $filters)) + ->when(!empty($filters), function ($query) use ($union, $filters) { + // Handle annotation size filtering separately as it requires calculation + if (isset($filters['annotation_size'])) { + $sizeValues = $filters['annotation_size']; + + if ($union) { + $query->where(function ($q) use ($sizeValues) { + foreach ($sizeValues as $value) { + if ($value < 0) { + $q->orWhereRaw($this->getAnnotationSizeQuery(abs($value), true)); + } else { + $q->orWhereRaw($this->getAnnotationSizeQuery($value, false)); + } + } + }); + } else { + foreach ($sizeValues as $value) { + if ($value < 0) { + $query->whereRaw($this->getAnnotationSizeQuery(abs($value), true)); + } else { + $query->whereRaw($this->getAnnotationSizeQuery($value, false)); + } + } + } + + // Remove annotation_size from filters array to avoid double processing + unset($filters['annotation_size']); + } + + // Process remaining filters normally + if (!empty($filters)) { + $this->compileFilterConditions($query, $union, $filters); + } + }) ->when($session, function ($query) use ($session, $request) { if ($session->hide_other_users_annotations) { $query->where('image_annotation_labels.user_id', $request->user()->id); @@ -78,4 +115,43 @@ public function index(Request $request, $vid, $lid) ->orderBy('image_annotations.id', 'desc') ->pluck('images.uuid', 'image_annotations.id'); } + + /** + * Get the SQL query for filtering annotations by size category. + * + * @param int $categoryId The size category ID + * @param bool $negate Whether to negate the condition + * @return string The SQL condition + */ + private function getAnnotationSizeQuery(int $categoryId, bool $negate = false): string + { + $operator = $negate ? '!=' : '='; + + return "CASE + WHEN image_annotations.shape_id = " . \Biigle\Shape::circleId() . " THEN + CASE + WHEN POW((image_annotations.points->>2)::float, 2) * PI() = 0 THEN 0 + WHEN POW((image_annotations.points->>2)::float, 2) * PI() < 100 THEN 1 + WHEN POW((image_annotations.points->>2)::float, 2) * PI() < 1000 THEN 2 + WHEN POW((image_annotations.points->>2)::float, 2) * PI() < 10000 THEN 3 + WHEN POW((image_annotations.points->>2)::float, 2) * PI() < 100000 THEN 4 + ELSE 5 + END + WHEN image_annotations.shape_id = " . \Biigle\Shape::rectangleId() . " THEN + CASE + WHEN SQRT(POW((image_annotations.points->>0)::float - (image_annotations.points->>2)::float, 2) + POW((image_annotations.points->>1)::float - (image_annotations.points->>3)::float, 2)) * SQRT(POW((image_annotations.points->>2)::float - (image_annotations.points->>4)::float, 2) + POW((image_annotations.points->>3)::float - (image_annotations.points->>5)::float, 2)) = 0 THEN 0 + WHEN SQRT(POW((image_annotations.points->>0)::float - (image_annotations.points->>2)::float, 2) + POW((image_annotations.points->>1)::float - (image_annotations.points->>3)::float, 2)) * SQRT(POW((image_annotations.points->>2)::float - (image_annotations.points->>4)::float, 2) + POW((image_annotations.points->>3)::float - (image_annotations.points->>5)::float, 2)) < 100 THEN 1 + WHEN SQRT(POW((image_annotations.points->>0)::float - (image_annotations.points->>2)::float, 2) + POW((image_annotations.points->>1)::float - (image_annotations.points->>3)::float, 2)) * SQRT(POW((image_annotations.points->>2)::float - (image_annotations.points->>4)::float, 2) + POW((image_annotations.points->>3)::float - (image_annotations.points->>5)::float, 2)) < 1000 THEN 2 + WHEN SQRT(POW((image_annotations.points->>0)::float - (image_annotations.points->>2)::float, 2) + POW((image_annotations.points->>1)::float - (image_annotations.points->>3)::float, 2)) * SQRT(POW((image_annotations.points->>2)::float - (image_annotations.points->>4)::float, 2) + POW((image_annotations.points->>3)::float - (image_annotations.points->>5)::float, 2)) < 10000 THEN 3 + WHEN SQRT(POW((image_annotations.points->>0)::float - (image_annotations.points->>2)::float, 2) + POW((image_annotations.points->>1)::float - (image_annotations.points->>3)::float, 2)) * SQRT(POW((image_annotations.points->>2)::float - (image_annotations.points->>4)::float, 2) + POW((image_annotations.points->>3)::float - (image_annotations.points->>5)::float, 2)) < 100000 THEN 4 + ELSE 5 + END + WHEN image_annotations.shape_id IN (" . \Biigle\Shape::pointId() . ", " . \Biigle\Shape::lineId() . ") THEN 0 + ELSE + CASE + WHEN json_array_length(image_annotations.points) < 6 THEN 0 + ELSE 2 + END + END {$operator} {$categoryId}"; + } } diff --git a/app/Http/Controllers/Api/Volumes/FilterVideoAnnotationsByLabelController.php b/app/Http/Controllers/Api/Volumes/FilterVideoAnnotationsByLabelController.php index f5707a2253..2cc29bed17 100644 --- a/app/Http/Controllers/Api/Volumes/FilterVideoAnnotationsByLabelController.php +++ b/app/Http/Controllers/Api/Volumes/FilterVideoAnnotationsByLabelController.php @@ -23,6 +23,7 @@ class FilterVideoAnnotationsByLabelController extends Controller * @apiParam (Optional arguments) {Number} take Number of video annotations to return. If this parameter is present, the most recent annotations will be returned first. Default is unlimited. * @apiParam (Optional arguments) {Array} shape_id Array of shape ids to use to filter images * @apiParam (Optional arguments) {Array} user_id Array of user ids to use to filter values + * @apiParam (Optional arguments) {Array} annotation_size Array of annotation size category ids to use to filter values * @apiParam (Optional arguments) {Boolean} union Whether the filters should be considered inclusive (OR) or exclusive (AND) * @apiPermission projectMember * @apiDescription Returns a map of video annotation IDs to their video UUIDs. If there is an active annotation session, annotations hidden by the session are not returned. Only available for video volumes. @@ -43,6 +44,8 @@ public function index(Request $request, $vid, $lid) 'shape_id.*' => 'integer', 'user_id' => 'array', 'user_id.*' => 'integer', + 'annotation_size' => 'array', + 'annotation_size.*' => 'integer', 'union' => 'boolean', ]); @@ -50,6 +53,7 @@ public function index(Request $request, $vid, $lid) $filters = [ 'shape_id' => $request->input('shape_id'), 'user_id' => $request->input('user_id'), + 'annotation_size' => $request->input('annotation_size'), ]; $filters = array_filter($filters); $union = $request->input('union', false); @@ -66,7 +70,40 @@ public function index(Request $request, $vid, $lid) ->join('videos', 'video_annotations.video_id', '=', 'videos.id') ->where('videos.volume_id', $vid) ->where('video_annotation_labels.label_id', $lid) - ->when(!empty($filters), fn ($query) => $this->compileFilterConditions($query, $union, $filters)) + ->when(!empty($filters), function ($query) use ($union, $filters) { + // Handle annotation size filtering separately as it requires calculation + if (isset($filters['annotation_size'])) { + $sizeValues = $filters['annotation_size']; + + if ($union) { + $query->where(function ($q) use ($sizeValues) { + foreach ($sizeValues as $value) { + if ($value < 0) { + $q->orWhereRaw($this->getAnnotationSizeQuery(abs($value), true)); + } else { + $q->orWhereRaw($this->getAnnotationSizeQuery($value, false)); + } + } + }); + } else { + foreach ($sizeValues as $value) { + if ($value < 0) { + $query->whereRaw($this->getAnnotationSizeQuery(abs($value), true)); + } else { + $query->whereRaw($this->getAnnotationSizeQuery($value, false)); + } + } + } + + // Remove annotation_size from filters array to avoid double processing + unset($filters['annotation_size']); + } + + // Process remaining filters normally + if (!empty($filters)) { + $this->compileFilterConditions($query, $union, $filters); + } + }) ->when($session, function ($query) use ($session, $request) { if ($session->hide_other_users_annotations) { $query->where('video_annotation_labels.user_id', $request->user()->id); @@ -78,4 +115,43 @@ public function index(Request $request, $vid, $lid) ->orderBy('video_annotations.id', 'desc') ->pluck('videos.uuid', 'video_annotations.id'); } + + /** + * Get the SQL query for filtering video annotations by size category. + * + * @param int $categoryId The size category ID + * @param bool $negate Whether to negate the condition + * @return string The SQL condition + */ + private function getAnnotationSizeQuery(int $categoryId, bool $negate = false): string + { + $operator = $negate ? '!=' : '='; + + return "CASE + WHEN video_annotations.shape_id = " . \Biigle\Shape::circleId() . " THEN + CASE + WHEN POW((video_annotations.points->0->>2)::float, 2) * PI() = 0 THEN 0 + WHEN POW((video_annotations.points->0->>2)::float, 2) * PI() < 100 THEN 1 + WHEN POW((video_annotations.points->0->>2)::float, 2) * PI() < 1000 THEN 2 + WHEN POW((video_annotations.points->0->>2)::float, 2) * PI() < 10000 THEN 3 + WHEN POW((video_annotations.points->0->>2)::float, 2) * PI() < 100000 THEN 4 + ELSE 5 + END + WHEN video_annotations.shape_id = " . \Biigle\Shape::rectangleId() . " THEN + CASE + WHEN SQRT(POW((video_annotations.points->0->>0)::float - (video_annotations.points->0->>2)::float, 2) + POW((video_annotations.points->0->>1)::float - (video_annotations.points->0->>3)::float, 2)) * SQRT(POW((video_annotations.points->0->>2)::float - (video_annotations.points->0->>4)::float, 2) + POW((video_annotations.points->0->>3)::float - (video_annotations.points->0->>5)::float, 2)) = 0 THEN 0 + WHEN SQRT(POW((video_annotations.points->0->>0)::float - (video_annotations.points->0->>2)::float, 2) + POW((video_annotations.points->0->>1)::float - (video_annotations.points->0->>3)::float, 2)) * SQRT(POW((video_annotations.points->0->>2)::float - (video_annotations.points->0->>4)::float, 2) + POW((video_annotations.points->0->>3)::float - (video_annotations.points->0->>5)::float, 2)) < 100 THEN 1 + WHEN SQRT(POW((video_annotations.points->0->>0)::float - (video_annotations.points->0->>2)::float, 2) + POW((video_annotations.points->0->>1)::float - (video_annotations.points->0->>3)::float, 2)) * SQRT(POW((video_annotations.points->0->>2)::float - (video_annotations.points->0->>4)::float, 2) + POW((video_annotations.points->0->>3)::float - (video_annotations.points->0->>5)::float, 2)) < 1000 THEN 2 + WHEN SQRT(POW((video_annotations.points->0->>0)::float - (video_annotations.points->0->>2)::float, 2) + POW((video_annotations.points->0->>1)::float - (video_annotations.points->0->>3)::float, 2)) * SQRT(POW((video_annotations.points->0->>2)::float - (video_annotations.points->0->>4)::float, 2) + POW((video_annotations.points->0->>3)::float - (video_annotations.points->0->>5)::float, 2)) < 10000 THEN 3 + WHEN SQRT(POW((video_annotations.points->0->>0)::float - (video_annotations.points->0->>2)::float, 2) + POW((video_annotations.points->0->>1)::float - (video_annotations.points->0->>3)::float, 2)) * SQRT(POW((video_annotations.points->0->>2)::float - (video_annotations.points->0->>4)::float, 2) + POW((video_annotations.points->0->>3)::float - (video_annotations.points->0->>5)::float, 2)) < 100000 THEN 4 + ELSE 5 + END + WHEN video_annotations.shape_id IN (" . \Biigle\Shape::pointId() . ", " . \Biigle\Shape::lineId() . ") THEN 0 + ELSE + CASE + WHEN json_array_length(video_annotations.points->0) < 6 THEN 0 + ELSE 2 + END + END {$operator} {$categoryId}"; + } } diff --git a/app/Http/Controllers/Views/Volumes/VolumeChartsController.php b/app/Http/Controllers/Views/Volumes/VolumeChartsController.php new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/Services/AnnotationSizeCalculator.php b/app/Services/AnnotationSizeCalculator.php new file mode 100644 index 0000000000..be203141e1 --- /dev/null +++ b/app/Services/AnnotationSizeCalculator.php @@ -0,0 +1,129 @@ + 100k px²)'; + } + } + + /** + * Get size category ID for filtering. + * + * @param float $area The area in square pixels + * @return int The size category ID + */ + public static function getSizeCategoryId(float $area): int + { + if ($area < 0.001) { // Handle floating point precision issues + return 0; // No area + } elseif ($area < 100) { + return 1; // Very small + } elseif ($area < 1000) { + return 2; // Small + } elseif ($area < 10000) { + return 3; // Medium + } elseif ($area < 100000) { + return 4; // Large + } else { + return 5; // Very large + } + } + + /** + * Get all available size categories. + * + * @return array Array of size categories [id => name] + */ + public static function getSizeCategories(): array + { + return [ + 0 => 'No area', + 1 => 'Very small (< 100 px²)', + 2 => 'Small (100-1000 px²)', + 3 => 'Medium (1k-10k px²)', + 4 => 'Large (10k-100k px²)', + 5 => 'Very large (> 100k px²)', + ]; + } +} diff --git a/resources/assets/js/largo/components/annotationFilter.vue b/resources/assets/js/largo/components/annotationFilter.vue index 9fc575ae1c..6e168ec794 100644 --- a/resources/assets/js/largo/components/annotationFilter.vue +++ b/resources/assets/js/largo/components/annotationFilter.vue @@ -109,11 +109,20 @@ export default { return { filterValues: { Shape: availableShapes, - User: {} + User: {}, + "Size": { + 0: "No area", + 1: "Very small (< 100 px²)", + 2: "Small (100-1000 px²)", + 3: "Medium (1k-10k px²)", + 4: "Large (10k-100k px²)", + 5: "Very large (> 100k px²)" + } }, filterToKeyMapping: { Shape: "shape_id", - User: "user_id" + User: "user_id", + "Size": "annotation_size" }, selectedFilter: "Shape", selectedFilterValue: null, diff --git a/resources/views/manual/tutorials/largo/largo.blade.php b/resources/views/manual/tutorials/largo/largo.blade.php index 7fa04132ad..c828f76b8c 100644 --- a/resources/views/manual/tutorials/largo/largo.blade.php +++ b/resources/views/manual/tutorials/largo/largo.blade.php @@ -148,6 +148,7 @@

Settings

diff --git a/tests/php/Services/AnnotationSizeCalculatorBasicTest.php b/tests/php/Services/AnnotationSizeCalculatorBasicTest.php new file mode 100644 index 0000000000..03a48d43e6 --- /dev/null +++ b/tests/php/Services/AnnotationSizeCalculatorBasicTest.php @@ -0,0 +1,29 @@ +assertIsArray($categories); + $this->assertCount(6, $categories); + $this->assertEquals('No area', $categories[0]); + $this->assertEquals('Very large (> 100k px²)', $categories[5]); + } + + public function testGetSizeCategoryId() + { + $this->assertEquals(0, AnnotationSizeCalculator::getSizeCategoryId(0)); + $this->assertEquals(1, AnnotationSizeCalculator::getSizeCategoryId(50)); + $this->assertEquals(2, AnnotationSizeCalculator::getSizeCategoryId(500)); + $this->assertEquals(3, AnnotationSizeCalculator::getSizeCategoryId(5000)); + $this->assertEquals(4, AnnotationSizeCalculator::getSizeCategoryId(50000)); + $this->assertEquals(5, AnnotationSizeCalculator::getSizeCategoryId(500000)); + } +} diff --git a/tests/php/Services/AnnotationSizeCalculatorTest.php b/tests/php/Services/AnnotationSizeCalculatorTest.php new file mode 100644 index 0000000000..d39e29cdb3 --- /dev/null +++ b/tests/php/Services/AnnotationSizeCalculatorTest.php @@ -0,0 +1,85 @@ +assertEqualsWithDelta(314.159, $area, 0.01); + } + + public function testCalculateAreaRectangle() + { + // Rectangle 10x20 = 200 + $points = [0, 0, 10, 0, 10, 20, 0, 20]; // four corners + $area = AnnotationSizeCalculator::calculateArea(Shape::rectangleId(), $points); + $this->assertEquals(200, $area); + } + + public function testCalculateAreaPolygon() + { + // Simple square 10x10 = 100 + $points = [0, 0, 10, 0, 10, 10, 0, 10]; // four corners + $area = AnnotationSizeCalculator::calculateArea(Shape::polygonId(), $points); + $this->assertEquals(100, $area); + } + + public function testCalculateAreaEllipse() + { + // Ellipse with major axis 20 and minor axis 20: area = π * a * b / 4 = π * 20 * 20 / 4 ≈ 314.16 + $points = [0, 0, 20, 0, 0, 10, 0, -10]; // endpoints of major and minor axes + $area = AnnotationSizeCalculator::calculateArea(Shape::ellipseId(), $points); + $this->assertEqualsWithDelta(314.159, $area, 0.01); + } + + public function testCalculateAreaPoint() + { + $points = [100, 100]; + $area = AnnotationSizeCalculator::calculateArea(Shape::pointId(), $points); + $this->assertEquals(0, $area); + } + + public function testCalculateAreaLine() + { + $points = [0, 0, 10, 0, 20, 0]; // straight line + $area = AnnotationSizeCalculator::calculateArea(Shape::lineId(), $points); + $this->assertEquals(0, $area); + } + + public function testGetSizeCategory() + { + $this->assertEquals('No area', AnnotationSizeCalculator::getSizeCategory(0)); + $this->assertEquals('Very small (< 100 px²)', AnnotationSizeCalculator::getSizeCategory(50)); + $this->assertEquals('Small (100-1000 px²)', AnnotationSizeCalculator::getSizeCategory(500)); + $this->assertEquals('Medium (1k-10k px²)', AnnotationSizeCalculator::getSizeCategory(5000)); + $this->assertEquals('Large (10k-100k px²)', AnnotationSizeCalculator::getSizeCategory(50000)); + $this->assertEquals('Very large (> 100k px²)', AnnotationSizeCalculator::getSizeCategory(500000)); + } + + public function testGetSizeCategoryId() + { + $this->assertEquals(0, AnnotationSizeCalculator::getSizeCategoryId(0)); + $this->assertEquals(1, AnnotationSizeCalculator::getSizeCategoryId(50)); + $this->assertEquals(2, AnnotationSizeCalculator::getSizeCategoryId(500)); + $this->assertEquals(3, AnnotationSizeCalculator::getSizeCategoryId(5000)); + $this->assertEquals(4, AnnotationSizeCalculator::getSizeCategoryId(50000)); + $this->assertEquals(5, AnnotationSizeCalculator::getSizeCategoryId(500000)); + } + + public function testGetSizeCategories() + { + $categories = AnnotationSizeCalculator::getSizeCategories(); + $this->assertIsArray($categories); + $this->assertCount(6, $categories); + $this->assertEquals('No area', $categories[0]); + $this->assertEquals('Very large (> 100k px²)', $categories[5]); + } +} diff --git a/tests/php/Services/AnnotationSizeCalculatorUnitTest.php b/tests/php/Services/AnnotationSizeCalculatorUnitTest.php new file mode 100644 index 0000000000..9f5230f476 --- /dev/null +++ b/tests/php/Services/AnnotationSizeCalculatorUnitTest.php @@ -0,0 +1,64 @@ +assertEquals('No area', AnnotationSizeCalculator::getSizeCategory(0)); + $this->assertEquals('No area', AnnotationSizeCalculator::getSizeCategory(0.0)); + $this->assertEquals('Very small (< 100 px²)', AnnotationSizeCalculator::getSizeCategory(50)); + $this->assertEquals('Small (100-1000 px²)', AnnotationSizeCalculator::getSizeCategory(500)); + $this->assertEquals('Medium (1k-10k px²)', AnnotationSizeCalculator::getSizeCategory(5000)); + $this->assertEquals('Large (10k-100k px²)', AnnotationSizeCalculator::getSizeCategory(50000)); + $this->assertEquals('Very large (> 100k px²)', AnnotationSizeCalculator::getSizeCategory(500000)); + } + + public function testGetSizeCategoryId() + { + $this->assertEquals(0, AnnotationSizeCalculator::getSizeCategoryId(0)); + $this->assertEquals(0, AnnotationSizeCalculator::getSizeCategoryId(0.0)); + $this->assertEquals(1, AnnotationSizeCalculator::getSizeCategoryId(50)); + $this->assertEquals(2, AnnotationSizeCalculator::getSizeCategoryId(500)); + $this->assertEquals(3, AnnotationSizeCalculator::getSizeCategoryId(5000)); + $this->assertEquals(4, AnnotationSizeCalculator::getSizeCategoryId(50000)); + $this->assertEquals(5, AnnotationSizeCalculator::getSizeCategoryId(500000)); + } + + public function testGetSizeCategories() + { + $categories = AnnotationSizeCalculator::getSizeCategories(); + $this->assertIsArray($categories); + $this->assertCount(6, $categories); + $this->assertEquals('No area', $categories[0]); + $this->assertEquals('Very large (> 100k px²)', $categories[5]); + } + + public function testCalculateAreaBasicMath() + { + // Test calculations using hardcoded shape IDs to avoid import issues + // These are the actual shape IDs from the database: + // 1 = point, 2 = line, 3 = polygon, 4 = circle, 5 = rectangle, 6 = ellipse + + // Circle with radius 5: area = π * r² = π * 25 ≈ 78.54 + $circlePoints = [100, 100, 5]; // center x, center y, radius + $circleArea = AnnotationSizeCalculator::calculateArea(4, $circlePoints); // 4 is circle ID + $this->assertEqualsWithDelta(78.54, $circleArea, 0.01); + + // Test that points and lines return 0 area + $pointArea = AnnotationSizeCalculator::calculateArea(1, [100, 100]); // 1 is point ID + $this->assertEquals(0, $pointArea); + + $lineArea = AnnotationSizeCalculator::calculateArea(2, [0, 0, 10, 0, 20, 0]); // 2 is line ID + $this->assertEquals(0, $lineArea); + + // Simple rectangle: 10x20 = 200 + $rectanglePoints = [0, 0, 10, 0, 10, 20, 0, 20]; // four corners + $rectangleArea = AnnotationSizeCalculator::calculateArea(5, $rectanglePoints); // 5 is rectangle ID + $this->assertEquals(200, $rectangleArea); + } +} diff --git a/tests/php/unit/Services/AnnotationSizeCalculatorTest.php b/tests/php/unit/Services/AnnotationSizeCalculatorTest.php new file mode 100644 index 0000000000..b3085e5a6f --- /dev/null +++ b/tests/php/unit/Services/AnnotationSizeCalculatorTest.php @@ -0,0 +1,115 @@ +assertEquals(0, $area); + } + + public function testCalculateAreaLine() + { + $area = AnnotationSizeCalculator::calculateArea( + AnnotationSizeCalculator::LINE_SHAPE_ID, + [0, 0, 10, 10] + ); + $this->assertEquals(0, $area); + } + + public function testCalculateAreaCircle() + { + // Circle with radius 5 + $area = AnnotationSizeCalculator::calculateArea( + AnnotationSizeCalculator::CIRCLE_SHAPE_ID, + [0, 0, 5] + ); + $this->assertEqualsWithDelta(78.54, $area, 0.01); + } + + public function testCalculateAreaRectangle() + { + // Rectangle 10x5 + $area = AnnotationSizeCalculator::calculateArea( + AnnotationSizeCalculator::RECTANGLE_SHAPE_ID, + [0, 0, 10, 0, 10, 5, 0, 5] + ); + $this->assertEquals(50, $area); + } + + public function testCalculateAreaPolygon() + { + // Triangle with vertices at (0,0), (10,0), (5,10) + $area = AnnotationSizeCalculator::calculateArea( + AnnotationSizeCalculator::POLYGON_SHAPE_ID, + [0, 0, 10, 0, 5, 10] + ); + $this->assertEquals(50, $area); + } + + public function testCalculateAreaEllipse() + { + // Ellipse with major axis 20 and minor axis 10 + $area = AnnotationSizeCalculator::calculateArea( + AnnotationSizeCalculator::ELLIPSE_SHAPE_ID, + [0, 0, 20, 0, 10, 5, 10, -5] + ); + $this->assertEqualsWithDelta(157.08, $area, 0.01); + } + + public function testGetSizeCategoryId() + { + $this->assertEquals(0, AnnotationSizeCalculator::getSizeCategoryId(0)); + $this->assertEquals(1, AnnotationSizeCalculator::getSizeCategoryId(50)); + $this->assertEquals(2, AnnotationSizeCalculator::getSizeCategoryId(500)); + $this->assertEquals(3, AnnotationSizeCalculator::getSizeCategoryId(5000)); + $this->assertEquals(4, AnnotationSizeCalculator::getSizeCategoryId(50000)); + $this->assertEquals(5, AnnotationSizeCalculator::getSizeCategoryId(500000)); + } + + public function testGetSizeCategory() + { + $this->assertEquals('No area', AnnotationSizeCalculator::getSizeCategory(0)); + $this->assertEquals('Very small (< 100 px²)', AnnotationSizeCalculator::getSizeCategory(50)); + $this->assertEquals('Small (100-1000 px²)', AnnotationSizeCalculator::getSizeCategory(500)); + $this->assertEquals('Medium (1k-10k px²)', AnnotationSizeCalculator::getSizeCategory(5000)); + $this->assertEquals('Large (10k-100k px²)', AnnotationSizeCalculator::getSizeCategory(50000)); + $this->assertEquals('Very large (> 100k px²)', AnnotationSizeCalculator::getSizeCategory(500000)); + } + + public function testGetSizeCategories() + { + $categories = AnnotationSizeCalculator::getSizeCategories(); + + $this->assertIsArray($categories); + $this->assertCount(6, $categories); + $this->assertArrayHasKey(0, $categories); + $this->assertArrayHasKey(5, $categories); + $this->assertEquals('No area', $categories[0]); + $this->assertEquals('Very large (> 100k px²)', $categories[5]); + } + + public function testFloatingPointPrecision() + { + // Test very small area that might have floating point precision issues + $this->assertEquals(0, AnnotationSizeCalculator::getSizeCategoryId(0.0001)); + $this->assertEquals('No area', AnnotationSizeCalculator::getSizeCategory(0.0001)); + } + + public function testBoundaryValues() + { + // Test exact boundary values + $this->assertEquals(1, AnnotationSizeCalculator::getSizeCategoryId(99.9)); + $this->assertEquals(2, AnnotationSizeCalculator::getSizeCategoryId(100)); + $this->assertEquals(2, AnnotationSizeCalculator::getSizeCategoryId(999.9)); + $this->assertEquals(3, AnnotationSizeCalculator::getSizeCategoryId(1000)); + } +}