Skip to content

Commit 9576da9

Browse files
author
Gin
committed
Add code to export annotation to YOLO dataset
1 parent bdeb49c commit 9576da9

11 files changed

+330
-13
lines changed

Common/Cpp/StringTools.cpp

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,25 @@ std::string replace(const std::string& str, const std::string& desired, const st
2626
return ret;;
2727
}
2828

29+
std::string strip(const std::string& str){
30+
size_t first = str.find_first_not_of(" \t\n\r"); // Find first non-whitespace character
31+
if (std::string::npos == first) { // If the string is all whitespace or empty
32+
return ""; // Return an empty string
33+
}
34+
size_t last = str.find_last_not_of(" \t\n\r"); // Find last non-whitespace character
35+
return str.substr(first, (last - first + 1)); // Extract the trimmed substring
36+
}
2937

38+
size_t to_size_t(const std::string& str){
39+
try {
40+
int num = std::stoi(str);
41+
return static_cast<size_t>(num);
42+
} catch (const std::invalid_argument& e) {
43+
return SIZE_MAX;
44+
} catch (const std::out_of_range& e) {
45+
return SIZE_MAX;
46+
}
47+
}
3048

3149

3250
}

Common/Cpp/StringTools.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ namespace StringTools{
1515

1616
std::string replace(const std::string& str, const std::string& desired, const std::string& replace_with);
1717

18+
// Trim leading and trailing white spaces
19+
std::string strip(const std::string& str);
1820

21+
// Parse str to size_t. Return SIZE_MAX if parsing fails
22+
size_t to_size_t(const std::string& str);
1923

2024
}
2125
}

SerialPrograms/Source/ML/DataLabeling/ML_AnnotationIO.cpp

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,23 @@
77

88
#include <fstream>
99
#include <iostream>
10+
#include <map>
1011
#include <QDirIterator>
1112
#include <QDir>
13+
#include <QMessageBox>
1214

15+
#include "Common/Cpp/Json/JsonTools.h"
16+
#include "Common/Cpp/Json/JsonArray.h"
17+
#include "Common/Cpp/Json/JsonObject.h"
18+
#include "Common/Cpp/Json/JsonValue.h"
19+
#include "Common/Cpp/StringTools.h"
20+
#include "Common/Cpp/PrettyPrint.h"
1321
#include "ML_AnnotationIO.h"
1422
#include "ML_SegmentAnythingModelConstants.h"
23+
#include "ML_ObjectAnnotation.h"
24+
25+
namespace fs = std::filesystem;
26+
using std::cout, std::endl;
1527

