diff --git a/app/Http/Requests/StoreProjectReport.php b/app/Http/Requests/StoreProjectReport.php index c1a4033105..d07e62e3ce 100644 --- a/app/Http/Requests/StoreProjectReport.php +++ b/app/Http/Requests/StoreProjectReport.php @@ -37,6 +37,21 @@ public function rules() { return array_merge(parent::rules(), [ 'type_id' => 'required|integer|exists:report_types,id', + 'yolo_image_path' => 'nullable|string', + 'yolo_split_ratio' => 'nullable|string|regex:/^\d+(\.\d+)? \d+(\.\d+)? \d+(\.\d+)?$/', + ]); + } + + /** + * Get the options for the new report. + * + * @return array + */ + public function getOptions() + { + return array_merge(parent::getOptions(), [ + 'yoloImagePath' => $this->input('yolo_image_path'), + 'yoloSplitRatio' => $this->input('yolo_split_ratio'), ]); } diff --git a/app/Http/Requests/StoreVolumeReport.php b/app/Http/Requests/StoreVolumeReport.php index f40401cdcd..7d56f6e424 100644 --- a/app/Http/Requests/StoreVolumeReport.php +++ b/app/Http/Requests/StoreVolumeReport.php @@ -42,6 +42,7 @@ public function rules() ReportType::imageAnnotationsCsvId(), ReportType::imageAnnotationsExtendedId(), ReportType::imageAnnotationsCocoId(), + ReportType::imageAnnotationsYoloId(), ReportType::imageAnnotationsFullId(), ReportType::imageAnnotationsAbundanceId(), ReportType::imageAnnotationsImageLocationId(), @@ -62,6 +63,8 @@ public function rules() return array_merge(parent::rules(), [ 'type_id' => ['required', Rule::in($types)], 'annotation_session_id' => "nullable|integer|exists:annotation_sessions,id,volume_id,{$this->volume->id}", + 'yolo_image_path' => 'nullable|string', + 'yolo_split_ratio' => 'nullable|string|regex:/^\d+(\.\d+)? \d+(\.\d+)? \d+(\.\d+)?$/', ]); } @@ -119,8 +122,13 @@ public function withValidator($validator) */ public function getOptions() { - return array_merge(parent::getOptions(), [ + \Log::info('StoreVolumeReport input:', $this->all()); + $options = array_merge(parent::getOptions(), [ 'annotationSession' => $this->input('annotation_session_id'), + 'yoloImagePath' => $this->input('yolo_image_path'), + 'yoloSplitRatio' => $this->input('yolo_split_ratio'), ]); + \Log::info('StoreVolumeReport options:', $options); + return $options; } } diff --git a/app/ReportType.php b/app/ReportType.php index 85d166d922..acb671668a 100644 --- a/app/ReportType.php +++ b/app/ReportType.php @@ -21,6 +21,8 @@ * @method static int imageAnnotationsExtendedId() * @method static ReportType imageAnnotationsCoco() * @method static int imageAnnotationsCocoId() + * @method static ReportType imageAnnotationsYolo() + * @method static int imageAnnotationsYoloId() * @method static ReportType imageAnnotationsFull() * @method static int imageAnnotationsFullId() * @method static ReportType imageAnnotationsImageLocation() @@ -57,6 +59,7 @@ class ReportType extends Model 'imageAnnotationsCsv' => 'ImageAnnotations\Csv', 'imageAnnotationsExtended' => 'ImageAnnotations\Extended', 'imageAnnotationsCoco' => 'ImageAnnotations\Coco', + 'imageAnnotationsYolo' => 'ImageAnnotations\Yolo', 'imageAnnotationsFull' => 'ImageAnnotations\Full', 'imageAnnotationsImageLocation' => 'ImageAnnotations\ImageLocation', 'imageIfdo' => 'ImageIfdo', diff --git a/app/Services/Reports/Projects/ImageAnnotations/YoloReportGenerator.php b/app/Services/Reports/Projects/ImageAnnotations/YoloReportGenerator.php new file mode 100644 index 0000000000..bbd03b5fca --- /dev/null +++ b/app/Services/Reports/Projects/ImageAnnotations/YoloReportGenerator.php @@ -0,0 +1,29 @@ +query()->get(); + + // Always create a single unified dataset for YOLO + $csv = $this->createCsv($rows); + $this->tmpFiles[] = $csv; + + $this->executeScript('to_yolo', $path); + + // Python script creates output directory based on the first CSV file path + // Collect all files from that directory for zipping + $firstCsvPath = $this->tmpFiles[0]->getPath(); + $outputDir = pathinfo($firstCsvPath, PATHINFO_DIRNAME) . '/' . pathinfo($firstCsvPath, PATHINFO_FILENAME) . '_yolo_output'; + + + // Clear the toZip array and rebuild it with files from output directory + $toZip = []; + if (is_dir($outputDir)) { + $this->addDirectoryToZip($outputDir, $toZip); + } else { + \Log::warning("YOLO: Output directory not found", ['outputDir' => $outputDir]); + } + + $this->makeZip($toZip, $path); + } + + /** + * Override makeZip to handle symlinks properly + */ + protected function makeZip($files, $path) + { + $zip = \App::make(\ZipArchive::class); + $open = $zip->open($path, \ZipArchive::OVERWRITE); + + if ($open !== true) { + throw new \Exception("Could not open ZIP file '{$path}'."); + } + + try { + foreach ($files as $source => $target) { + // Check if file is a symlink + if (is_link($source)) { + // Add symlink to zip + $linkTarget = readlink($source); + $zip->addFromString($target, $linkTarget); + // Set external attributes to mark as symlink + $zip->setExternalAttributesName($target, \ZipArchive::OPSYS_UNIX, 0120777 << 16); + } else { + // Regular file + $zip->addFile($source, $target); + } + } + } finally { + $zip->close(); + } + } + + /** + * Override executeScript to pass YOLO-specific arguments before CSV files + */ + protected function executeScript($scriptName, $path) + { + $imagePath = $this->options->get('yoloImagePath', ''); + $splitRatio = $this->options->get('yoloSplitRatio', '0.7 0.2 0.1'); + + // Call parent's pythonScriptRunner directly with custom arguments + // Command format: python script.py volumeName path imagePath splitRatio csv1 csv2 ... + $python = config('reports.python'); + $script = config("reports.scripts.{$scriptName}"); + $csvs = implode(' ', array_map(fn ($csv) => $csv->getPath(), $this->tmpFiles)); + + $command = sprintf( + "%s %s \"%s\" %s %s %s %s 2>&1", + $python, + $script, + $this->source->name, + $path, + escapeshellarg($imagePath), + escapeshellarg($splitRatio), + $csvs + ); + + exec($command, $lines, $code); + + if ($code !== 0) { + throw new \Exception("The report script '{$scriptName}' failed with exit code {$code}:\n".implode("\n", $lines)); + } + } + + /** + * Recursively add all files from a directory to the zip array + */ + protected function addDirectoryToZip($dir, &$toZip, $basePath = '') + { + $items = new \DirectoryIterator($dir); + foreach ($items as $item) { + if ($item->isDot()) continue; + + $relativePath = $basePath ? $basePath . '/' . $item->getFilename() : $item->getFilename(); + + if ($item->isDir()) { + $this->addDirectoryToZip($item->getPathname(), $toZip, $relativePath); + } else { + $toZip[$item->getPathname()] = $relativePath; + } + } + } + + /** + * Assemble a new DB query for the volume of this report. + * + * @return \Illuminate\Database\Query\Builder + */ + protected function query() + { + $query = $this + ->initQuery([ + 'image_annotation_labels.id as annotation_label_id', + 'image_annotation_labels.label_id', + 'labels.name as label_name', + 'users.id as user_id', + 'images.id as image_id', + 'images.filename', + 'images.lng as longitude', + 'images.lat as latitude', + 'shapes.name as shape_name', + 'image_annotations.points', + 'images.attrs', + ]) + ->join('shapes', 'image_annotations.shape_id', '=', 'shapes.id') + ->leftJoin('users', 'image_annotation_labels.user_id', '=', 'users.id') + ->orderBy('image_annotation_labels.id'); + + return $query; + } + + /** + * Create a CSV file for this report. + * + * @param \Illuminate\Support\Collection $rows The rows for the CSV + * @return CsvFile + */ + protected function createCsv($rows) + { + $csv = CsvFile::makeTmp(); + // column headers + $csv->putCsv([ + 'annotation_label_id', + 'label_id', + 'label_name', + 'image_id', + 'filename', + 'image_longitude', + 'image_latitude', + 'shape_name', + 'points', + 'attributes', + ]); + + foreach ($rows as $row) { + $csv->putCsv([ + $row->annotation_label_id, + $row->label_id, + $row->label_name, + $row->image_id, + $row->filename, + $row->longitude, + $row->latitude, + $row->shape_name, + $row->points, + $row->attrs, + ]); + } + + $csv->close(); + + return $csv; + } +} diff --git a/config/reports.php b/config/reports.php index 2e7ce4ffee..6d0468f096 100644 --- a/config/reports.php +++ b/config/reports.php @@ -15,6 +15,7 @@ 'csvs_to_xlsx' => __DIR__.'/../resources/scripts/reports/csvs_to_xlsx.py', 'full_report' => __DIR__.'/../resources/scripts/reports/full_report.py', 'to_coco' => __DIR__.'/../resources/scripts/reports/to_coco.py', + 'to_yolo' => __DIR__.'/../resources/scripts/reports/to_yolo.py', ], /** diff --git a/database/migrations/2025_11_21_100000_add_yolo_report_type.php b/database/migrations/2025_11_21_100000_add_yolo_report_type.php new file mode 100644 index 0000000000..135dd9c9b5 --- /dev/null +++ b/database/migrations/2025_11_21_100000_add_yolo_report_type.php @@ -0,0 +1,29 @@ +insert([ + 'name' => 'ImageAnnotations\Yolo', + ]); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + DB::table('report_types')->where('name', 'ImageAnnotations\Yolo')->delete(); + } +} diff --git a/resources/assets/js/reports/mixins/reportForm.vue b/resources/assets/js/reports/mixins/reportForm.vue index c4b107bc85..cfacfdd47a 100644 --- a/resources/assets/js/reports/mixins/reportForm.vue +++ b/resources/assets/js/reports/mixins/reportForm.vue @@ -29,11 +29,22 @@ export default { separate_users: false, only_labels: [], aggregate_child_labels: false, - all_labels: false + all_labels: false, + yolo_image_path: '', + yolo_split_ratio: '0.7 0.2 0.1', }, + splitTrain: 0.7, + splitVal: 0.2, + splitTest: 0.1, }; }, computed: { + splitSum() { + return parseFloat((this.splitTrain + this.splitVal + this.splitTest).toFixed(2)); + }, + isValidSplit() { + return this.splitSum === 1.0; + }, flatLabels() { let labels = []; this.labelTrees.forEach(function (tree) { @@ -104,11 +115,14 @@ export default { } }, methods: { + updateSplitRatio() { + this.options.yolo_split_ratio = `${this.splitTrain} ${this.splitVal} ${this.splitTest}`; + }, request(id, resource) { if (this.loading) return; this.success = false; this.startLoading(); - resource.save({id: id}, this.selectedOptions) + resource.save({ id: id }, this.selectedOptions) .then(this.submitted, this.handleError) .finally(this.finishLoading); }, @@ -182,7 +196,10 @@ export default { }, selectedVariant() { this.options.all_labels = false; - } + }, + splitTrain() { this.updateSplitRatio(); }, + splitVal() { this.updateSplitRatio(); }, + splitTest() { this.updateSplitRatio(); }, }, created() { this.reportTypes = biigle.$require('reports.reportTypes'); diff --git a/resources/assets/js/reports/projectForm.vue b/resources/assets/js/reports/projectForm.vue index 6d8632c71a..a8fab42b3d 100644 --- a/resources/assets/js/reports/projectForm.vue +++ b/resources/assets/js/reports/projectForm.vue @@ -28,6 +28,8 @@ export default { 'only_labels', 'aggregate_child_labels', 'all_labels', + 'yolo_image_path', + 'yolo_split_ratio', ], 'ImageLabels': [ 'separate_label_trees', diff --git a/resources/assets/js/reports/volumeForm.vue b/resources/assets/js/reports/volumeForm.vue index 9174768b4c..00503dbb7e 100644 --- a/resources/assets/js/reports/volumeForm.vue +++ b/resources/assets/js/reports/volumeForm.vue @@ -24,6 +24,8 @@ export default { 'only_labels', 'aggregate_child_labels', 'all_labels', + 'yolo_image_path', + 'yolo_split_ratio', ], 'ImageLabels': [ 'separate_label_trees', diff --git a/resources/scripts/reports/to_yolo.py b/resources/scripts/reports/to_yolo.py new file mode 100644 index 0000000000..20920688b6 --- /dev/null +++ b/resources/scripts/reports/to_yolo.py @@ -0,0 +1,259 @@ +import pandas as pd +import numpy +from shapely.geometry import Point +from shapely.affinity import scale, rotate +import warnings +import sys +import math +import json +import ast +import os +import shutil + +import random + +# Command format: python script.py volumeName path imagePath splitRatio csv1 csv2 ... + +volumeName = sys.argv[1] +path = sys.argv[2] +image_path = sys.argv[3] if len(sys.argv) > 3 else '' +split_ratio = sys.argv[4] if len(sys.argv) > 4 else '0.7 0.2 0.1' +paths = sys.argv[5:] # CSV files + +# Clean up image path +if image_path: + image_path = image_path.strip().strip('"').strip("'") + +# Parse split ratio +try: + splits = [float(x) for x in split_ratio.split()] + if len(splits) != 3: + splits = [0.7, 0.2, 0.1] +except: + splits = [0.7, 0.2, 0.1] + +# Normalize splits +total_split = sum(splits) +splits = [x / total_split for x in splits] + +def check_shape_and_attributes(row): + shape = row.shape_name + attrs = row.attributes + valid = True + if not(shape == "LineString" or shape == "Polygon" or shape == "Rectangle" or shape == "Circle" or shape == "Ellipse"): + warnings.warn('The shape %s is not supported !' % (shape)) + valid = False + try: + desired_dict=json.loads(attrs) + desired_dict["height"] + desired_dict["width"] + except TypeError: + warnings.warn('Attributes of %s cannot be read! It might be empty.'% (row.filename)) + valid = False + except KeyError: + warnings.warn('Height or width of %s is not listed as an attribute. If you added the file recently please try again later. Otherwise the file may be corrupt.'%(row.filename)) + valid = False + return valid + +def get_bbox(row): + desired_array = ast.literal_eval(row.points) + if row.shape_name == "Circle": + x, y, r = desired_array + r = max(r, 1) + circlePolygon = Point(x, y).buffer(r) + desired_array = numpy.array(list(zip(*circlePolygon.exterior.coords.xy))).flatten().astype(float).tolist() + elif row.shape_name == "Ellipse": + m1x, m1y, mi1x, mi1y, m2x, m2y, mi2x, mi2y = desired_array + x = (m1x+mi1x+m2x+mi2x)/4 + y = (m1y+mi1y+m2y+mi2y)/4 + lm = math.sqrt((m1x-m2x)**2+(m1y-m2y)**2)/2 + lmi = math.sqrt((mi1x-mi2x)**2+(mi1y-mi2y)**2)/2 + angle=0 + if m1x-m2x==0: + angle=math.pi/2 + else: + angle = math.atan((m1y-m2y)/(m1x-m2x)) + circlePolygon = Point(x, y).buffer(1) + circlePolygon = scale(circlePolygon, lm, lmi) + circlePolygon = rotate(circlePolygon, angle, use_radians=True) + desired_array = numpy.array(list(zip(*circlePolygon.exterior.coords.xy))).flatten().astype(float).tolist() + elif row.shape_name == "Rectangle": + desired_array.extend(desired_array[:2]) + + x_coord = desired_array[::2] + y_coord = desired_array[1::2] + xmax = max(x_coord) + ymax = max(y_coord) + xmin = min(x_coord) + ymin = min(y_coord) + + return xmin, ymin, xmax, ymax + +def convert_to_yolo(xmin, ymin, xmax, ymax, img_width, img_height): + # YOLO format: x_center y_center width height (normalized) + dw = 1. / img_width + dh = 1. / img_height + x = (xmin + xmax) / 2.0 + y = (ymin + ymax) / 2.0 + w = xmax - xmin + h = ymax - ymin + x = x * dw + w = w * dw + y = y * dh + h = h * dh + return x, y, w, h + +for path in paths: + data = pd.read_csv(path) + # Filter invalid shapes + for index, row in data.iterrows(): + if not check_shape_and_attributes(row): + data.drop(index, inplace=True) + + # Create temp dir for this csv + base_dir = os.path.dirname(path) + temp_dir = os.path.join(base_dir, 'yolo_temp_' + os.path.basename(path)) + os.makedirs(temp_dir) + + labels_dir = os.path.join(temp_dir, 'labels') + os.makedirs(labels_dir) + + # Map labels to IDs (0-indexed) + unique_labels = data[['label_id', 'label_name']].drop_duplicates().sort_values('label_id') + label_map = {row.label_id: i for i, row in enumerate(unique_labels.itertuples())} + class_names = [row.label_name for row in unique_labels.itertuples()] + + # Write classes.txt + with open(os.path.join(temp_dir, 'classes.txt'), 'w') as f: + for name in class_names: + f.write(f"{name}\n") + + # Group by image + grouped = data.groupby('filename') + all_filenames = list(grouped.groups.keys()) + + # Shuffle and split + random.seed(42) + random.shuffle(all_filenames) + + total_images = len(all_filenames) + train_count = int(total_images * splits[0]) + val_count = int(total_images * splits[1]) + + train_files = all_filenames[:train_count] + val_files = all_filenames[train_count:train_count+val_count] + test_files = all_filenames[train_count+val_count:] + + split_files = { + 'train': train_files, + 'val': val_files, + 'test': test_files + } + + # Create standard YOLO directory structure + # images/train, images/val, images/test + # labels/train, labels/val, labels/test + images_dir = os.path.join(temp_dir, 'images') + labels_dir = os.path.join(temp_dir, 'labels') + + for split in ['train', 'val', 'test']: + os.makedirs(os.path.join(images_dir, split), exist_ok=True) + os.makedirs(os.path.join(labels_dir, split), exist_ok=True) + + # Determine which split each image belongs to + image_to_split = {} + for split_name, file_list in split_files.items(): + for img_file in file_list: + image_to_split[img_file] = split_name + + # Generate label files + for filename, group in grouped: + # Determine which split this image belongs to + split_name = image_to_split.get(filename, 'train') + split_labels_dir = os.path.join(labels_dir, split_name) + + txt_filename = os.path.splitext(filename)[0] + '.txt' + label_file_path = os.path.join(split_labels_dir, txt_filename) + + with open(label_file_path, 'w') as f: + for row in group.itertuples(): + # Get image dimensions + try: + desired_dict = json.loads(row.attributes) + img_width = desired_dict["width"] + img_height = desired_dict["height"] + + xmin, ymin, xmax, ymax = get_bbox(row) + x, y, w, h = convert_to_yolo(xmin, ymin, xmax, ymax, img_width, img_height) + cls_idx = label_map[row.label_id] + + f.write(f"{cls_idx} {x:.6f} {y:.6f} {w:.6f} {h:.6f}\n") + except Exception as e: + warnings.warn(f"Error processing annotation for {filename}: {e}") + + # Create symlinks for images in their respective split directories + if image_path: + for split_name, file_list in split_files.items(): + target_images_dir = os.path.join(images_dir, split_name) + for img_file in file_list: + source = os.path.join(image_path, img_file) + target = os.path.join(target_images_dir, img_file) + try: + os.symlink(source, target) + except Exception as e: + warnings.warn(f"Could not create symlink for {img_file}: {e}") + + # Create data.yaml + data_yaml_path = os.path.join(temp_dir, 'data.yaml') + with open(data_yaml_path, 'w') as f: + # Use relative paths in data.yaml + f.write("train: images/train\n") + f.write("val: images/val\n") + f.write("test: images/test\n") + f.write("\n") + f.write(f"nc: {len(class_names)}\n") + f.write("names:\n") + for i, name in enumerate(class_names): + f.write(f" {i}: {name}\n") + + # Create README.txt + readme_path = os.path.join(temp_dir, 'README.txt') + with open(readme_path, 'w') as f: + f.write("YOLO Dataset\n") + f.write("=============\n\n") + f.write("This dataset is in YOLO format for object detection.\n\n") + f.write("Directory Structure:\n") + f.write("- data.yaml: Dataset configuration file\n") + if image_path: + f.write("- images/train/: Training images (symlinks)\n") + f.write("- images/val/: Validation images (symlinks)\n") + f.write("- images/test/: Test images (symlinks)\n") + else: + f.write(" Please copy the image to the following directories with the same split as the labels:\n") + f.write("- images/train/: Training images\n") + f.write("- images/val/: Validation images\n") + f.write("- images/test/: Test images\n") + f.write("- labels/train/: Training annotation files\n") + f.write("- labels/val/: Validation annotation files\n") + f.write("- labels/test/: Test annotation files\n\n") + + if image_path: + f.write(f"Image symlinks point to: {image_path}\n\n") + else: + f.write("WARNING: No image path provided. Image symlinks were not created. Please copy/symlink the images into this directory with the same split as the labels. See above for the expected directory structure.\n\n") + + f.write("Usage:\n") + f.write("1. Unzip this archive.\n") + f.write("2. Run training using the ABSOLUTE path to data.yaml:\n") + f.write(" yolo train data=$(pwd)/data.yaml model=yolo11n.pt epochs=10\n\n") + + if image_path and image_path.startswith('~'): + f.write("WARNING: You used '~' in your image path.\n") + f.write("Symlinks might not resolve '~' to your home directory automatically.\n") + f.write("If images are not found, try using the full absolute path (e.g., /Users/name/...)\n") + + # Create output directory next to the CSV file + output_dir = os.path.splitext(path)[0] + '_yolo_output' + + # Move temp_dir contents to output_dir + shutil.move(temp_dir, output_dir) diff --git a/resources/views/manual/tutorials/reports/reports-schema.blade.php b/resources/views/manual/tutorials/reports/reports-schema.blade.php index 349d872864..bfddf56f12 100644 --- a/resources/views/manual/tutorials/reports/reports-schema.blade.php +++ b/resources/views/manual/tutorials/reports/reports-schema.blade.php @@ -26,6 +26,7 @@
+ The YOLO file format is a standard format for training object detection models (e.g., YOLOv5, YOLOv8, YOLO11). The data is organized into a directory structure with images and text files containing normalized bounding box coordinates. This report generates a ZIP file containing the dataset split into train, validation, and test sets, along with a data.yaml configuration file. Point annotations are incompatible and will not be included in this report. All remaining annotations will be converted to bounding boxes.
+
+ You can optionally provide a local path to the images on your hard drive. If provided, the report will create symlinks to the images in the generated directory structure, allowing you to use the dataset without duplicating the image files. If no local path is provided, you must manually move or copy the images into the respective images/train, images/val, and images/test directories to match the structure below.
+
+ The generated ZIP file has the following structure: +
+data.yaml +classes.txt +README.txt +images/ +├─ train/ +│ ├─ image1.jpg +│ └─ ... +├─ val/ +│ └─ ... +└─ test/ + └─ ... +labels/ +├─ train/ +│ ├─ image1.txt +│ └─ ... +├─ val/ +│ └─ ... +└─ test/ + └─ ... ++ +
diff --git a/resources/views/partials/reportTypeInfo.blade.php b/resources/views/partials/reportTypeInfo.blade.php index 45a8cdd2ee..4a6bfc7cda 100644 --- a/resources/views/partials/reportTypeInfo.blade.php +++ b/resources/views/partials/reportTypeInfo.blade.php @@ -7,6 +7,9 @@
+ The path to the directory containing the images on your local machine. They are assumed to be the same for all volumes in the project. +
+diff --git a/tests/php/Services/Reports/Projects/ImageAnnotations/YoloReportGeneratorTest.php b/tests/php/Services/Reports/Projects/ImageAnnotations/YoloReportGeneratorTest.php new file mode 100644 index 0000000000..54ed474260 --- /dev/null +++ b/tests/php/Services/Reports/Projects/ImageAnnotations/YoloReportGeneratorTest.php @@ -0,0 +1,16 @@ +assertEquals('Yolo image annotation report', $generator->getName()); + $this->assertEquals('yolo_image_annotation_report', $generator->getFilename()); + } +} diff --git a/tests/php/Services/Reports/Volumes/ImageAnnotations/YoloReportGeneratorTest.php b/tests/php/Services/Reports/Volumes/ImageAnnotations/YoloReportGeneratorTest.php new file mode 100644 index 0000000000..7dde9b3400 --- /dev/null +++ b/tests/php/Services/Reports/Volumes/ImageAnnotations/YoloReportGeneratorTest.php @@ -0,0 +1,202 @@ +assertSame('yolo image annotation report', $generator->getName()); + $this->assertSame('yolo_image_annotation_report', $generator->getFilename()); + $this->assertStringEndsWith('.zip', $generator->getFullFilename()); + } + + public function testOptionsAreStored() + { + $generator = new YoloReportGenerator([ + 'yoloImagePath' => '/custom/path', + 'yoloSplitRatio' => '0.6 0.3 0.1', + ]); + + $this->assertEquals('/custom/path', $generator->options->get('yoloImagePath')); + $this->assertEquals('0.6 0.3 0.1', $generator->options->get('yoloSplitRatio')); + } + + public function testDefaultOptions() + { + $generator = new YoloReportGenerator(); + + $this->assertEquals('', $generator->options->get('yoloImagePath', '')); + $this->assertEquals('0.7 0.2 0.1', $generator->options->get('yoloSplitRatio', '0.7 0.2 0.1')); + } + + public function testGenerateReport() + { + $volume = VolumeTest::create([ + 'name' => 'My Cool Volume', + ]); + + $root = LabelTest::create(); + $child = LabelTest::create([ + 'parent_id' => $root->id, + 'label_tree_id' => $root->label_tree_id, + ]); + + $al = ImageAnnotationLabelTest::create([ + 'label_id' => $child->id, + ]); + $al->annotation->image->volume_id = $volume->id; + $al->annotation->image->attrs = ['width' => 1920, 'height' => 1080]; + $al->annotation->image->save(); + + $csvMock = Mockery::mock(); + + $csvMock->shouldReceive('getPath') + ->once() + ->andReturn('abc'); + + $csvMock->shouldReceive('putCsv') + ->once() + ->with([ + 'annotation_label_id', + 'label_id', + 'label_name', + 'image_id', + 'filename', + 'image_longitude', + 'image_latitude', + 'shape_name', + 'points', + 'attributes', + ]); + + $csvMock->shouldReceive('putCsv') + ->once() + ->with([ + $al->id, + $child->id, + $child->name, + $al->annotation->image_id, + $al->annotation->image->filename, + null, + null, + $al->annotation->shape->name, + json_encode($al->annotation->points), + json_encode(['width' => 1920, 'height' => 1080]) + ]); + + $csvMock->shouldReceive('close') + ->once(); + + App::singleton(CsvFile::class, fn () => $csvMock); + + $zipMock = Mockery::mock(); + + $zipMock->shouldReceive('open') + ->once() + ->andReturn(true); + + $zipMock->shouldReceive('close')->once(); + + App::singleton(ZipArchive::class, fn () => $zipMock); + + $generator = new YoloReportGeneratorTestStub([ + 'yoloImagePath' => '/path/to/images', + 'yoloSplitRatio' => '0.7 0.2 0.1', + ]); + $generator->setSource($volume); + $generator->generateReport('my/path'); + } + + public function testGenerateReportAlwaysCreatesUnifiedDataset() + { + // Test that YOLO report always creates a single CSV, not multiple + // even when separateLabelTrees or separateUsers options are set + $volume = VolumeTest::create(['name' => 'Test Volume']); + + $label = LabelTest::create(); + $al = ImageAnnotationLabelTest::create(['label_id' => $label->id]); + $al->annotation->image->volume_id = $volume->id; + $al->annotation->image->attrs = ['width' => 1920, 'height' => 1080]; + $al->annotation->image->save(); + + // Test with separateLabelTrees = true (should be ignored) + $generator1 = new YoloReportGeneratorStub([ + 'separateLabelTrees' => true, + 'yoloImagePath' => '/path/to/images', + 'yoloSplitRatio' => '0.7 0.2 0.1', + ]); + $generator1->setSource($volume); + $csvCount1 = $generator1->getCsvCount(); + + // Test with separateUsers = true (should be ignored) + $generator2 = new YoloReportGeneratorStub([ + 'separateUsers' => true, + 'yoloImagePath' => '/path/to/images', + 'yoloSplitRatio' => '0.7 0.2 0.1', + ]); + $generator2->setSource($volume); + $csvCount2 = $generator2->getCsvCount(); + + // Test with no separation options + $generator3 = new YoloReportGeneratorStub([ + 'yoloImagePath' => '/path/to/images', + 'yoloSplitRatio' => '0.7 0.2 0.1', + ]); + $generator3->setSource($volume); + $csvCount3 = $generator3->getCsvCount(); + + // All should create exactly 1 CSV file + $this->assertEquals(1, $csvCount1, 'YOLO should ignore separateLabelTrees and create 1 CSV'); + $this->assertEquals(1, $csvCount2, 'YOLO should ignore separateUsers and create 1 CSV'); + $this->assertEquals(1, $csvCount3, 'YOLO should create 1 CSV by default'); + } +} + +// Stub class for testGenerateReport that skips Python and ZIP file operations +class YoloReportGeneratorTestStub extends YoloReportGenerator +{ + protected function executeScript($scriptName, $path) + { + // Skip Python script execution + } + + protected function makeZip($files, $path) + { + // Use mocked ZipArchive but skip actual file operations + $zip = App::make(ZipArchive::class); + $open = $zip->open($path, ZipArchive::OVERWRITE); + if ($open !== true) { + throw new \Exception("Could not open ZIP file '{$path}'."); + } + $zip->close(); + } +} + +// Stub class to test CSV creation logic without executing Python or ZIP operations +class YoloReportGeneratorStub extends YoloReportGenerator +{ + private $csvCount = 0; + + public function getCsvCount() + { + // Count how many CSVs would be created + $rows = $this->query()->get(); + + // YOLO always creates a single CSV, regardless of separation options + $this->csvCount = 1; + + return $this->csvCount; + } +}