From 5fa09d8981afcf9f2bf1508e8aa21aa9b713d67a Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Fri, 31 Oct 2025 15:51:24 +0100 Subject: [PATCH 1/5] Reimplement new laser point detection method Based on: https://github.com/biigle/laserpoints/pull/95 References: https://github.com/biigle/laserpoints/issues/44 --- README.md | 19 - requirements.txt | 6 +- src/Console/Commands/Config.php | 36 - src/Console/Commands/Publish.php | 37 - .../Controllers/Api/LaserpointsController.php | 174 +++- src/Http/Requests/ComputeImage.php | 56 -- src/Http/Requests/ComputeVolume.php | 60 -- src/Http/routes.php | 16 +- src/Image.php | 1 + src/Jobs/Job.php | 121 --- src/Jobs/ProcessDelphiJob.php | 110 -- ...alJob.php => ProcessImageAutomaticJob.php} | 50 +- src/Jobs/ProcessImageDelphiJob.php | 56 -- src/Jobs/ProcessImageManualJob.php | 75 +- src/Jobs/ProcessVolumeAutomaticJob.php | 87 ++ src/Jobs/ProcessVolumeDelphiJob.php | 81 -- src/Jobs/ProcessVolumeManualJob.php | 64 ++ src/LaserpointsServiceProvider.php | 24 - src/Support/DelphiApply.php | 28 - src/Support/DelphiGather.php | 89 -- src/Support/DetectAutomatic.php | 49 + src/Support/DetectLines.php | 49 + src/Support/{Detect.php => DetectManual.php} | 10 +- src/Support/LaserpointsScript.php | 9 +- src/Volume.php | 18 +- src/config/laserpoints.php | 25 +- src/resources/assets/js/api/laserpoints.js | 20 +- .../assets/js/components/laserpointsForm.vue | 97 +- src/resources/assets/js/laserpointsPanel.vue | 2 + src/resources/scripts/automatic.py | 937 ++++++++++++++++++ src/resources/scripts/delphi_apply.py | 157 --- src/resources/scripts/delphi_gather.py | 79 -- src/resources/scripts/delphi_gather_finish.py | 21 - .../scripts/{detect.py => manual.py} | 0 src/resources/views/imagesIndex.blade.php | 8 +- src/resources/views/volumesSidebar.blade.php | 7 +- .../Api/LaserpointsControllerTest.php | 174 ++-- tests/Jobs/ProcessDelphiJobTest.php | 126 --- ...t.php => ProcessImageAutomaticJobTest.php} | 71 +- tests/Jobs/ProcessImageDelphiJobTest.php | 66 -- tests/Jobs/ProcessImageManualJobTest.php | 138 ++- tests/Jobs/ProcessVolumeAutomaticJobTest.php | 54 + tests/Jobs/ProcessVolumeDelphiJobTest.php | 82 -- tests/Jobs/ProcessVolumeManualJobTest.php | 71 ++ tests/VolumeTest.php | 33 +- 45 files changed, 1951 insertions(+), 1542 deletions(-) delete mode 100644 src/Console/Commands/Config.php delete mode 100644 src/Console/Commands/Publish.php delete mode 100644 src/Http/Requests/ComputeImage.php delete mode 100644 src/Http/Requests/ComputeVolume.php delete mode 100644 src/Jobs/Job.php delete mode 100644 src/Jobs/ProcessDelphiJob.php rename src/Jobs/{ProcessManualJob.php => ProcessImageAutomaticJob.php} (55%) delete mode 100644 src/Jobs/ProcessImageDelphiJob.php create mode 100644 src/Jobs/ProcessVolumeAutomaticJob.php delete mode 100644 src/Jobs/ProcessVolumeDelphiJob.php create mode 100644 src/Jobs/ProcessVolumeManualJob.php delete mode 100644 src/Support/DelphiApply.php delete mode 100644 src/Support/DelphiGather.php create mode 100644 src/Support/DetectAutomatic.php create mode 100644 src/Support/DetectLines.php rename src/Support/{Detect.php => DetectManual.php} (65%) create mode 100644 src/resources/scripts/automatic.py delete mode 100644 src/resources/scripts/delphi_apply.py delete mode 100644 src/resources/scripts/delphi_gather.py delete mode 100644 src/resources/scripts/delphi_gather_finish.py rename src/resources/scripts/{detect.py => manual.py} (100%) delete mode 100644 tests/Jobs/ProcessDelphiJobTest.php rename tests/Jobs/{ProcessManualJobTest.php => ProcessImageAutomaticJobTest.php} (51%) delete mode 100644 tests/Jobs/ProcessImageDelphiJobTest.php create mode 100644 tests/Jobs/ProcessVolumeAutomaticJobTest.php delete mode 100644 tests/Jobs/ProcessVolumeDelphiJobTest.php create mode 100644 tests/Jobs/ProcessVolumeManualJobTest.php diff --git a/README.md b/README.md index 738d78d1..dfdae115 100644 --- a/README.md +++ b/README.md @@ -12,25 +12,6 @@ This module is already included in [`biigle/biigle`](https://github.com/biigle/b 2. Add `Biigle\Modules\Laserpoints\LaserpointsServiceProvider::class` to the `providers` array in `config/app.php`. 3. Run `php artisan vendor:publish --tag=public` to publish the public assets of this module. 4. Run `pip install -r vendor/biigle/laserpoints/requirements.txt` to install the Python requirements. -5. Configure a storage disk for the temporary laserpoints files `LASERPOINTS_DISK` variable to the name of this storage disk in the `.env` file. Example for a local disk: - ```php - 'laserpoints' => [ - 'driver' => 'local', - 'root' => storage_path('framework/cache/laserpoints'), - ], - ``` - -## References - -Reference publications that you should cite if you use the laser point detection for one of your studies. - -- **BIIGLE 2.0** - [Langenkämper, D., Zurowietz, M., Schoening, T., & Nattkemper, T. W. (2017). Biigle 2.0-browsing and annotating large marine image collections.](https://doi.org/10.3389/fmars.2017.00083) - Frontiers in Marine Science, 4, 83. doi: `10.3389/fmars.2017.00083` - -- **Laser Point Detection** - [Schoening, T., Kuhn, T., Bergmann, M., & Nattkemper, T. W. (2015). DELPHI—fast and adaptive computational laser point detection and visual footprint quantification for arbitrary underwater image collections.](https://doi.org/10.3389/fmars.2015.00020) - Frontiers in Marine Science, 2, 20. doi: `10.3389/fmars.2015.00020` ## Developing diff --git a/requirements.txt b/requirements.txt index 7606c0ed..e305e433 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ -numpy==1.22.0 -scipy==1.10.0 -Pillow==10.3.0 +numpy==2.1.* +scipy==1.13.* +opencv-contrib-python-headless==4.11.0.* diff --git a/src/Console/Commands/Config.php b/src/Console/Commands/Config.php deleted file mode 100644 index f84f420c..00000000 --- a/src/Console/Commands/Config.php +++ /dev/null @@ -1,36 +0,0 @@ -call('vendor:publish', [ - '--provider' => ServiceProvider::class, - '--tag' => ['config'], - ]); - } -} diff --git a/src/Console/Commands/Publish.php b/src/Console/Commands/Publish.php deleted file mode 100644 index 0c8c5dc8..00000000 --- a/src/Console/Commands/Publish.php +++ /dev/null @@ -1,37 +0,0 @@ -call('vendor:publish', [ - '--provider' => ServiceProvider::class, - '--tag' => ['public'], - '--force' => true, - ]); - } -} diff --git a/src/Http/Controllers/Api/LaserpointsController.php b/src/Http/Controllers/Api/LaserpointsController.php index 0aa00a30..b44c2fd3 100644 --- a/src/Http/Controllers/Api/LaserpointsController.php +++ b/src/Http/Controllers/Api/LaserpointsController.php @@ -4,96 +4,194 @@ use Biigle\Http\Controllers\Api\Controller; use Biigle\Label; -use Biigle\Modules\Laserpoints\Http\Requests\ComputeImage; -use Biigle\Modules\Laserpoints\Http\Requests\ComputeVolume; use Biigle\Modules\Laserpoints\Image; -use Biigle\Modules\Laserpoints\Jobs\ProcessImageDelphiJob; +use Biigle\Modules\Laserpoints\Jobs\ProcessImageAutomaticJob; use Biigle\Modules\Laserpoints\Jobs\ProcessImageManualJob; -use Biigle\Modules\Laserpoints\Jobs\ProcessVolumeDelphiJob; +use Biigle\Modules\Laserpoints\Jobs\ProcessVolumeAutomaticJob; +use Biigle\Modules\Laserpoints\Jobs\ProcessVolumeManualJob; use Biigle\Modules\Laserpoints\Volume; -use Biigle\Volume as BaseVolume; use Exception; +use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; class LaserpointsController extends Controller { /** - * Compute distance between laser points for an image. + * Compute distance between laser points for an image with manual annotations. * - * @api {post} images/:id/laserpoints/area Compute image area + * @api {post} images/:id/laserpoints/manual Compute image area with manual annotations * @apiGroup Images - * @apiName ImagesComputeArea + * @apiName ImagesComputeAreaManual * @apiPermission projectEditor - * @apiDescription This feature is not available for very large images. * * @apiParam {Number} id The image ID. * @apiParam (Required arguments) {Number} label_id ID of the laser point label that was used. * @apiParam (Required arguments) {Number} distance The distance between two laser points in cm. * - * @param ComputeImage $request + * @param Request $request + * @param int $id * * @return \Illuminate\Http\Response */ - public function computeImage(ComputeImage $request) + public function imageManual(Request $request, $id) { - $image = Image::convert($request->image); + $image = Image::with('volume')->findOrFail($id); + $this->authorize('edit-in', $image->volume); + // TODO manual is possible for tiled images? how does the script get the dimensions? do we need a python script for this at all? + if ($image->tiled) { + throw ValidationException::withMessages([ + 'id' => 'Laser point detection is not available for very large images.', + ]); + } + $request->validate([ + 'distance' => 'required|numeric|min:1', + 'label_id' => 'required|integer|exists:labels,id', + ]); + $label = Label::find($request->input('label_id')); try { - $manual = $image->readyForManualDetection($label); + $image->readyForManualDetection($label); } catch (Exception $e) { throw ValidationException::withMessages([ 'id' => 'Laser point detection can\'t be performed. '.$e->getMessage(), ]); } - if ($manual) { - ProcessImageManualJob::dispatch($image, $request->input('distance'), $label->id) - ->onQueue(config('laserpoints.process_manual_queue')); - } else { - try { - Volume::convert($image->volume)->readyForDelphiDetection($label); - } catch (Exception $e) { - throw ValidationException::withMessages([ - 'id' => 'Delphi laser point detection can\'t be performed. '.$e->getMessage(), - ]); - } - - ProcessImageDelphiJob::dispatch($image, $request->input('distance'), $label->id) - ->onQueue(config('laserpoints.process_delphi_queue')); + ProcessImageManualJob::dispatch($image, $label, $request->input('distance')) + ->onQueue(config('laserpoints.process_manual_queue')); + } + + /** + * Compute distance between laser points for an image with automatic detection. + * + * @api {post} images/:id/laserpoints/automatic Compute image area with automatic detection + * @apiGroup Images + * @apiName ImagesComputeAreaAutomatic + * @apiPermission projectEditor + * @apiDescription This feature is not available for very large images. + * + * @apiParam {Number} id The image ID. + * @apiParam (Required arguments) {Number} distance The distance between two laser points in cm. + * + * @param Request $request + * @param int $id + * + * @return \Illuminate\Http\Response + */ + public function imageAutomatic(Request $request, $id) + { + $image = Image::with('volume')->findOrFail($id); + $this->authorize('edit-in', $image->volume); + if ($image->tiled) { + throw ValidationException::withMessages([ + 'id' => 'Laser point detection is not available for very large images.', + ]); } + $request->validate([ + 'distance' => 'required|numeric|min:1', + ]); + + ProcessImageAutomaticJob::dispatch($image, $request->input('distance')) + ->onQueue(config('laserpoints.process_automatic_queue')); } /** - * Compute distance between laser points for a volume. + * Compute distance between laser points for a volume with manual annotations. * - * @api {post} volumes/:id/laserpoints/area Compute image footprint for all images + * @api {post} volumes/:id/laserpoints/manual Compute image area with manual annotations * @apiGroup Volumes - * @apiName VolumesComputeImageArea + * @apiName VolumesComputeAreaManual * @apiPermission projectEditor - * @apiDescription This feature is not available for video volumes and volumes with very large images. * * @apiParam {Number} id The volume ID. * @apiParam (Required arguments) {Number} label_id ID of the laser point label that was used. * @apiParam (Required arguments) {Number} distance The distance between two laser points in cm. * - * @param ComputeVolume $request + * @param Request $request + * @param int $id + * * @return \Illuminate\Http\Response */ - public function computeVolume(ComputeVolume $request) + public function volumeManual(Request $request, $id) { - $volume = Volume::convert($request->volume); + // TODO use cache key to prevent users from submitting multiple jobs at the same + // time + $volume = Volume::findOrFail($id); + $this->authorize('edit-in', $volume); + if (!$volume->isImageVolume()) { + throw ValidationException::withMessages([ + 'id' => 'Laser point detection is only available for image volumes.', + ]); + } + // TODO manual is possible for tiled images? how does the script get the dimensions? do we need a python script for this at all? + if ($volume->hasTiledImages()) { + throw ValidationException::withMessages([ + 'id' => 'Laser point detection is not available for volumes with very large images.', + ]); + } + $request->validate([ + 'distance' => 'required|numeric|min:1', + 'label_id' => 'required|integer|exists:labels,id', + ]); + $label = Label::find($request->input('label_id')); try { - $volume->readyForDelphiDetection($label); + $volume->readyForManualDetection($label); } catch (Exception $e) { throw ValidationException::withMessages([ - 'id' => 'Delphi laser point detection can\'t be performed. '.$e->getMessage(), + 'id' => 'Laser point detection can\'t be performed. '.$e->getMessage(), ]); } - ProcessVolumeDelphiJob::dispatch($volume, $request->input('distance'), $label->id) - ->onQueue(config('laserpoints.process_delphi_queue')); + ProcessVolumeManualJob::dispatch($volume, $label, $request->input('distance')) + ->onQueue(config('laserpoints.process_manual_queue')); + } + + /** + * Compute distance between laser points for a volume with automatic detection. + * + * @api {post} volumes/:id/laserpoints/automatic Compute image area with automatic detection + * @apiGroup Volumes + * @apiName VolumesComputeAreaAutomatic + * @apiPermission projectEditor + * @apiDescription This feature is not available for video volumes and volumes with very large images. + * + * @apiParam {Number} id The image ID. + * @apiParam (Required arguments) {Number} distance The distance between two laser points in cm. + * @apiParam (Optional arguments) {boolean} disable_line_detection Set to true if the laser pointers can move relative to the camera (e.g. laser points could move even if the vehicle does not move). + * + * @param Request $request + * @param int $id + * + * @return \Illuminate\Http\Response + */ + public function volumeAutomatic(Request $request, $id) + { + // TODO use cache key to track which image job should delete cache data + // and to prevent users from submitting multiple jobs at the same time + $volume = Volume::findOrFail($id); + $this->authorize('edit-in', $volume); + + if (!$volume->isImageVolume()) { + throw ValidationException::withMessages([ + 'id' => 'Laser point detection is only available for image volumes.', + ]); + } + + if ($volume->hasTiledImages()) { + throw ValidationException::withMessages([ + 'id' => 'Laser point detection is not available for volumes with very large images.', + ]); + } + + $request->validate([ + 'distance' => 'required|numeric|min:1', + 'disable_line_detection' => 'boolean', + ]); + + ProcessVolumeAutomaticJob::dispatch($volume, $request->input('distance'), $request->input('disable_line_detection', false)) + ->onQueue(config('laserpoints.process_automatic_queue')); } } diff --git a/src/Http/Requests/ComputeImage.php b/src/Http/Requests/ComputeImage.php deleted file mode 100644 index 457d3fff..00000000 --- a/src/Http/Requests/ComputeImage.php +++ /dev/null @@ -1,56 +0,0 @@ -image = Image::with('volume')->findOrFail($this->route('id')); - - return $this->user()->can('edit-in', $this->image->volume); - } - - /** - * Get the validation rules that apply to the request. - * - * @return array - */ - public function rules() - { - return [ - 'distance' => 'required|numeric|min:1', - 'label_id' => 'required|integer|exists:labels,id', - ]; - } - - /** - * Configure the validator instance. - * - * @param \Illuminate\Validation\Validator $validator - * @return void - */ - public function withValidator($validator) - { - $validator->after(function ($validator) { - if ($this->image->tiled === true) { - $validator->errors()->add('id', 'Laser point detection is not available for very large images.'); - } - }); - } -} diff --git a/src/Http/Requests/ComputeVolume.php b/src/Http/Requests/ComputeVolume.php deleted file mode 100644 index 2ee5aede..00000000 --- a/src/Http/Requests/ComputeVolume.php +++ /dev/null @@ -1,60 +0,0 @@ -volume = Volume::findOrFail($this->route('id')); - - return $this->user()->can('edit-in', $this->volume); - } - - /** - * Get the validation rules that apply to the request. - * - * @return array - */ - public function rules() - { - return [ - 'distance' => 'required|numeric|min:1', - 'label_id' => 'required|integer|exists:labels,id', - ]; - } - - /** - * Configure the validator instance. - * - * @param \Illuminate\Validation\Validator $validator - * @return void - */ - public function withValidator($validator) - { - $validator->after(function ($validator) { - if (!$this->volume->isImageVolume()) { - $validator->errors()->add('id', 'Laser point detection is only available for image volumes.'); - } - - if ($this->volume->hasTiledImages()) { - $validator->errors()->add('id', 'Laser point detection is not available for volumes with very large images.'); - } - }); - } -} diff --git a/src/Http/routes.php b/src/Http/routes.php index b0b91483..c528a8f3 100644 --- a/src/Http/routes.php +++ b/src/Http/routes.php @@ -5,12 +5,20 @@ 'prefix' => 'api/v1', 'middleware' => ['api', 'auth:web,api'], ], function ($router) { - $router->post('images/{id}/laserpoints/area', [ - 'uses' => 'LaserpointsController@computeImage', + $router->post('images/{id}/laserpoints/manual', [ + 'uses' => 'LaserpointsController@imageManual', ]); - $router->post('volumes/{id}/laserpoints/area', [ - 'uses' => 'LaserpointsController@computeVolume', + $router->post('images/{id}/laserpoints/automatic', [ + 'uses' => 'LaserpointsController@imageAutomatic', + ]); + + $router->post('volumes/{id}/laserpoints/manual', [ + 'uses' => 'LaserpointsController@volumeManual', + ]); + + $router->post('volumes/{id}/laserpoints/automatic', [ + 'uses' => 'LaserpointsController@volumeAutomatic', ]); $router->get('images/{id}/laserpoints', [ diff --git a/src/Image.php b/src/Image.php index 355dab0d..f2591558 100644 --- a/src/Image.php +++ b/src/Image.php @@ -60,6 +60,7 @@ class Image extends BaseImage */ public static function convert(BaseImage $image) { + // TODO: still needed? $instance = new static; $instance->setRawAttributes($image->attributes); $instance->exists = $image->exists; diff --git a/src/Jobs/Job.php b/src/Jobs/Job.php deleted file mode 100644 index 903e7d6e..00000000 --- a/src/Jobs/Job.php +++ /dev/null @@ -1,121 +0,0 @@ -distance = $distance; - $this->labelId = $labelId; - } - - /** - * Execute the job. - * - * @return void - */ - abstract public function handle(); - - /** - * Collects all images of a volume that contain laser point annotations. - * - * @param int $id Volume ID - * - * @return Collection Laser point coordinates indexed by image ID - */ - protected function getLaserpointsForVolume($id) - { - return ImageAnnotation::join('image_annotation_labels', 'image_annotation_labels.annotation_id', '=', 'image_annotations.id') - ->join('images', 'image_annotations.image_id', '=', 'images.id') - ->where('images.volume_id', $id) - ->where('image_annotation_labels.label_id', $this->labelId) - ->where('image_annotations.shape_id', Shape::pointId()) - ->select('image_annotations.points', 'image_annotations.image_id') - ->get() - ->groupBy('image_id') - ->pipe([$this, 'filterInvalidLaserPoints']) - ->map(function ($i) { - return $i->pluck('points')->toJson(); - }); - } - - /** - * Perform the gather step. - * - * @param Collection $points Points Collection returned from getLaserpointsForVolume. - * - * @return string Path to the gather file in the storage disk. - */ - protected function gather($points) - { - $images = Image::whereIn('id', $points->keys()) - ->with('volume') - ->select('filename', 'id', 'volume_id') - // Take only a maximum of 100 images for delphi_gather. - ->inRandomOrder() - ->limit(100) - ->get(); - - $gather = App::make(DelphiGather::class); - $callback = function ($image, $path) use ($gather, $points) { - return $gather->execute($path, $points->get($image->id)); - }; - - $outputPath = $gather->getOutputPath(); - - try { - foreach ($images as $image) { - FileCache::get($image, $callback); - } - - $gather->finish(); - - $storagePath = Storage::disk(config('laserpoints.disk')) - ->putFileAs('', new SplFileInfo($outputPath), File::basename($outputPath)); - } finally { - File::delete($outputPath); - } - - return $storagePath; - } -} diff --git a/src/Jobs/ProcessDelphiJob.php b/src/Jobs/ProcessDelphiJob.php deleted file mode 100644 index a178870e..00000000 --- a/src/Jobs/ProcessDelphiJob.php +++ /dev/null @@ -1,110 +0,0 @@ -image = Image::convert($image); - $this->gatherFile = $gatherFile; - $this->distance = $distance; - } - - /** - * Execute the job. - * - * @return void - */ - public function handle() - { - $tmpDir = config('laserpoints.tmp_dir'); - $localGatherPath = "{$tmpDir}/{$this->gatherFile}"; - $stream = Storage::disk(config('laserpoints.disk')) - ->readStream($this->gatherFile); - File::put($localGatherPath, $stream); - - $callback = function ($image, $path) use ($localGatherPath) { - $delphi = App::make(DelphiApply::class); - - return $delphi->execute($localGatherPath, $path, $this->distance); - }; - - try { - $output = FileCache::getOnce($this->image, $callback); - } catch (Exception $e) { - $output = [ - 'error' => true, - 'message' => $e->getMessage(), - ]; - } finally { - File::delete($localGatherPath); - } - - $output['distance'] = $this->distance; - - $this->image->laserpoints = $output; - $this->image->save(); - } -} diff --git a/src/Jobs/ProcessManualJob.php b/src/Jobs/ProcessImageAutomaticJob.php similarity index 55% rename from src/Jobs/ProcessManualJob.php rename to src/Jobs/ProcessImageAutomaticJob.php index 31d38be6..6a68f5af 100644 --- a/src/Jobs/ProcessManualJob.php +++ b/src/Jobs/ProcessImageAutomaticJob.php @@ -3,9 +3,10 @@ namespace Biigle\Modules\Laserpoints\Jobs; use App; -use Biigle\Jobs\Job as BaseJob; +use Biigle\Jobs\Job; +use Biigle\Shape; use Biigle\Modules\Laserpoints\Image; -use Biigle\Modules\Laserpoints\Support\Detect; +use Biigle\Modules\Laserpoints\Support\DetectAutomatic; use Exception; use FileCache; use Illuminate\Bus\Batchable; @@ -13,30 +14,11 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -class ProcessManualJob extends BaseJob implements ShouldQueue +class ProcessImageAutomaticJob extends Job implements ShouldQueue { use Batchable, InteractsWithQueue, SerializesModels; - /** - * The image to process. - * - * @var Image - */ - protected $image; - - /** - * Laser point coordinates for the image. - * - * @var string - */ - protected $points; - - /** - * Distance between laser points im cm to use for computation. - * - * @var float - */ - protected $distance; + public $tries = 1; /** * Ignore this job if the image does not exist any more. @@ -48,17 +30,19 @@ class ProcessManualJob extends BaseJob implements ShouldQueue /** * Create a new job instance. * - * @param Image $image - * @param string $points - * @param float $distance + * @param Image $image The image to process. + * @param float $distance Distance between laser points im cm to use for computation. + * @param ?string $lineInfo JSON string from the line detection * * @return void */ - public function __construct($image, $points, $distance) + public function __construct( + public Image $image, + public float $distance, + public ?string $lineInfo = null, + ) { - $this->image = $image; - $this->points = $points; - $this->distance = $distance; + // } /** @@ -69,10 +53,10 @@ public function __construct($image, $points, $distance) public function handle() { try { - $output = FileCache::getOnce($this->image, function ($image, $path) { - $detect = App::make(Detect::class); + $output = FileCache::get($this->image, function ($image, $path) { + $detect = App::make(DetectAutomatic::class); - return $detect->execute($path, $this->distance, $this->points); + return $detect->execute($path, $this->distance, $this->lineInfo); }); } catch (Exception $e) { $output = [ diff --git a/src/Jobs/ProcessImageDelphiJob.php b/src/Jobs/ProcessImageDelphiJob.php deleted file mode 100644 index 17139e98..00000000 --- a/src/Jobs/ProcessImageDelphiJob.php +++ /dev/null @@ -1,56 +0,0 @@ -image = $image; - } - - /** - * Execute the job. - * - * @return void - */ - public function handle() - { - // The image may be deleted in the meantime. - if (!$this->image) { - return; - } - - $points = $this->getLaserpointsForVolume($this->image->volume_id); - $gatherFile = $this->gather($points); - $job = new ProcessDelphiJob($this->image, $this->distance, $gatherFile); - - Bus::batch([$job]) - ->onQueue(config('laserpoints.process_delphi_queue')) - ->finally(function () use ($gatherFile) { - Storage::disk(config('laserpoints.disk'))->delete($gatherFile); - }) - ->dispatch(); - } -} diff --git a/src/Jobs/ProcessImageManualJob.php b/src/Jobs/ProcessImageManualJob.php index 9cda3034..f06e5345 100644 --- a/src/Jobs/ProcessImageManualJob.php +++ b/src/Jobs/ProcessImageManualJob.php @@ -2,21 +2,24 @@ namespace Biigle\Modules\Laserpoints\Jobs; -use Biigle\Image; +use App; +use Biigle\Jobs\Job; +use Biigle\Label; +use Biigle\Modules\Laserpoints\Image; +use Biigle\Modules\Laserpoints\Support\DetectManual; use Biigle\Shape; -use DB; +use Exception; +use FileCache; +use Illuminate\Bus\Batchable; +use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -class ProcessImageManualJob extends Job +class ProcessImageManualJob extends Job implements ShouldQueue { - use SerializesModels; + use Batchable, InteractsWithQueue, SerializesModels; - /** - * The image to compute the area for. - * - * @var Image - */ - protected $image; + public $tries = 1; /** * Ignore this job if the image does not exist any more. @@ -28,16 +31,19 @@ class ProcessImageManualJob extends Job /** * Create a new job instance. * - * @param Image $image - * @param float $distance - * @param int $labelId + * @param Image $image The image to process. + * @param Label $label The laser point label. + * @param float $distance Distance between laser points im cm to use for computation. * * @return void */ - public function __construct(Image $image, $distance, $labelId) + public function __construct( + public Image $image, + public Label $label, + public float $distance, + ) { - parent::__construct($distance, $labelId); - $this->image = $image; + // } /** @@ -47,28 +53,39 @@ public function __construct(Image $image, $distance, $labelId) */ public function handle() { - $points = $this->getLaserpointsForImage($this->image->id); - ProcessManualJob::dispatch($this->image, $points, $this->distance) - ->onQueue(config('laserpoints.process_manual_queue')); + try { + // TODO implement this without loading the image. dimensions are available in the image model. this can be enabled for tiled images too then. + $output = FileCache::get($this->image, function ($image, $path) { + $detect = App::make(DetectManual::class); + $points = $this->getLaserpoints(); + + return $detect->execute($path, $this->distance, $points); + }); + } catch (Exception $e) { + $output = [ + 'error' => true, + 'message' => $e->getMessage(), + ]; + } + + $output['distance'] = $this->distance; + + $this->image->laserpoints = $output; + $this->image->save(); } /** * Collects the laser point annotations of the given image. * - * @param int $id Image ID - * * @return string JSON encoded array of annotation coordinates */ - protected function getLaserpointsForImage($id) + protected function getLaserpoints() { - $points = DB::table('image_annotations') + return $this->image->annotations() ->join('image_annotation_labels', 'image_annotation_labels.annotation_id', '=', 'image_annotations.id') - ->where('image_annotations.image_id', $id) - ->where('image_annotation_labels.label_id', $this->labelId) + ->where('image_annotation_labels.label_id', $this->label->id) ->where('image_annotations.shape_id', Shape::pointId()) - ->select('image_annotations.points', 'image_annotations.image_id') - ->pluck('image_annotations.points'); - - return '['.$points->implode(',').']'; + ->pluck('image_annotations.points') + ->toJson(); } } diff --git a/src/Jobs/ProcessVolumeAutomaticJob.php b/src/Jobs/ProcessVolumeAutomaticJob.php new file mode 100644 index 00000000..effab55c --- /dev/null +++ b/src/Jobs/ProcessVolumeAutomaticJob.php @@ -0,0 +1,87 @@ +disableLineDetection) { + $lineSampleImages = $this->volume->images() + ->inRandomOrder() + ->take(100) + ->get() + ->all(); + $lineInfo = FileCache::batch($lineSampleImages, function ($images, $paths) { + return $this->performLineDetection($images, $paths); + }); + } + + + $this->volume->images() + ->eachById(function ($image) use ($lineInfo) { + $image = Image::convert($image); + ProcessImageAutomaticJob::dispatch($image, $this->distance, $lineInfo) + ->onQueue(config('laserpoints.process_automatic_queue')); + }); + } + + /** + * Execute the line detection. + * + * @param array $images Cached image models + * @param array $paths Cached image file paths + * + * @return string + */ + protected function performLineDetection(array $images, array $paths) + { + $input = array_combine(array_map(fn ($image) => $image->id, $images), $paths); + $detect = App::make(DetectLines::class); + + return $detect->execute($input, $this->distance); + } +} diff --git a/src/Jobs/ProcessVolumeDelphiJob.php b/src/Jobs/ProcessVolumeDelphiJob.php deleted file mode 100644 index 12c05b50..00000000 --- a/src/Jobs/ProcessVolumeDelphiJob.php +++ /dev/null @@ -1,81 +0,0 @@ -volume = $volume; - } - - /** - * Execute the job. - * - * @return void - */ - public function handle() - { - $points = $this->getLaserpointsForVolume($this->volume->id); - $images = Image::whereIn('id', $points->keys())->get(); - - $jobs = $images->map(function ($image) use ($points) { - return new ProcessManualJob($image, $points->get($image->id), $this->distance); - }); - - Bus::batch($jobs) - ->onQueue(config('laserpoints.process_manual_queue')) - ->dispatch(); - - $query = $this->volume->images()->whereNotIn('id', $points->keys()); - - if ($query->exists()) { - $images = $query->get(); - $gatherFile = $this->gather($points); - - $jobs = $images->map(function ($image) use ($gatherFile) { - return new ProcessDelphiJob($image, $this->distance, $gatherFile); - }); - - Bus::batch($jobs) - ->onQueue(config('laserpoints.process_delphi_queue')) - ->finally(function () use ($gatherFile) { - Storage::disk(config('laserpoints.disk'))->delete($gatherFile); - }) - ->dispatch(); - } - } -} diff --git a/src/Jobs/ProcessVolumeManualJob.php b/src/Jobs/ProcessVolumeManualJob.php new file mode 100644 index 00000000..ebafb222 --- /dev/null +++ b/src/Jobs/ProcessVolumeManualJob.php @@ -0,0 +1,64 @@ +volume->id) + ->join('image_annotations', 'images.id', '=', 'image_annotations.image_id') + ->join('image_annotation_labels', 'image_annotation_labels.annotation_id', '=', 'image_annotations.id') + ->where('image_annotation_labels.label_id', $this->label->id) + ->where('image_annotations.shape_id', Shape::pointId()) + ->select('images.id as images_id') + ->distinct() + ->eachById(function ($image) { + // Reassign the ID because the ambiguous column had to use an alias. + $image->id = $image->images_id; + ProcessImageManualJob::dispatch($image, $this->label, $this->distance) + ->onQueue(config('laserpoints.process_manual_queue')); + }, 1000, 'images.id', 'images_id'); + } +} diff --git a/src/LaserpointsServiceProvider.php b/src/LaserpointsServiceProvider.php index 42fb27fd..21a86671 100644 --- a/src/LaserpointsServiceProvider.php +++ b/src/LaserpointsServiceProvider.php @@ -58,29 +58,5 @@ public function boot(Modules $modules, Router $router) public function register() { $this->mergeConfigFrom(__DIR__.'/config/laserpoints.php', 'laserpoints'); - - $this->app->singleton('command.laserpoints.publish', function ($app) { - return new \Biigle\Modules\Laserpoints\Console\Commands\Publish(); - }); - $this->app->singleton('command.laserpoints.config', function ($app) { - return new \Biigle\Modules\Laserpoints\Console\Commands\Config(); - }); - - $this->commands([ - 'command.laserpoints.publish', - 'command.laserpoints.config', - ]); - } - /** - * Get the services provided by the provider. - * - * @return array - */ - public function provides() - { - return [ - 'command.laserpoints.publish', - 'command.laserpoints.config', - ]; } } diff --git a/src/Support/DelphiApply.php b/src/Support/DelphiApply.php deleted file mode 100644 index 06e84dc9..00000000 --- a/src/Support/DelphiApply.php +++ /dev/null @@ -1,28 +0,0 @@ -&1"; - - return $this->exec($command); - } -} diff --git a/src/Support/DelphiGather.php b/src/Support/DelphiGather.php deleted file mode 100644 index 5768acb0..00000000 --- a/src/Support/DelphiGather.php +++ /dev/null @@ -1,89 +0,0 @@ -outputPath = "{$tmpDir}/{$tmpFile}"; - } - - /** - * Execute a new Delphi preprocessing. - * - * @param string $path Path to the image file - * @param string $points JSON encoded array of laser point coordinates for the image - * @throws Exception If the script crashed. - */ - public function execute($path, $points) - { - $script = config('laserpoints.delphi_gather_script'); - - return $this->python("{$script} '{$path}' '{$points}' '{$this->outputPath}'"); - } - - /** - * Finish the Delphi preprocessing after all images have been processed. - * - * @throws Exception If the script crashed. - */ - public function finish() - { - $script = config('laserpoints.delphi_gather_finish_script'); - - return $this->python("{$script} '{$this->outputPath}'"); - } - - /** - * Get the path to the temporary ouput file of the Delphi gather script. - * - * @return string - */ - public function getOutputPath() - { - return $this->outputPath; - } - - /** - * Execute a python script. - * - * @param string $command Script and arguments. - * @throws Exception If the script crashed. - */ - protected function python($command) - { - $code = 0; - $python = config('laserpoints.python'); - $lines = []; - $output = exec("{$python} {$command} 2>&1", $lines, $code); - - if ($output || $code !== 0) { - $message = "Fatal error with Delphi gather script (code {$code})."; - Log::error($message, [ - 'command' => $command, - 'output' => $lines, - ]); - File::delete($this->outputPath); - - throw new Exception($message); - } - } -} diff --git a/src/Support/DetectAutomatic.php b/src/Support/DetectAutomatic.php new file mode 100644 index 00000000..2cd4b569 --- /dev/null +++ b/src/Support/DetectAutomatic.php @@ -0,0 +1,49 @@ +&1"; + + return $this->exec($command); + } + + $tmpDir = config('laserpoints.tmp_dir'); + $lineInfoPath = tempnam($tmpDir, 'biigle_lines_'); + File::put($lineInfoPath, $lineInfo); + + try { + $command = "{$python} {$script} " . + "--input '{$imagePath}' " . + "--lines-file '{$lineInfoPath}' " . + "--laserdistance '{$distance}' " . + "--mode biigle_mode_with_lines 2>&1"; + + return $this->exec($command); + + } finally { + File::delete($lineInfoPath); + } + } +} diff --git a/src/Support/DetectLines.php b/src/Support/DetectLines.php new file mode 100644 index 00000000..d1c14ed2 --- /dev/null +++ b/src/Support/DetectLines.php @@ -0,0 +1,49 @@ +&1"; + + $this->exec($command, decode: false); + $linesInfo = File::get($workDir.'/fitted_lines.json'); + } finally { + File::deleteDirectory($workDir); + } + + $linesJson = json_decode($linesInfo); + if (!$linesJson || empty($linesInfo->lines)) { + return null; + } + + return $linesInfo; + } +} diff --git a/src/Support/Detect.php b/src/Support/DetectManual.php similarity index 65% rename from src/Support/Detect.php rename to src/Support/DetectManual.php index 93801b56..14b76068 100644 --- a/src/Support/Detect.php +++ b/src/Support/DetectManual.php @@ -5,23 +5,23 @@ /** * Wrapper for the manual laser points detection script. */ -class Detect extends LaserpointsScript +class DetectManual extends LaserpointsScript { /** * Execute a new manual laser point detection. * - * @param string $imageUrl Absolute path to the image file to detect laserpoints on + * @param string $imagePath Absolute path to the image file to detect laserpoints on * @param float $distance Distance of the laser points in cm * @param string $points Coordinates of all manually annotated laser points on the image as JSON encoded string (like `'[[100,100],[200,200]]'`) * @throws Exception If the detection script crashed. * * @return array The JSON object returned by the detect script as array */ - public function execute($imageUrl, $distance, $points) + public function execute($imagePath, $distance, $points) { $python = config('laserpoints.python'); - $script = config('laserpoints.detect_script'); - $command = "{$python} {$script} '{$imageUrl}' '{$distance}' '{$points}' 2>&1"; + $script = config('laserpoints.manual_script'); + $command = "{$python} {$script} '{$imagePath}' '{$distance}' '{$points}' 2>&1"; return $this->exec($command); } diff --git a/src/Support/LaserpointsScript.php b/src/Support/LaserpointsScript.php index 8f425f7f..54235681 100644 --- a/src/Support/LaserpointsScript.php +++ b/src/Support/LaserpointsScript.php @@ -15,15 +15,18 @@ class LaserpointsScript * * @return array The JSON object returned by the detection script as array */ - public function exec($command) + public function exec($command, $decode = true) { $code = 0; $lines = []; - $output = json_decode(exec($command, $lines, $code), true); + $output = exec($command, $lines, $code); + if ($decode) { + $output = json_decode($output, true); + } // Common script errors are handled gracefully with JSON error output. If the // output is no valid JSON with an 'error' property the script crashed fatally. - if ($output === null || !array_key_exists('error', $output)) { + if ($code !== 0 || $decode && ($output === null || !array_key_exists('error', $output))) { $message = "Fatal error with laser point detection (code {$code})."; Log::error($message, [ 'command' => $command, diff --git a/src/Volume.php b/src/Volume.php index 5f12971a..cb54e83d 100644 --- a/src/Volume.php +++ b/src/Volume.php @@ -16,14 +16,6 @@ class Volume extends BaseVolume { use FiltersInvalidLaserPoints; - /** - * Minimum number of manually annotated images required for Delphi laser point - * detection. - * - * @var int - */ - const MIN_DELPHI_IMAGES = 4; - /** * Converts a regular Biigle volume to a Laserpoints volume. * @@ -41,13 +33,13 @@ public static function convert(BaseVolume $volume) } /** - * Determines if the images of this volume can be processed with Delphi. + * Determines if the images of this volume have a valid number of manually annotated laser points. * * @param Label $label The laser point label. * - * @throws Exception If the images of this volume can't be processed with Delphi + * @throws Exception If the images of this volume have an invalid count of manually annotated laser points */ - public function readyForDelphiDetection(Label $label) + public function readyForManualDetection(Label $label) { $points = ImageAnnotation::join('image_annotation_labels', 'image_annotation_labels.annotation_id', '=', 'image_annotations.id') ->join('images', 'image_annotations.image_id', '=', 'images.id') @@ -62,10 +54,6 @@ public function readyForDelphiDetection(Label $label) return $annotations->count(); }); - if ($points->count() < self::MIN_DELPHI_IMAGES) { - throw new Exception('Only '.$points->count().' images have manually annotated laser points. At least '.self::MIN_DELPHI_IMAGES.' are required.'); - } - $reference = $points->first(); if ($reference < Image::MIN_MANUAL_POINTS) { throw new Exception('There must be at least '.Image::MIN_MANUAL_POINTS.' manually annotated laser points per image ('.$reference.' found).'); diff --git a/src/config/laserpoints.php b/src/config/laserpoints.php index bc662f09..7e77d6b7 100644 --- a/src/config/laserpoints.php +++ b/src/config/laserpoints.php @@ -8,38 +8,23 @@ 'python' => '/usr/bin/python3', /* - | Path to the detect script. + | Path to the manual detection script. */ - 'detect_script' => __DIR__.'/../resources/scripts/detect.py', + 'manual_script' => __DIR__.'/../resources/scripts/manual.py', /* - | Path to the Delphi gather script. + | Path to the automatic detection script. */ - 'delphi_gather_script' => __DIR__.'/../resources/scripts/delphi_gather.py', - - /* - | Path to the Delphi gather finish script. - */ - 'delphi_gather_finish_script' => __DIR__.'/../resources/scripts/delphi_gather_finish.py', - - /* - | Path to the Delphi apply script. - */ - 'delphi_apply_script' => __DIR__.'/../resources/scripts/delphi_apply.py', + 'automatic_script' => __DIR__.'/../resources/scripts/automatic.py', /* | Directory for temporary files. */ 'tmp_dir' => sys_get_temp_dir(), - /* - | Storage disk to store Delphi gather files that are shared by queued jobs. - */ - 'disk' => env('LASERPOINTS_STORAGE_DISK', 'laserpoints'), - /* | Specifies which queue should be used for which job. */ - 'process_delphi_queue' => env('LASERPOINTS_PROCESS_DELPHI_QUEUE', 'default'), + 'process_automatic_queue' => env('LASERPOINTS_PROCESS_AUTOMATIC_QUEUE', env('LASERPOINTS_PROCESS_DELPHI_QUEUE', 'default')), 'process_manual_queue' => env('LASERPOINTS_PROCESS_MANUAL_QUEUE', 'default'), ]; diff --git a/src/resources/assets/js/api/laserpoints.js b/src/resources/assets/js/api/laserpoints.js index 6a4566ca..93e9f031 100644 --- a/src/resources/assets/js/api/laserpoints.js +++ b/src/resources/assets/js/api/laserpoints.js @@ -6,21 +6,29 @@ import { Resource } from '../import.js'; * var resource = biigle.$require('api.laserpoints'); * * Perform the laser point detection on all images of a volume: - * resource.processVolume({volume_id: 1}, {distance: 50}).then(...); + * resource.processVolumeAutomatic({volume_id: 1}, {distance: 50}).then(...); * * Perform the laser point detection on a single image: - * resource.processImage({image_id: 1}, {distance: 50}).then(...); + * resource.processImageAutomatic({image_id: 1}, {distance: 50}).then(...); * * Get the laser point information for an image * resource.get({image_id: 1}).then(...); */ export default Resource('api/v1/images{/image_id}/laserpoints', {}, { - processVolume: { + processVolumeAutomatic: { method: 'POST', - url: 'api/v1/volumes{/volume_id}/laserpoints/area', + url: 'api/v1/volumes{/volume_id}/laserpoints/automatic', }, - processImage: { + processImageAutomatic: { method: 'POST', - url: 'api/v1/images{/image_id}/laserpoints/area', + url: 'api/v1/images{/image_id}/laserpoints/automatic', + }, + processVolumeManual: { + method: 'POST', + url: 'api/v1/volumes{/volume_id}/laserpoints/manual', + }, + processImageManual: { + method: 'POST', + url: 'api/v1/images{/image_id}/laserpoints/manual', }, }); diff --git a/src/resources/assets/js/components/laserpointsForm.vue b/src/resources/assets/js/components/laserpointsForm.vue index 58950db9..a282344d 100644 --- a/src/resources/assets/js/components/laserpointsForm.vue +++ b/src/resources/assets/js/components/laserpointsForm.vue @@ -1,13 +1,36 @@