1628
namespace PokemonAutomation{
1729
namespace ML{
@@ -77,6 +89,206 @@ std::vector<std::string> find_images_in_folder(const std::string& folder_path, b
7789
return all_image_paths;
7890
}
7991

92+
void export_image_annotations_to_yolo_dataset(
93+
const std::string& image_folder_path,
94+
const std::string& annotation_folder_path,
95+
const std::string& yolo_dataset_path
96+
){
97+
const bool recursive = true;
98+
const std::vector<std::string>& image_paths = find_images_in_folder(image_folder_path, recursive);
99+
if (image_paths.size() == 0){
100+
QMessageBox box;
101+
box.critical(nullptr, "Empty Image Folder",
102+
QString::fromStdString("No images found in " + image_folder_path + "."));
103+
return;
104+
}
105+
106+
// TODO for simplicity we will parse this YAML file. In future we should use a proper YAML library
107+
std::ifstream fin(yolo_dataset_path.c_str());
108+
if (!fin){
109+
QMessageBox box;
110+
box.critical(nullptr, "Cannot Open YOLO Dataset Config File",
111+
QString::fromStdString("Cannot open " + yolo_dataset_path + "."));
112+
return;
113+
}
114+
115+
std::vector<std::string> label_names;
116+
bool reading_labels = false;
117+
118+
std::string line;
119+
int line_id = 0;
120+
while(std::getline(fin, line)){
121+
line_id++;
122+
// remove "#" comments
123+
size_t pound_idx = line.find_first_of("#");
124+
if (pound_idx != std::string::npos){
125+
line = line.substr(0, pound_idx);
126+
}
127+
line = StringTools::strip(line);
128+
if (line.size() == 0){
129+
continue;
130+
}
131+
132+
// cout << "Line: " << line << endl;
133+
if (line.starts_with("names:")){
134+
reading_labels = true;
135+
line.clear();
136+
continue;
137+
}
138+
139+
if (reading_labels){
140+
// cout << "start reading labels" << endl;
141+
size_t colon_idx = line.find_first_of(":");
142+
if (colon_idx == std::string::npos){
143+
QMessageBox box;
144+
box.critical(nullptr, "Error Parsing Dataset YAML",
145+
QString::fromStdString("YAML file " + yolo_dataset_path + " line " + std::to_string(line_id) + " contains no colon for labels."));
146+
return;
147+
}
148+
std::string number = StringTools::strip(line.substr(0, colon_idx));
149+
std::string label = StringTools::strip(line.substr(colon_idx+1));
150+
151+
// cout << "found number " << number << " label " << label << endl;
152+
size_t num = StringTools::to_size_t(number);
153+
if (num != label_names.size()){
154+
QMessageBox box;
155+
box.critical(nullptr, "Error Parsing Dataset YAML",
156+
QString::fromStdString("YAML file " + yolo_dataset_path + " line " + std::to_string(line_id) + " has no label index."));
157+
return;
158+
}
159+
label_names.push_back(label);
160+
}
161+
162+
line.clear();
163+
}
164+
165+
std::map<std::string, size_t> label_indices;
166+
cout << "Load dataset labels: " << endl;
167+
for(size_t i = 0; i < label_names.size(); i++){
168+
cout << "- " << label_names[i] << endl;
169+
label_indices[label_names[i]] = i;
170+
}
171+
172+
173+
// convert images and annotations into new subfolders in the folder of the dataset config
174+
auto converted_folder_name = "exported-" + now_to_filestring();
175+
176+
const auto yolo_dataset_config_file = fs::path(yolo_dataset_path);
177+
const fs::path yolo_dataset_folder = yolo_dataset_config_file.parent_path();
178+
const auto target_folder = yolo_dataset_folder / converted_folder_name;
179+
if (fs::exists(target_folder)){
180+
QMessageBox box;
181+
box.critical(nullptr, "Export Destination Folder Already Exists",
182+
QString::fromStdString("Folder " + target_folder.string() + " already exists."));
183+
return;
184+
}
185+
const auto target_image_folder = target_folder / "images";
186+
const auto target_label_folder = target_folder / "labels";
187+
cout << "Export to image folder: " << target_image_folder << endl;
188+
cout << "Export to label folder: " << target_label_folder << endl;
189+
190+
fs::create_directories(target_image_folder);
191+
fs::create_directories(target_label_folder);
192+
193+
fs::path anno_folder(annotation_folder_path);
194+
for(size_t i = 0; i < image_paths.size(); i++){
195+
const auto& image_path = image_paths[i];
196+
const auto image_file = fs::path(image_path);
197+
198+
const std::string anno_filename = image_file.filename().replace_extension(".json").string();
199+
fs::path anno_file = anno_folder / anno_filename;
200+
if (!fs::exists(anno_file)){
201+
QMessageBox box;
202+
box.critical(nullptr, "Cannot Find Annotation File",
203+
QString::fromStdString("No annotation for " + image_path + "."));
204+
return;
205+
}
206+
207+
const auto target_image_file = target_image_folder / image_file.filename();
208+
try{
209+
fs::copy_file(image_file, target_image_file);
210+
} catch (fs::filesystem_error& e)
211+
{
212+
QMessageBox box;
213+
box.critical(nullptr, "Cannot Copy File",
214+
QString::fromStdString(
215+
"Cannot copy from " + image_file.string() + " to " + target_image_file.string() +
216+
". Probably permission issue, source image is broken or target image path already exists due to image folder having same image filenames"
217+
));
218+
return;
219+
}
220+
221+
std::string json_content;
222+
const bool anno_loaded = file_to_string(anno_file.string(), json_content);
223+
if (!anno_loaded){
224+
QMessageBox box;
225+
box.warning(nullptr, "Unable to Load Annotation",
226+
QString::fromStdString("Cannot open annotation file " + anno_file.string() + ". Probably wrong permission?"));
227+
return;
228+
}
229+
230+
const JsonValue loaded_json = parse_json(json_content);
231+
const JsonObject* json_obj = loaded_json.to_object();
232+
if (!json_obj){
233+
QMessageBox box;
234+
box.warning(nullptr, "Wrong JSON content",
235+
QString::fromStdString("Wong JSON content in annotation file " + anno_file.string() +
236+
". Probably older annotataion? Try loading and saving this annotation file."));
237+
return;
238+
}
239+
240+
std::vector<std::string> label_file_lines;
241+
try{
242+
const int64_t image_width = json_obj->get_integer_throw("IMAGE_WIDTH");
243+
const int64_t image_height = json_obj->get_integer_throw("IMAGE_HEIGHT");
244+
const JsonArray& json_array = json_obj->get_array_throw("ANNOTATION");
245+
for(size_t i = 0; i < json_array.size(); i++){
246+
const ObjectAnnotation anno_obj = ObjectAnnotation::from_json((json_array)[i]);
247+
const std::string& label = anno_obj.label;
248+
249+
auto it = label_indices.find(label);
250+
if (it == label_indices.end()){
251+
if (label.ends_with("-male")){
252+
it = label_indices.find(label.substr(0, label.size()-5));
253+
} else if(label.ends_with("-female")){
254+
it = label_indices.find(label.substr(0, label.size()-7));
255+
}
256+
}
257+
if (it == label_indices.end()){
258+
continue; // label not part of the YOLO dataset. Ignored.
259+
}
260+
261+
const size_t label_id = it->second;
262+
263+
// TODO: once we implement the user checkbox on mask reliability, we should change this line
264+
const auto& box = anno_obj.mask_box;
265+
const double center_x = (box.min_x + box.max_x) / (2.0 * image_width);
266+
const double center_y = (box.min_y + box.max_y) / (2.0 * image_height);
267+
const double width = box.width() / (double)image_width;
268+
const double height = box.height() / (double)image_height;
269+
270+
// each row in the YOLO dataclass label file is: class_index x_center y_center width height
271+
// https://docs.ultralytics.com/yolov5/tutorials/train_custom_data/#12-leverage-models-for-automated-labeling
272+
std::ostringstream os;
273+
os << label_id << " " << center_x << " " << center_y << " " << width << " " << height;
274+
label_file_lines.push_back(os.str());
275+
}
276+
} catch(JsonParseException& ){
277+
QMessageBox box;
278+
box.warning(nullptr, "Wrong JSON content",
279+
QString::fromStdString("Wong JSON content in annotation file " + anno_file.string() + "."));
280+
return;
281+
}
282+
283+
const auto target_label_file = target_label_folder / image_file.filename().replace_extension(".txt");
284+
std::ofstream fout(target_label_file.string());
285+
for(const auto& line : label_file_lines){
286+
fout << line << "\n";
287+
}
288+
}
289+
cout << "Done exporting " << image_paths.size() << " annotations to YOLOv5 dataset" << endl;
290+
}
291+
80292

81293
}
82294
}

SerialPrograms/Source/ML/DataLabeling/ML_AnnotationIO.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ void save_image_embedding_to_disk(const std::string& image_filepath, const std::
2525
// Find image paths stored in a folder. The search can be recursive into child folders or not.
2626
std::vector<std::string> find_images_in_folder(const std::string& folder_path, bool recursive);
2727

28+
void export_image_annotations_to_yolo_dataset(
29+
const std::string& image_folder_path,
30+
const std::string& annotation_folder_path,
31+
const std::string& yolo_dataset_path
32+
);
33+
34+
2835
}
2936
}
3037

SerialPrograms/Source/ML/Programs/ML_LabelImages.cpp

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -118,26 +118,38 @@ void LabelImages::from_json(const JsonValue& json){
118118
if (file_path){
119119
load_custom_label_set(*file_path);
120120
}
121+
122+
file_path = obj->get_string("YOLO_CONFIG_FILE_PATH");
123+
if (file_path){
124+
m_yolo_config_file_path = *file_path;
125+
}
121126
}
122127
JsonValue LabelImages::to_json() const{
123128
JsonObject obj = std::move(*m_options.to_json().to_object());
124129
obj["ImageSetup"] = m_display_option.to_json();
125130
obj["CUSTOM_LABEL_SET_FILE_PATH"] = m_custom_label_set_file_path;
131+
obj["YOLO_CONFIG_FILE_PATH"] = m_yolo_config_file_path;
126132

127133
save_annotation_to_file();
128134
return obj;
129135
}
130136

131137
void LabelImages::save_annotation_to_file() const{
132-
// m_annotation_file_path
133-
if (m_annotation_file_path.size() > 0 && !m_fail_to_load_annotation_file){
134-
JsonArray anno_json_arr;
135-
for(const auto& anno_obj: m_annotations){
136-
anno_json_arr.push_back(anno_obj.to_json());
137-
}
138-
cout << "Saving annotation to " << m_annotation_file_path << endl;
139-
anno_json_arr.dump(m_annotation_file_path);
138+
if (m_annotation_file_path.size() == 0 || m_fail_to_load_annotation_file){
139+
return;
140+
}
141+
JsonObject json;
142+
json["IMAGE_WIDTH"] = source_image_width;
143+
json["IMAGE_HEIGHT"] = source_image_height;
144+
145+
JsonArray anno_json_arr;
146+
for(const auto& anno_obj: m_annotations){
147+
anno_json_arr.push_back(anno_obj.to_json());
140148
}
149+
json["ANNOTATION"] = std::move(anno_json_arr);
150+
151+
cout << "Saving annotation to " << m_annotation_file_path << endl;
152+
json.dump(m_annotation_file_path);
141153
}
142154

143155
void LabelImages::clear_for_new_image(){
@@ -188,7 +200,14 @@ void LabelImages::load_image_related_data(const std::string& image_path, size_t
188200
}
189201

190202
const JsonValue loaded_json = parse_json(json_content);
191-
const JsonArray* json_array = loaded_json.to_array();
203+
const JsonObject* json_obj = loaded_json.to_object();
204+
const JsonArray* json_array = nullptr;
205+
if (json_obj == nullptr){
206+
// legacy format, load as an array
207+
json_array = loaded_json.to_array();
208+
} else{
209+
json_array = json_obj->get_array("ANNOTATION");
210+
}
192211
if (json_array == nullptr){
193212
m_fail_to_load_annotation_file = true;
194213
QMessageBox box;
@@ -606,17 +625,26 @@ void LabelImages::load_custom_label_set(const std::string& json_path){
606625
set_selected_label(selected_label());
607626
}
608627

628+
609629
std::pair<size_t, size_t> LabelImages::float_to_pixel(double x, double y) const{
610630
const size_t px = (size_t)std::max<double>(source_image_width * x + 0.5, 0);
611631
const size_t py = (size_t)std::max<double>(source_image_height * y + 0.5, 0);
612632
return std::make_pair(px, py);
613633
}
614634

635+
615636
std::pair<double, double> LabelImages::pixel_to_float(size_t x, size_t y) const{
616637
return std::make_pair(x / (double)source_image_width, y / (double)source_image_height);
617638
}
618639

619640

641+
void LabelImages::export_to_yolov5_dataset(const std::string& image_folder_path, const std::string& dataset_path){
642+
m_yolo_config_file_path = dataset_path;
643+
644+
export_image_annotations_to_yolo_dataset(image_folder_path, ML_ANNOTATION_PATH(), dataset_path);
645+
}
646+
647+
620648
}
621649
}
622650

SerialPrograms/Source/ML/Programs/ML_LabelImages.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ class LabelImages : public PanelInstance, public ConfigOption::Listener {
104104

105105
void load_custom_label_set(const std::string& json_path);
106106

107+
void export_to_yolov5_dataset(const std::string& image_folder_path, const std::string& dataset_path);
108+
107109
private:
108110
void on_config_value_changed(void* object) override;
109111

@@ -175,6 +177,9 @@ class LabelImages : public PanelInstance, public ConfigOption::Listener {
175177

176178
// the file path to load custom label set
177179
std::string m_custom_label_set_file_path;
180+
// the path to get the YOLOv5 YAML config file to export images and finished annotations to
181+
// YOLO dataset.
182+
std::string m_yolo_config_file_path;
178183
};
179184

180185

0 commit comments

Comments
 (0)