diff --git a/common/README.md b/common/README.md
index 5c1153e7b4b709bc41cc8c1f6641df7cc1e923a0..a4fbdcaf17019aa71d1731abf0bf7c8221e2579e 100644
--- a/common/README.md
+++ b/common/README.md
@@ -44,7 +44,20 @@ It will enable the relative imports.
 
 #### ROCO
 
-Telechargez le dossier [PolySTAR/RoboMaster/Équipe-Computer vision/DJI ROCO](https://drive.google.com/drive/folders/1AM3PqwwHzlK3tAS-1R5Qk3edPv0T4NzB)  du drive, contenant tous les datasets fournis par DJI, et unzippez le dans le dossier [dataset/dji_roco](../dataset/dji_roco).
+Download the directory [PolySTAR/RoboMaster/Équipe-Computer vision/DJI ROCO](https://drive.google.com/drive/folders/1AM3PqwwHzlK3tAS-1R5Qk3edPv0T4NzB) in Drive, with the 4 datasets given by DJI, and unzip in folder [dataset/dji_roco](../dataset/dji_roco).
+
+The dji_roco directory should look like:
+
+![DJI ROCO Dataset directory organization](./doc/dataset_dji_repo.png)
+
+#### TWITCH
+
+Download the directory [PolySTAR/RoboMaster/Équipe-Computer vision/datasets/twitch/v1](https://drive.google.com/drive/folders/1TaxdzB57U9wII9K2VDOEP8vUMm94_cR7) in Drive, with the 8 labelized videos from twitch, and unzip in folder [dataset/twitch/v1](../dataset/twitch/v1).
+
+The twitch/v1 directory should look like:
+
+![Twitch Dataset directory organization](./doc/dataset_twitch_repo.png)
+
 
 ## Dataset creation
 
diff --git a/common/doc/dataset_dji_repo.png b/common/doc/dataset_dji_repo.png
new file mode 100644
index 0000000000000000000000000000000000000000..b34b590d26696a0652221e5abeed5833cbdf5d14
Binary files /dev/null and b/common/doc/dataset_dji_repo.png differ
diff --git a/common/doc/dataset_twitch_repo.png b/common/doc/dataset_twitch_repo.png
new file mode 100644
index 0000000000000000000000000000000000000000..5d1bd5958d432f370a722753716fb851119752bf
Binary files /dev/null and b/common/doc/dataset_twitch_repo.png differ
diff --git a/common/poetry.lock b/common/poetry.lock
index 4e43268da15169a8c4d31bf39da2450e37807273..9e094771826283b4ca1bb9f359d03b272c60188a 100644
Binary files a/common/poetry.lock and b/common/poetry.lock differ
diff --git a/common/polystar/common/image_pipeline/models/model_abc.py b/common/polystar/common/image_pipeline/models/model_abc.py
index c4a4c38cc5fc0582176fc306f810598dc25c790b..00d480831b8521649d7bd27d6de18c1eac2161c8 100644
--- a/common/polystar/common/image_pipeline/models/model_abc.py
+++ b/common/polystar/common/image_pipeline/models/model_abc.py
@@ -3,11 +3,11 @@ from typing import Any, List, Sequence
 
 
 class ModelABC(ABC):
-    def fit(self, features: Any, labels: List[Any]) -> "ModelABC":
+    def fit(self, features: List[Any], labels: List[Any]) -> "ModelABC":
         return self
 
     @abstractmethod
-    def predict(self, features: Any) -> Sequence[Any]:
+    def predict(self, features: List[Any]) -> Sequence[Any]:
         pass
 
     @abstractmethod
diff --git a/common/polystar/common/image_pipeline/models/random_model.py b/common/polystar/common/image_pipeline/models/random_model.py
index 5c2f798070ad16c9a1ad7745edc13fb0204ad583..3c938d9ba4d1f41485e45c959c3b46ed8c104824 100644
--- a/common/polystar/common/image_pipeline/models/random_model.py
+++ b/common/polystar/common/image_pipeline/models/random_model.py
@@ -12,14 +12,14 @@ class RandomModel(ModelABC):
     labels_: np.ndarray = field(init=False, default=None)
     weights_: np.ndarray = field(init=False, default=None)
 
-    def fit(self, features: Any, labels: List[Any]) -> "RandomModel":
+    def fit(self, features: List[Any], labels: List[Any]) -> "RandomModel":
         counter = Counter(labels)
         self.labels_ = np.asarray(list(counter.keys()))
         occurrences = np.asarray(list(counter.values()))
         self.weights_ = occurrences / occurrences.sum()
         return self
 
-    def predict(self, features: Any) -> Sequence[Any]:
+    def predict(self, features: List[Any]) -> Sequence[Any]:
         return np.random.choice(self.labels_, size=len(features), replace=True, p=self.weights_)
 
     def __str__(self) -> str:
diff --git a/common/polystar/common/models/image.py b/common/polystar/common/models/image.py
index 1bf4fc61f065337992ef42e9f181f79729679b7c..33d63c22ab531203a2fa9b6439769640bb9729d7 100644
--- a/common/polystar/common/models/image.py
+++ b/common/polystar/common/models/image.py
@@ -8,3 +8,7 @@ class Image(Array[int, ..., ..., 3]):
     @staticmethod
     def from_path(image_path: Path) -> "Image":
         return cv2.cvtColor(cv2.imread(str(image_path)), cv2.COLOR_BGR2RGB)
+
+    def save(self, image_path: Path):
+        image_path.parent.mkdir(exist_ok=True, parents=True)
+        cv2.imwrite(str(image_path), cv2.cvtColor(self, cv2.COLOR_RGB2BGR))
diff --git a/common/polystar/common/models/object.py b/common/polystar/common/models/object.py
index aee53bcd44ab71cb0abfc2795a6739625bf268ee..6946967b9da3d0e5be4d1f7eaecf83cafdbccb8c 100644
--- a/common/polystar/common/models/object.py
+++ b/common/polystar/common/models/object.py
@@ -54,6 +54,8 @@ class ObjectFactory:
             int(float(json["bndbox"]["ymax"])) - int(float(json["bndbox"]["ymin"])),
         )
 
+        x, y = max(0, x), max(0, y)
+
         if t is not ObjectType.Armor:
             return Object(type=t, x=x, y=y, w=w, h=h)
 
diff --git a/common/research_common/dataset/twitch/twitch_roco_datasets.py b/common/research_common/dataset/twitch/twitch_roco_datasets.py
index a04bb75a3fac63e473d05d16463924f13a3f7f9e..368a7c48ee12d59cb1f91df26554f85e1f936a7d 100644
--- a/common/research_common/dataset/twitch/twitch_roco_datasets.py
+++ b/common/research_common/dataset/twitch/twitch_roco_datasets.py
@@ -1,15 +1,18 @@
 from enum import Enum
 
-from research_common.constants import TWITCH_DSET_ROBOTS_VIEWS_DIR
+from research_common.constants import TWITCH_DSET_DIR
 from research_common.dataset.directory_roco_dataset import DirectoryROCODataset
 
 
 class TwitchROCODataset(DirectoryROCODataset, Enum):
     def __init__(self, competition_name: str):
-        super().__init__(TWITCH_DSET_ROBOTS_VIEWS_DIR / competition_name, self.name)
+        super().__init__(TWITCH_DSET_DIR / "v1" / competition_name, self.name)
 
+    TWITCH_470149568 = "470149568"
     TWITCH_470150052 = "470150052"
     TWITCH_470151286 = "470151286"
+    TWITCH_470152289 = "470152289"
+    TWITCH_470152730 = "470152730"
     TWITCH_470152838 = "470152838"
     TWITCH_470153081 = "470153081"
-    TWITCH_470152289 = "470152289"
+    TWITCH_470158483 = "470158483"
diff --git a/common/research_common/image_pipeline_evaluation/image_dataset_generator.py b/common/research_common/image_pipeline_evaluation/image_dataset_generator.py
index b65ce92537669be951a60a66fdeb763e7b24102d..c2848779ea75666d70dde2931dffa9ed096dce8b 100644
--- a/common/research_common/image_pipeline_evaluation/image_dataset_generator.py
+++ b/common/research_common/image_pipeline_evaluation/image_dataset_generator.py
@@ -1,13 +1,27 @@
 from abc import abstractmethod
-from typing import TypeVar, Generic, Tuple, List
+from pathlib import Path
+from typing import TypeVar, Generic, Tuple, List, Iterable
 
 from polystar.common.models.image import Image
-from research_common.dataset.roco_dataset import ROCODataset
+from research_common.dataset.directory_roco_dataset import DirectoryROCODataset
 
 T = TypeVar("T")
 
 
 class ImageDatasetGenerator(Generic[T]):
+    def from_roco_datasets(
+        self, datasets: Iterable[DirectoryROCODataset]
+    ) -> Tuple[List[Path], List[Image], List[T], List[int]]:
+        images_path, images, labels, dataset_sizes = [], [], [], []
+        for dataset in datasets:
+            prev_total_size = len(images)
+            for img_path, label in self.from_roco_dataset(dataset):
+                images_path.append(img_path)
+                images.append(Image.from_path(img_path))
+                labels.append(label)
+            dataset_sizes.append(len(images) - prev_total_size)
+        return images_path, images, labels, dataset_sizes
+
     @abstractmethod
-    def from_roco_dataset(self, dataset: ROCODataset) -> Tuple[List[Image], List[T]]:
+    def from_roco_dataset(self, dataset: DirectoryROCODataset) -> Iterable[Tuple[Path, T]]:
         pass
diff --git a/common/research_common/image_pipeline_evaluation/image_pipeline_evaluation_reporter.py b/common/research_common/image_pipeline_evaluation/image_pipeline_evaluation_reporter.py
index 5a43e92394ef0adc5419777208df1ecbdf63b277..336a970cca3d768371bdaf3de3d588acc1b2e3f7 100644
--- a/common/research_common/image_pipeline_evaluation/image_pipeline_evaluation_reporter.py
+++ b/common/research_common/image_pipeline_evaluation/image_pipeline_evaluation_reporter.py
@@ -1,18 +1,22 @@
 from collections import Counter
 from dataclasses import dataclass
+from os.path import relpath
+from pathlib import Path
 from typing import Iterable, List, Any, Dict, Tuple
 
+import numpy as np
 from pandas import DataFrame
 
 from polystar.common.image_pipeline.image_pipeline import ImagePipeline
 from polystar.common.utils.dataframe import format_df_rows, format_df_row, format_df_column
 from polystar.common.utils.markdown import MarkdownFile
 from polystar.common.utils.time import create_time_id
-from research_common.constants import EVALUATION_DIR
+from research_common.constants import EVALUATION_DIR, DSET_DIR
 from research_common.dataset.roco_dataset import ROCODataset
 from research_common.image_pipeline_evaluation.image_pipeline_evaluator import (
     ImagePipelineEvaluator,
     ClassificationResults,
+    SetClassificationResults,
 )
 
 
@@ -41,18 +45,30 @@ class ImagePipelineEvaluationReporter:
         mf.title("Datasets", level=2)
 
         mf.title("Training", level=3)
-        self._report_dataset(mf, self.evaluator.train_roco_dataset, self.evaluator.train_labels)
+        self._report_dataset(
+            mf, self.evaluator.train_roco_datasets, self.evaluator.train_dataset_sizes, self.evaluator.train_labels
+        )
 
         mf.title("Testing", level=3)
-        self._report_dataset(mf, self.evaluator.test_roco_dataset, self.evaluator.test_labels)
+        self._report_dataset(
+            mf, self.evaluator.test_roco_datasets, self.evaluator.test_dataset_sizes, self.evaluator.test_labels
+        )
 
     @staticmethod
-    def _report_dataset(mf: MarkdownFile, roco_dataset: ROCODataset, labels: List[Any]):
-        mf.paragraph(roco_dataset.dataset_name)
-        label2count = Counter(labels)
+    def _report_dataset(
+        mf: MarkdownFile, roco_datasets: List[ROCODataset], dataset_sizes: List[int], labels: List[Any]
+    ):
         total = len(labels)
         mf.paragraph(f"{total} images")
-        mf.list(f"{label}: {count} ({count / total:.1%})" for label, count in sorted(label2count.items()))
+        df = DataFrame(
+            {
+                dataset.dataset_name: Counter(labels[start:end])
+                for dataset, start, end in zip(roco_datasets, np.cumsum([0] + dataset_sizes), np.cumsum(dataset_sizes))
+            }
+        ).fillna(0)
+        df["Total"] = sum([df[d.dataset_name] for d in roco_datasets])
+        df["Repartition"] = (df["Total"] / total).map("{:.1%}".format)
+        mf.table(df)
 
     def _report_aggregated_results(self, mf: MarkdownFile, pipeline2results: Dict[str, ClassificationResults]):
         aggregated_results = self._aggregate_results(pipeline2results)
@@ -64,28 +80,47 @@ class ImagePipelineEvaluationReporter:
         for pipeline_name, results in pipeline2results.items():
             self._report_pipeline_results(mf, pipeline_name, results)
 
-    @staticmethod
-    def _report_pipeline_results(mf: MarkdownFile, pipeline_name: str, results: ClassificationResults):
+    def _report_pipeline_results(self, mf: MarkdownFile, pipeline_name: str, results: ClassificationResults):
         mf.title(pipeline_name, level=2)
 
         mf.paragraph(results.full_pipeline_name)
 
         mf.title("Train results", level=3)
-
-        mf.paragraph(f"Inference time: {results.train_mean_inference_time: .2e} s/img")
-        ImagePipelineEvaluationReporter._report_pipeline_set_report(mf, results.train_report)
+        ImagePipelineEvaluationReporter._report_pipeline_set_results(
+            mf, results.train_results, self.evaluator.train_images_paths
+        )
 
         mf.title("Test results", level=3)
-
-        mf.paragraph(f"Inference time: {results.test_mean_inference_time: .2e} s/img")
-        ImagePipelineEvaluationReporter._report_pipeline_set_report(mf, results.test_report)
+        ImagePipelineEvaluationReporter._report_pipeline_set_results(
+            mf, results.test_results, self.evaluator.test_images_paths
+        )
 
     @staticmethod
-    def _report_pipeline_set_report(mf: MarkdownFile, set_report: Dict):
-        df = DataFrame(set_report)
+    def _report_pipeline_set_results(mf: MarkdownFile, results: SetClassificationResults, image_paths: List[Path]):
+        mf.title("Metrics", level=4)
+        mf.paragraph(f"Inference time: {results.mean_inference_time: .2e} s/img")
+        df = DataFrame(results.report)
         format_df_rows(df, ["precision", "recall", "f1-score"], "{:.1%}")
         format_df_row(df, "support", int)
         mf.table(df)
+        mf.title("Confusion Matrix:", level=4)
+        mf.table(DataFrame(results.confusion_matrix))
+        mf.title("10 Mistakes examples", level=4)
+        mistakes_idx = np.random.choice(results.mistakes, min(len(results.mistakes), 10), replace=False)
+        relative_paths = [
+            f"![img]({relpath(str(image_paths[idx]), str(mf.markdown_path.parent))})" for idx in mistakes_idx
+        ]
+        images_names = [image_paths[idx].relative_to(DSET_DIR) for idx in mistakes_idx]
+        mf.table(
+            DataFrame(
+                {
+                    "images": relative_paths,
+                    "labels": results.labels[mistakes_idx],
+                    "predictions": results.predictions[mistakes_idx],
+                    "image names": images_names,
+                }
+            ).set_index("images")
+        )
 
     def _aggregate_results(self, pipeline2results: Dict[str, ClassificationResults]) -> DataFrame:
         main_metric_name = f"{self.main_metric[0]} {self.main_metric[1]}"
@@ -93,8 +128,8 @@ class ImagePipelineEvaluationReporter:
 
         for pipeline_name, results in pipeline2results.items():
             df.loc[pipeline_name] = [
-                results.test_report[self.main_metric[1]][self.main_metric[0]],
-                results.test_mean_inference_time,
+                results.test_results.report[self.main_metric[1]][self.main_metric[0]],
+                results.test_results.mean_inference_time,
             ]
 
         df = df.sort_values(main_metric_name, ascending=False)
diff --git a/common/research_common/image_pipeline_evaluation/image_pipeline_evaluator.py b/common/research_common/image_pipeline_evaluation/image_pipeline_evaluator.py
index 3f98a72112e3a35d4b5a61545a96bcc45183bf80..24fdfa63bfc5f21bde2a16cfcd9963405ee0f2e1 100644
--- a/common/research_common/image_pipeline_evaluation/image_pipeline_evaluator.py
+++ b/common/research_common/image_pipeline_evaluation/image_pipeline_evaluator.py
@@ -1,37 +1,65 @@
 import logging
 from dataclasses import dataclass
 from time import time
-from typing import List, Tuple, Dict, Any, Iterable
+from typing import List, Dict, Any, Iterable, Sequence
 
-from sklearn.metrics import classification_report
+import numpy as np
+from sklearn.metrics import classification_report, confusion_matrix
 
 from polystar.common.image_pipeline.image_pipeline import ImagePipeline
 from polystar.common.models.image import Image
-from research_common.dataset.roco_dataset import ROCODataset
+from research_common.dataset.directory_roco_dataset import DirectoryROCODataset
 from research_common.image_pipeline_evaluation.image_dataset_generator import ImageDatasetGenerator
 
 
+@dataclass
+class SetClassificationResults:
+    labels: np.ndarray
+    predictions: np.ndarray
+    mean_inference_time: float
+
+    @property
+    def report(self) -> Dict:
+        return classification_report(self.labels, self.predictions, output_dict=True)
+
+    @property
+    def confusion_matrix(self) -> Dict:
+        return confusion_matrix(self.labels, self.predictions)
+
+    @property
+    def mistakes(self) -> Sequence[int]:
+        return np.where(self.labels != self.predictions)[0]
+
+
 @dataclass
 class ClassificationResults:
-    train_report: Dict
-    train_mean_inference_time: float
-    test_report: Dict
-    test_mean_inference_time: float
+    train_results: SetClassificationResults
+    test_results: SetClassificationResults
     full_pipeline_name: str
 
 
 class ImagePipelineEvaluator:
     def __init__(
         self,
-        train_roco_dataset: ROCODataset,
-        test_roco_dataset: ROCODataset,
+        train_roco_datasets: List[DirectoryROCODataset],
+        test_roco_datasets: List[DirectoryROCODataset],
         image_dataset_generator: ImageDatasetGenerator,
     ):
         logging.info("Loading data")
-        self.train_roco_dataset = train_roco_dataset
-        self.test_roco_dataset = test_roco_dataset
-        self.train_images, self.train_labels = image_dataset_generator.from_roco_dataset(train_roco_dataset)
-        self.test_images, self.test_labels = image_dataset_generator.from_roco_dataset(test_roco_dataset)
+        self.train_roco_datasets = train_roco_datasets
+        self.test_roco_datasets = test_roco_datasets
+        (
+            self.train_images_paths,
+            self.train_images,
+            self.train_labels,
+            self.train_dataset_sizes,
+        ) = image_dataset_generator.from_roco_datasets(train_roco_datasets)
+        (
+            self.test_images_paths,
+            self.test_images,
+            self.test_labels,
+            self.test_dataset_sizes,
+        ) = image_dataset_generator.from_roco_datasets(test_roco_datasets)
 
     def evaluate_pipelines(self, pipelines: Iterable[ImagePipeline]) -> Dict[str, ClassificationResults]:
         return {str(pipeline): self.evaluate(pipeline) for pipeline in pipelines}
@@ -41,20 +69,16 @@ class ImagePipelineEvaluator:
         pipeline.fit(self.train_images, self.train_labels)
 
         logging.info(f"Infering")
-        train_report, train_time = self._evaluate_on_set(pipeline, self.train_images, self.train_labels)
-        test_report, test_time = self._evaluate_on_set(pipeline, self.test_images, self.test_labels)
+        train_results = self._evaluate_on_set(pipeline, self.train_images, self.train_labels)
+        test_results = self._evaluate_on_set(pipeline, self.test_images, self.test_labels)
 
         return ClassificationResults(
-            train_report=train_report,
-            test_report=test_report,
-            train_mean_inference_time=train_time,
-            test_mean_inference_time=test_time,
-            full_pipeline_name=repr(pipeline),
+            train_results=train_results, test_results=test_results, full_pipeline_name=repr(pipeline),
         )
 
     @staticmethod
-    def _evaluate_on_set(pipeline: ImagePipeline, images: List[Image], labels: List[Any]) -> Tuple[Dict, float]:
+    def _evaluate_on_set(pipeline: ImagePipeline, images: List[Image], labels: List[Any]) -> SetClassificationResults:
         t = time()
         preds = pipeline.predict(images)
         mean_time = (time() - t) / len(images)
-        return classification_report(labels, preds, output_dict=True), mean_time
+        return SetClassificationResults(np.asarray(labels), np.asarray(preds), mean_time)
diff --git a/common/research_common/scripts/construct_dataset_from_manual_annotation.py b/common/research_common/scripts/construct_dataset_from_manual_annotation.py
index d3ab5188b70a695406761be7c49829f9e3741712..3178c9139ee0bba87e820e4859d74d3382e27306 100644
--- a/common/research_common/scripts/construct_dataset_from_manual_annotation.py
+++ b/common/research_common/scripts/construct_dataset_from_manual_annotation.py
@@ -3,8 +3,6 @@ from pathlib import Path
 from shutil import move, rmtree
 from zipfile import ZipFile
 
-from research_common.constants import TWITCH_DSET_DIR, TWITCH_ROBOTS_VIEWS_DIR, TWITCH_DSET_ROBOTS_VIEWS_DIR
-
 
 def construct_dataset_from_manual_annotations(
     source_images_directory: Path, source_annotations_directory: Path, destination_directory: Path
@@ -29,9 +27,3 @@ def _unzip_all_in_directory(source_directory: Path, destination_directory: Path,
     for directory in destination_directory.glob("*"):
         if directory.is_dir():
             rmtree(str(directory))
-
-
-if __name__ == "__main__":
-    construct_dataset_from_manual_annotations(
-        TWITCH_ROBOTS_VIEWS_DIR, TWITCH_DSET_DIR / "robots-views-annotations", TWITCH_DSET_ROBOTS_VIEWS_DIR,
-    )
diff --git a/common/research_common/scripts/construct_twith_datasets_from_manual_annotation.py b/common/research_common/scripts/construct_twith_datasets_from_manual_annotation.py
index 44f643434662f809a7f7d73d0b0ce18d5acd3f97..3989bba47072f03992f2b9d29b6a059c95423082 100644
--- a/common/research_common/scripts/construct_twith_datasets_from_manual_annotation.py
+++ b/common/research_common/scripts/construct_twith_datasets_from_manual_annotation.py
@@ -1,4 +1,5 @@
-from shutil import copy, move, rmtree
+from os import remove
+from shutil import copy, move, rmtree, make_archive
 
 from research_common.constants import TWITCH_DSET_DIR, TWITCH_ROBOTS_VIEWS_DIR, TWITCH_DSET_ROBOTS_VIEWS_DIR
 from research_common.dataset.directory_roco_dataset import DirectoryROCODataset
@@ -38,8 +39,9 @@ def _separate_twitch_videos():
         move(str(annotation.xml_path), str(annotations_path / annotation.xml_path.name))
     if list((TWITCH_DSET_ROBOTS_VIEWS_DIR / "image").glob("*")):
         raise Exception(f"Some images remains unmoved")
-    if list((TWITCH_DSET_ROBOTS_VIEWS_DIR / "image_annotation").glob("*")):
-        raise Exception(f"Some annotations remains unmoved")
+    for remaining_file in (TWITCH_DSET_ROBOTS_VIEWS_DIR / "image_annotation").glob("*"):
+        if remaining_file.name != ".DS_Store":
+            raise Exception(f"Some annotations remains unmoved")
     rmtree(str(TWITCH_DSET_ROBOTS_VIEWS_DIR / "image"))
     rmtree(str(TWITCH_DSET_ROBOTS_VIEWS_DIR / "image_annotation"))
 
@@ -61,6 +63,12 @@ def _make_separate_reports():
 
 
 if __name__ == "__main__":
+
+    for zip_file in (TWITCH_DSET_DIR / "reviewed-robots-views-annotations").glob("*.zip"):
+        remove(str(zip_file))
+    for chunk_dir in (TWITCH_DSET_DIR / "reviewed-robots-views-annotations").glob("chunk_*"):
+        make_archive(chunk_dir, "zip", chunk_dir)
+
     _construct_mixed_twitch_dset()
     _correct_manual_annotations()
     _extract_runes_images()
diff --git a/common/research_common/scripts/correct_annotations.py b/common/research_common/scripts/correct_annotations.py
index 2dfb1a8608a46a43e29c50040701143da1df1ea2..5b45d96fcc2167c732484a7cc4dd8808ff2af4a1 100644
--- a/common/research_common/scripts/correct_annotations.py
+++ b/common/research_common/scripts/correct_annotations.py
@@ -3,14 +3,25 @@ from pathlib import Path
 
 
 class AnnotationFileCorrector:
-    FINAL_ARMOR_NAME_PATTERN = re.compile(r"<name>(armor|amor)-(?P<color>\w{2,4})-(?P<num>\d)</name>", re.IGNORECASE)
-    ABV_ARMOR_NAME_PATTERN = re.compile(r"<name>a(?P<color>\w)(?P<num>\d)</name>", re.IGNORECASE)
+    FINAL_ARMOR_NAME_PATTERN = re.compile(
+        r"<name>(armor|amor|amror)-(?P<color>\w{2,4})-(?P<num>\d)</name>", re.IGNORECASE
+    )
+    ABV_ARMOR_NAME_PATTERN = re.compile(r"<name>a{1,2}(?P<color>\w)(?P<num>\d)</name>", re.IGNORECASE)
     ABV_RUNES_PATTERN = re.compile(r"<name>r(?P<color>\w)</name>", re.IGNORECASE)
     ABV_BASE_PATTERN = re.compile(r"<name>b</name>", re.IGNORECASE)
     ABV_WATCHER_PATTERN = re.compile(r"<name>w</name>", re.IGNORECASE)
-    ABV_CAR_PATTERN = re.compile(r"<name>[cx]</name>", re.IGNORECASE)
-
-    COLORS_MAP = {"r": "red", "red": "red", "b": "blue", "blue": "blue", "g": "grey", "grey": "grey", "gray": "grey"}
+    ABV_CAR_PATTERN = re.compile(r"<name>(c|x|robot)</name>", re.IGNORECASE)
+
+    COLORS_MAP = {
+        "r": "red",
+        "red": "red",
+        "b": "blue",
+        "bleu": "blue",
+        "blue": "blue",
+        "g": "grey",
+        "grey": "grey",
+        "gray": "grey",
+    }
 
     def __init__(self, save_before: bool):
         self.save_before = save_before
diff --git a/dataset/dji_roco/.gitignore b/dataset/dji_roco/.gitignore
index 837840cd27abb5677670c853f5c0b7bc2d2be0dd..fafbe0fec119e0d4a1e64b072d9a9b59bd033925 100644
--- a/dataset/dji_roco/.gitignore
+++ b/dataset/dji_roco/.gitignore
@@ -1,3 +1,5 @@
 **/*.xml
 **/*.jpg
 **/*.png
+**/colors
+**/digits
diff --git a/dataset/twitch/.gitignore b/dataset/twitch/.gitignore
index 6413c17d7139a7238fa5ef7c0878ab3ba8186f77..d1462f9c9f1d0a25d6d03aabeb9dd8b1b2b2391c 100644
--- a/dataset/twitch/.gitignore
+++ b/dataset/twitch/.gitignore
@@ -1,3 +1,4 @@
 /robots-views-annotations
 /reviewed-robots-views-annotations
-/final-robots-views
\ No newline at end of file
+/final-robots-views
+/aerial-annotations
\ No newline at end of file
diff --git a/dataset/twitch/aerial-annotations/.gitignore b/dataset/twitch/v1/.gitignore
similarity index 100%
rename from dataset/twitch/aerial-annotations/.gitignore
rename to dataset/twitch/v1/.gitignore
diff --git a/drone-at-base/poetry.lock b/drone-at-base/poetry.lock
index 5d5ab9b3fe409ee080089ae7f272c78a9f47c25b..e8d0e4d738aeaf5eaa4438030e80abcae0c47a0b 100644
Binary files a/drone-at-base/poetry.lock and b/drone-at-base/poetry.lock differ
diff --git a/robots-at-robots/poetry.lock b/robots-at-robots/poetry.lock
index 5d5ab9b3fe409ee080089ae7f272c78a9f47c25b..e8d0e4d738aeaf5eaa4438030e80abcae0c47a0b 100644
Binary files a/robots-at-robots/poetry.lock and b/robots-at-robots/poetry.lock differ
diff --git a/robots-at-robots/research/armor_color/armor_color_pipeline_reporter_factory.py b/robots-at-robots/research/armor_color/armor_color_pipeline_reporter_factory.py
index dac836df8a0332dc044c6815b6109048ebe0bda3..d6fa16ec204c720a71e977e95492457d2e9624d1 100644
--- a/robots-at-robots/research/armor_color/armor_color_pipeline_reporter_factory.py
+++ b/robots-at-robots/research/armor_color/armor_color_pipeline_reporter_factory.py
@@ -1,16 +1,20 @@
+from typing import List
+
 from research.dataset.armor_color_dataset_factory import ArmorColorDatasetGenerator
-from research_common.dataset.roco_dataset import ROCODataset
+from research_common.dataset.directory_roco_dataset import DirectoryROCODataset
 from research_common.image_pipeline_evaluation.image_pipeline_evaluation_reporter import ImagePipelineEvaluationReporter
 from research_common.image_pipeline_evaluation.image_pipeline_evaluator import ImagePipelineEvaluator
 
 
 class ArmorColorPipelineReporterFactory:
     @staticmethod
-    def from_roco_datasets(train_roco_dataset: ROCODataset, test_roco_dataset: ROCODataset):
+    def from_roco_datasets(
+        train_roco_datasets: List[DirectoryROCODataset], test_roco_datasets: List[DirectoryROCODataset]
+    ):
         return ImagePipelineEvaluationReporter(
             evaluator=ImagePipelineEvaluator(
-                train_roco_dataset=train_roco_dataset,
-                test_roco_dataset=test_roco_dataset,
+                train_roco_datasets=train_roco_datasets,
+                test_roco_datasets=test_roco_datasets,
                 image_dataset_generator=ArmorColorDatasetGenerator(),
             ),
             evaluation_project="armor-color",
diff --git a/robots-at-robots/research/armor_color/baseline_experiments.py b/robots-at-robots/research/armor_color/baseline_experiments.py
index d85243d92d69e710cb0610ef8076c27a32c4e3f9..87dfe7290c0aea85fb50125d6afb67543825b447 100644
--- a/robots-at-robots/research/armor_color/baseline_experiments.py
+++ b/robots-at-robots/research/armor_color/baseline_experiments.py
@@ -6,14 +6,13 @@ from polystar.common.image_pipeline.models.random_model import RandomModel
 from polystar.common.image_pipeline.models.red_blue_channels_comparison_model import RedBlueComparisonModel
 from research.armor_color.armor_color_pipeline_reporter_factory import ArmorColorPipelineReporterFactory
 from research_common.dataset.twitch.twitch_roco_datasets import TwitchROCODataset
-from research_common.dataset.union_dataset import UnionDataset
 
 if __name__ == "__main__":
     logging.getLogger().setLevel("INFO")
 
     reporter = ArmorColorPipelineReporterFactory.from_roco_datasets(
-        train_roco_dataset=UnionDataset(TwitchROCODataset.TWITCH_470151286, TwitchROCODataset.TWITCH_470150052),
-        test_roco_dataset=TwitchROCODataset.TWITCH_470152289,
+        train_roco_datasets=[TwitchROCODataset.TWITCH_470151286, TwitchROCODataset.TWITCH_470150052],
+        test_roco_datasets=[TwitchROCODataset.TWITCH_470152289],
     )
 
     red_blue_comparison_pipeline = ImagePipeline(
diff --git a/robots-at-robots/research/armor_digit/armor_digit_pipeline_reporter_factory.py b/robots-at-robots/research/armor_digit/armor_digit_pipeline_reporter_factory.py
index 7e8afa1d6955d7022d21d7b12db5b7c2c8aa5e4e..5546f27d39414036d1d7290564acb9a26c03f9b4 100644
--- a/robots-at-robots/research/armor_digit/armor_digit_pipeline_reporter_factory.py
+++ b/robots-at-robots/research/armor_digit/armor_digit_pipeline_reporter_factory.py
@@ -1,7 +1,7 @@
-from typing import Iterable
+from typing import Iterable, List
 
 from research.dataset.armor_digit_dataset_factory import ArmorDigitDatasetGenerator
-from research_common.dataset.roco_dataset import ROCODataset
+from research_common.dataset.directory_roco_dataset import DirectoryROCODataset
 from research_common.image_pipeline_evaluation.image_pipeline_evaluation_reporter import ImagePipelineEvaluationReporter
 from research_common.image_pipeline_evaluation.image_pipeline_evaluator import ImagePipelineEvaluator
 
@@ -9,14 +9,14 @@ from research_common.image_pipeline_evaluation.image_pipeline_evaluator import I
 class ArmorDigitPipelineReporterFactory:
     @staticmethod
     def from_roco_datasets(
-        train_roco_dataset: ROCODataset,
-        test_roco_dataset: ROCODataset,
+        train_roco_datasets: List[DirectoryROCODataset],
+        test_roco_datasets: List[DirectoryROCODataset],
         acceptable_digits: Iterable[int] = (1, 2, 3, 4, 5, 7),
     ):
         return ImagePipelineEvaluationReporter(
             evaluator=ImagePipelineEvaluator(
-                train_roco_dataset=train_roco_dataset,
-                test_roco_dataset=test_roco_dataset,
+                train_roco_datasets=train_roco_datasets,
+                test_roco_datasets=test_roco_datasets,
                 image_dataset_generator=ArmorDigitDatasetGenerator(set(acceptable_digits)),
             ),
             evaluation_project="armor-digit",
diff --git a/robots-at-robots/research/armor_digit/baseline_experiments.py b/robots-at-robots/research/armor_digit/baseline_experiments.py
index 0b7c979881f725679f8d5cfb7ce62f308b6897de..8b51e3991dc9d30f0e5043fcaa97bb16746e16d3 100644
--- a/robots-at-robots/research/armor_digit/baseline_experiments.py
+++ b/robots-at-robots/research/armor_digit/baseline_experiments.py
@@ -24,8 +24,8 @@ N_CLASSES = 9  # there is no 6s or 9s
 #     logging.getLogger().setLevel("INFO")
 #
 #     reporter = ArmorDigitPipelineReporterFactory.from_roco_datasets(
-#         train_roco_dataset=UnionDataset(TwitchROCODataset.TWITCH_470151286, TwitchROCODataset.TWITCH_470150052),
-#         test_roco_dataset=TwitchROCODataset.TWITCH_470152289,
+#         train_roco_datasets=[TwitchROCODataset.TWITCH_470151286, TwitchROCODataset.TWITCH_470150052],
+#         test_roco_datasets=[TwitchROCODataset.TWITCH_470152289],
 #     )
 #
 #     random_pipeline = ImagePipeline(model=RandomModel(), custom_name="random")
diff --git a/robots-at-robots/research/dataset/armor_color_dataset_factory.py b/robots-at-robots/research/dataset/armor_color_dataset_factory.py
index c32fe9820beb7df24861a6cb15de28e1b397dea8..c601fa168777f24dc93832706e6470836c7821b6 100644
--- a/robots-at-robots/research/dataset/armor_color_dataset_factory.py
+++ b/robots-at-robots/research/dataset/armor_color_dataset_factory.py
@@ -1,11 +1,11 @@
-from typing import Tuple, Sequence
+from pathlib import Path
 
-from polystar.common.models.image import Image
-from research.dataset.armor_dataset_factory import ArmorDatasetFactory
-from research_common.dataset.roco_dataset import ROCODataset
-from research_common.image_pipeline_evaluation.image_dataset_generator import ImageDatasetGenerator
+from polystar.common.models.object import Armor
+from research.dataset.armor_image_dataset_factory import ArmorImageDatasetGenerator
 
 
-class ArmorColorDatasetGenerator(ImageDatasetGenerator[str]):
-    def from_roco_dataset(self, dataset: ROCODataset) -> Tuple[Sequence[Image], Sequence[str]]:
-        return zip(*[(armor_img, c.name) for (armor_img, c, n, k, p) in ArmorDatasetFactory.from_dataset(dataset)])
+class ArmorColorDatasetGenerator(ArmorImageDatasetGenerator[str]):
+    task_name: str = "colors"
+
+    def _label_from_armor_info(self, armor: Armor, k: int, path: Path) -> str:
+        return armor.color.name
diff --git a/robots-at-robots/research/dataset/armor_dataset_factory.py b/robots-at-robots/research/dataset/armor_dataset_factory.py
index 75688971c9851a5dd321374a8491e1f69154576a..138de0d526bc80fa925aaaa7b01cefa07fb814e5 100644
--- a/robots-at-robots/research/dataset/armor_dataset_factory.py
+++ b/robots-at-robots/research/dataset/armor_dataset_factory.py
@@ -6,7 +6,7 @@ import matplotlib.pyplot as plt
 
 from polystar.common.models.image import Image
 from polystar.common.models.image_annotation import ImageAnnotation
-from polystar.common.models.object import ObjectType, Armor, ArmorColor, ArmorNumber
+from polystar.common.models.object import ObjectType, Armor
 from polystar.common.target_pipeline.objects_validators.type_object_validator import TypeObjectValidator
 from research_common.dataset.dji.dji_roco_datasets import DJIROCODataset
 from research_common.dataset.roco_dataset import ROCODataset
@@ -14,36 +14,38 @@ from research_common.dataset.roco_dataset import ROCODataset
 
 class ArmorDatasetFactory:
     @staticmethod
-    def from_image_annotation(
-        image_annotation: ImageAnnotation,
-    ) -> Iterable[Tuple[Image, ArmorColor, ArmorNumber, int, Path]]:
+    def from_image_annotation(image_annotation: ImageAnnotation) -> Iterable[Tuple[Image, Armor, int, Path]]:
         img = image_annotation.image
         armors: List[Armor] = TypeObjectValidator(ObjectType.Armor).filter(image_annotation.objects, img)
         for i, obj in enumerate(armors):
             croped_img = img[obj.y: obj.y + obj.h, obj.x: obj.x + obj.w]
-            yield croped_img, obj.color, obj.numero, i, image_annotation.image_path
+            yield croped_img, obj, i, image_annotation.image_path
 
     @staticmethod
-    def from_dataset(dataset: ROCODataset) -> Iterable[Tuple[Image, ArmorColor, ArmorNumber, int, Path]]:
+    def from_dataset(dataset: ROCODataset) -> Iterable[Tuple[Image, Armor, int, Path]]:
         for image_annotation in dataset.image_annotations:
             for rv in ArmorDatasetFactory.from_image_annotation(image_annotation):
                 yield rv
 
 
 def save_digits_img():
-    for j, (digit_img, c, n, k, p) in enumerate(ArmorDatasetFactory.from_dataset(DJIROCODataset.Final)):
+    for j, (digit_img, armor, k, p) in enumerate(ArmorDatasetFactory.from_dataset(DJIROCODataset.Final)):
         # we ignore the 6s, because the format of their pictures is not accurate and we don't need them
-        if n != 6 and j >= 5925:  # TODO change j value depending where you are in saving (to save time)
+        if armor.numero != 6 and j >= 5925:  # TODO change j value depending where you are in saving (to save time)
             # if j >= 50:
             #     break
+            # TODO avoid getting image with chinese symbols in their path
             try:
-                print(c, n, k, 'in', p)
+                print(armor.color, armor.numero, k, 'in', p)
                 plt.imshow(digit_img)
-                plt.savefig(str(p.parents[1]) + '\\digits_found\\digit' + str(j) + '_' + c.name + '_' + str(n) + '.png')
+                plt.savefig(str(p.parents[1]) + '\\digits_found\\digit' + str(j) + '_' + armor.color.name +
+                            '_' + str(armor.numero) + '.png')
                 plt.clf()
             except ValueError:
                 # because some digit's pictures have zero-array values
                 continue
+        if j >= 5940:
+            break
 
 
 if __name__ == "__main__":
diff --git a/robots-at-robots/research/dataset/armor_digit_dataset_factory.py b/robots-at-robots/research/dataset/armor_digit_dataset_factory.py
index d7f0b938b623b56959cf8c43777e7954973d2377..a38d4a14f9c48447515ff501353c051c117fabc5 100644
--- a/robots-at-robots/research/dataset/armor_digit_dataset_factory.py
+++ b/robots-at-robots/research/dataset/armor_digit_dataset_factory.py
@@ -1,20 +1,21 @@
-from typing import Tuple, Sequence, Set
+from pathlib import Path
+from typing import Set
 
-from polystar.common.models.image import Image
-from research.dataset.armor_dataset_factory import ArmorDatasetFactory
-from research_common.dataset.roco_dataset import ROCODataset
-from research_common.image_pipeline_evaluation.image_dataset_generator import ImageDatasetGenerator
+from polystar.common.models.object import Armor
+from research.dataset.armor_image_dataset_factory import ArmorImageDatasetGenerator
 
 
-class ArmorDigitDatasetGenerator(ImageDatasetGenerator[str]):
+class ArmorDigitDatasetGenerator(ArmorImageDatasetGenerator[int]):
+    task_name: str = "digits"
+
     def __init__(self, acceptable_digits: Set[int]):
         self.acceptable_digits = acceptable_digits
 
-    def from_roco_dataset(self, dataset: ROCODataset) -> Tuple[Sequence[Image], Sequence[int]]:
-        return zip(
-            *[
-                (armor_img, digit)
-                for (armor_img, c, digit, k, p) in ArmorDatasetFactory.from_dataset(dataset)
-                if digit in self.acceptable_digits
-            ]
-        )
+    def _label_from_str(self, label: str) -> int:
+        return int(label)
+
+    def _label_from_armor_info(self, armor: Armor, k: int, path: Path) -> int:
+        return armor.numero
+
+    def _valid_label(self, label: int) -> bool:
+        return label in self.acceptable_digits
diff --git a/robots-at-robots/research/dataset/armor_image_dataset_factory.py b/robots-at-robots/research/dataset/armor_image_dataset_factory.py
new file mode 100644
index 0000000000000000000000000000000000000000..dfdf35b8ec6ea55e0cffc1c6f9458a69e7190109
--- /dev/null
+++ b/robots-at-robots/research/dataset/armor_image_dataset_factory.py
@@ -0,0 +1,54 @@
+import json
+from abc import abstractmethod
+from pathlib import Path
+from typing import TypeVar, Tuple, Iterable
+
+import cv2
+
+from polystar.common.models.image import Image
+from polystar.common.models.object import Armor
+from polystar.common.utils.time import create_time_id
+from research.dataset.armor_dataset_factory import ArmorDatasetFactory
+from research_common.dataset.directory_roco_dataset import DirectoryROCODataset
+from research_common.image_pipeline_evaluation.image_dataset_generator import ImageDatasetGenerator
+
+T = TypeVar("T")
+
+
+class ArmorImageDatasetGenerator(ImageDatasetGenerator[T]):
+    task_name: str
+
+    def from_roco_dataset(self, dataset: DirectoryROCODataset) -> Iterable[Tuple[Image, T]]:
+        if not (dataset.dataset_path / self.task_name / ".lock").exists():
+            self._create_labelized_armor_images_from_roco(dataset)
+        return self._get_images_paths_and_labels(dataset)
+
+    def _create_labelized_armor_images_from_roco(self, dataset):
+        dset_path = dataset.dataset_path / self.task_name
+        dset_path.mkdir(exist_ok=True)
+        for (armor_img, armor, k, path) in ArmorDatasetFactory.from_dataset(dataset):
+            label = self._label_from_armor_info(armor, k, path)
+            cv2.imwrite(str(dset_path / f"{path.stem}-{k}-{label}.jpg"), cv2.cvtColor(armor_img, cv2.COLOR_RGB2BGR))
+        (dataset.dataset_path / self.task_name / ".lock").write_text(
+            json.dumps({"version": "0.0", "date": create_time_id()})
+        )
+
+    def _get_images_paths_and_labels(self, dataset: DirectoryROCODataset) -> Iterable[Tuple[Image, T]]:
+        return (
+            (image_path, self._label_from_filepath(image_path))
+            for image_path in (dataset.dataset_path / self.task_name).glob("*.jpg")
+            if self._valid_label(self._label_from_filepath(image_path))
+        )
+
+    def _label_from_filepath(self, image_path: Path) -> T:
+        return self._label_from_str(image_path.stem.split("-")[-1])
+
+    @abstractmethod
+    def _label_from_armor_info(self, armor: Armor, k: int, path: Path) -> T:
+        pass
+
+    def _valid_label(self, label: T) -> bool:
+        return True
+
+    def _label_from_str(self, label: str) -> T:
+        return label
diff --git a/robots-at-runes/poetry.lock b/robots-at-runes/poetry.lock
index 5d5ab9b3fe409ee080089ae7f272c78a9f47c25b..8c2d89db08a86cb509e2a2a9ecb4c6a68f847449 100644
Binary files a/robots-at-runes/poetry.lock and b/robots-at-runes/poetry.lock differ
diff --git a/robots-at-runes/pyproject.toml b/robots-at-runes/pyproject.toml
index 27e06de40cb50160e76e6991e4a4416e204bc476..3e9905b88674270ea3dbcb63f1ea3b121aa87fb8 100644
--- a/robots-at-runes/pyproject.toml
+++ b/robots-at-runes/pyproject.toml
@@ -8,6 +8,7 @@ packages = [{ include = "polystar" }]
 [tool.poetry.dependencies]
 python = "^3.7"
 "polystar.common" = {path = "../common"}
+imutils = "^0.5.3"
 
 [tool.poetry.dev-dependencies]
 pytest = "^4.5"
diff --git a/robots-at-runes/research/constants.py b/robots-at-runes/research/dataset/__init__.py
similarity index 100%
rename from robots-at-runes/research/constants.py
rename to robots-at-runes/research/dataset/__init__.py
diff --git a/robots-at-runes/research/dataset/blend/__init__.py b/robots-at-runes/research/dataset/blend/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/robots-at-runes/research/dataset/blend/examples/.gitignore b/robots-at-runes/research/dataset/blend/examples/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..a13af165695e32f57c6c31c7a7e2c9f33ddf3881
--- /dev/null
+++ b/robots-at-runes/research/dataset/blend/examples/.gitignore
@@ -0,0 +1,2 @@
+/image
+/image_annotation
\ No newline at end of file
diff --git a/robots-at-runes/research/dataset/blend/examples/back1.jpg b/robots-at-runes/research/dataset/blend/examples/back1.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..59b770b853a95074c58a2c7d854d69efc787d4a5
Binary files /dev/null and b/robots-at-runes/research/dataset/blend/examples/back1.jpg differ
diff --git a/robots-at-runes/research/dataset/blend/examples/logo.png b/robots-at-runes/research/dataset/blend/examples/logo.png
new file mode 100644
index 0000000000000000000000000000000000000000..81182558975026552d3da02c9da45e0d65abe6b9
Binary files /dev/null and b/robots-at-runes/research/dataset/blend/examples/logo.png differ
diff --git a/robots-at-runes/research/dataset/blend/examples/logo.xml b/robots-at-runes/research/dataset/blend/examples/logo.xml
new file mode 100644
index 0000000000000000000000000000000000000000..b3d47c380c48ee78c5a264568ed62ee8271f5ec7
--- /dev/null
+++ b/robots-at-runes/research/dataset/blend/examples/logo.xml
@@ -0,0 +1,12 @@
+<annotation>
+	<point>
+		<x>432</x>
+		<y>76</y>
+		<label>green</label>
+	</point>
+	<point>
+		<x>432</x>
+		<y>13</y>
+		<label>blue</label>
+	</point>
+</annotation>
diff --git a/robots-at-runes/research/dataset/blend/image_blender.py b/robots-at-runes/research/dataset/blend/image_blender.py
new file mode 100644
index 0000000000000000000000000000000000000000..c19202e3d159e3298ede9df9695cf388a5f20c7f
--- /dev/null
+++ b/robots-at-runes/research/dataset/blend/image_blender.py
@@ -0,0 +1,96 @@
+from dataclasses import dataclass
+from random import random
+from typing import List, Tuple
+
+import cv2
+import numpy as np
+
+from polystar.common.models.image import Image
+from research.dataset.blend.labeled_image_modifiers.labeled_image_modifier_abc import LabeledImageModifierABC
+from research.dataset.labeled_image import LabeledImage, PointOfInterest
+
+
+@dataclass
+class ImageBlender:
+    background_size: Tuple[int, int]
+    object_modifiers: List[LabeledImageModifierABC]
+
+    def blend(self, background: Image, obj: LabeledImage) -> LabeledImage:
+        obj = self._modify_object(obj)
+        background = self._crop_background(background)
+        x, y = self._generate_position_of_object(background.shape, obj.image.shape)
+        return LabeledImage(
+            image=self._blend_obj_on_background(background, obj.image, x, y),
+            point_of_interests=[self._translate_poi(poi, x, y) for poi in obj.point_of_interests],
+        )
+
+    def _modify_object(self, obj: LabeledImage) -> LabeledImage:
+        for modifier in self.object_modifiers:
+            obj = modifier.randomly_modify(obj)
+        return obj
+
+    def _generate_position_of_object(
+        self, background_shape: Tuple[int, int, int], obj_shape: Tuple[int, int, int]
+    ) -> Tuple[int, int]:
+        return (
+            self._generate_position_of_object_amoung_axis(background_shape[1], obj_shape[1]),
+            self._generate_position_of_object_amoung_axis(background_shape[0], obj_shape[0]),
+        )
+
+    @staticmethod
+    def _generate_position_of_object_amoung_axis(background_size: int, obj_size: int) -> int:
+        return int(random() * (background_size - obj_size))
+
+    def _blend_obj_on_background(self, background: Image, obj_img: Image, x: int, y: int) -> Image:
+        background_roi = background[y : y + obj_img.shape[0], x : x + obj_img.shape[1], :]
+        mask = obj_img[:, :, 3]
+        obj_img = cv2.cvtColor(obj_img, cv2.COLOR_RGBA2RGB)
+        background_roi = background_roi.astype(np.float)
+        obj_img = obj_img.astype(np.float)
+        rv = background.copy()
+        for i in range(3):
+            rv[y : y + obj_img.shape[0], x : x + obj_img.shape[1], i] = (
+                (~mask * background_roi[:, :, i] + mask * obj_img[:, :, i]) / 255
+            ).astype(np.uint8)
+        return rv
+
+    @staticmethod
+    def _translate_poi(poi: PointOfInterest, x: int, y: int) -> PointOfInterest:
+        return PointOfInterest(poi.x + x, poi.y + y, poi.label)
+
+    def _crop_background(self, background: Image) -> Image:
+        h, w, _ = background.shape
+        x, y = int(random() * (h - self.background_size[1])), int(random() * (w - self.background_size[0]))
+        return background[y : y + self.background_size[1], x : x + self.background_size[0], :]
+
+
+if __name__ == "__main__":
+    from pathlib import Path
+
+    import matplotlib.pyplot as plt
+
+    from research.dataset.blend.labeled_image_modifiers.labeled_image_rotator import LabeledImageRotator
+    from research.dataset.blend.labeled_image_modifiers.labeled_image_scaler import LabeledImageScaler
+
+    EXAMPLES_DIR = Path(__file__).parent / "examples"
+
+    _obj = LabeledImage(
+        cv2.cvtColor(cv2.imread(str(EXAMPLES_DIR / "logo.png"), cv2.IMREAD_UNCHANGED), cv2.COLOR_BGRA2RGBA),
+        PointOfInterest.from_annotation_file(EXAMPLES_DIR / "logo.xml"),
+    )
+    _bg = cv2.cvtColor(cv2.imread(str(EXAMPLES_DIR / "back1.jpg")), cv2.COLOR_BGR2RGB)
+
+    _blender = ImageBlender(
+        background_size=(1_280, 720), object_modifiers=[LabeledImageScaler(1.5), LabeledImageRotator(180)]
+    )
+    for i in range(10):
+        res = _blender.blend(_bg, _obj)
+
+        res.save(EXAMPLES_DIR, f"test_{i}")
+
+        plt.imshow(res.image)
+        for poi in res.point_of_interests:
+            plt.plot([poi.x], [poi.y], f"{poi.label[0]}.")
+        plt.axis("off")
+        plt.tight_layout()
+        plt.show()
diff --git a/robots-at-runes/research/dataset/blend/labeled_image_modifiers/__init__.py b/robots-at-runes/research/dataset/blend/labeled_image_modifiers/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/robots-at-runes/research/dataset/blend/labeled_image_modifiers/labeled_image_modifier_abc.py b/robots-at-runes/research/dataset/blend/labeled_image_modifiers/labeled_image_modifier_abc.py
new file mode 100644
index 0000000000000000000000000000000000000000..e3ab2e7f4c7bd4bec333f24a3267e48b02b639c6
--- /dev/null
+++ b/robots-at-runes/research/dataset/blend/labeled_image_modifiers/labeled_image_modifier_abc.py
@@ -0,0 +1,40 @@
+from abc import ABC, abstractmethod
+from random import random
+
+from polystar.common.models.image import Image
+from research.dataset.labeled_image import LabeledImage, PointOfInterest
+
+
+class LabeledImageModifierABC(ABC):
+    def randomly_modify(self, labeled_image: LabeledImage) -> LabeledImage:
+        return self.modify_from_factor(labeled_image, random() * 2 - 1)
+
+    def modify_from_factor(self, labeled_image: LabeledImage, factor: float) -> LabeledImage:
+        return self.modify_from_value(labeled_image, self._get_value_from_factor(factor))
+
+    def modify_from_value(self, labeled_image: LabeledImage, value: float) -> LabeledImage:
+        new_image = self._generate_modified_image(labeled_image.image, value)
+        return LabeledImage(
+            image=new_image,
+            point_of_interests=[
+                self._generate_modified_poi(poi, labeled_image.image, new_image, value)
+                for poi in labeled_image.point_of_interests
+            ],
+        )
+
+    @abstractmethod
+    def _get_value_from_factor(self, factor: float) -> float:
+        """
+        :param factor: a factor of modification, in range [-1, 1]
+        :return: the value of modification used by other function of this class
+        """
+
+    @abstractmethod
+    def _generate_modified_image(self, image: Image, value: float) -> Image:
+        pass
+
+    @abstractmethod
+    def _generate_modified_poi(
+        self, poi: PointOfInterest, original_image: Image, new_image: Image, value: float
+    ) -> PointOfInterest:
+        pass
diff --git a/robots-at-runes/research/dataset/blend/labeled_image_modifiers/labeled_image_rotator.py b/robots-at-runes/research/dataset/blend/labeled_image_modifiers/labeled_image_rotator.py
new file mode 100644
index 0000000000000000000000000000000000000000..860972cbf0cc10519f982ef1a799c342bfc43361
--- /dev/null
+++ b/robots-at-runes/research/dataset/blend/labeled_image_modifiers/labeled_image_rotator.py
@@ -0,0 +1,34 @@
+from dataclasses import dataclass
+
+import cv2
+import numpy as np
+from imutils import rotate_bound
+
+from polystar.common.models.image import Image
+from research.dataset.blend.labeled_image_modifiers.labeled_image_modifier_abc import LabeledImageModifierABC
+from research.dataset.labeled_image import PointOfInterest
+
+
+@dataclass
+class LabeledImageRotator(LabeledImageModifierABC):
+    max_angle_degrees: float
+
+    def _generate_modified_image(self, image: Image, angle: float) -> Image:
+        return rotate_bound(image, angle)
+
+    def _generate_modified_poi(
+        self, poi: PointOfInterest, original_image: Image, new_image: Image, angle_degrees: float
+    ) -> PointOfInterest:
+        angle_rads = np.deg2rad(angle_degrees)
+        sin, cos = np.sin(angle_rads), np.cos(angle_rads)
+        rotation_matrix = np.array([[cos, -sin], [sin, cos]])
+        prev_vector_to_center = np.array((poi.x - original_image.shape[1] / 2, poi.y - original_image.shape[0] / 2))
+        new_vector_to_center = np.dot(rotation_matrix, prev_vector_to_center)
+        return PointOfInterest(
+            int(new_vector_to_center[0] + new_image.shape[1] / 2),
+            int(new_vector_to_center[1] + new_image.shape[0] / 2),
+            poi.label,
+        )
+
+    def _get_value_from_factor(self, factor: float) -> float:
+        return self.max_angle_degrees * factor
diff --git a/robots-at-runes/research/dataset/blend/labeled_image_modifiers/labeled_image_scaler.py b/robots-at-runes/research/dataset/blend/labeled_image_modifiers/labeled_image_scaler.py
new file mode 100644
index 0000000000000000000000000000000000000000..b98e25f4fa1a32e4197984864fe887dd7690b024
--- /dev/null
+++ b/robots-at-runes/research/dataset/blend/labeled_image_modifiers/labeled_image_scaler.py
@@ -0,0 +1,28 @@
+from dataclasses import dataclass
+
+import cv2
+
+from polystar.common.models.image import Image
+from research.dataset.blend.labeled_image_modifiers.labeled_image_modifier_abc import LabeledImageModifierABC
+from research.dataset.labeled_image import LabeledImage, PointOfInterest
+
+
+@dataclass
+class LabeledImageScaler(LabeledImageModifierABC):
+    max_scale: float
+
+    def _generate_modified_image(self, image: Image, scale: float) -> Image:
+        return cv2.resize(
+            image, (int(image.shape[1] * scale), int(image.shape[0] * scale)), interpolation=cv2.INTER_AREA
+        )
+
+    def _generate_modified_poi(
+        self, poi: PointOfInterest, original_image: Image, new_image: Image, scale: float
+    ) -> PointOfInterest:
+        return PointOfInterest(int(poi.x * scale), int(poi.y * scale), poi.label)
+
+    def _get_value_from_factor(self, factor: float) -> float:
+        intensity = (self.max_scale - 1) * abs(factor) + 1
+        if factor > 0:
+            return intensity
+        return 1 / intensity
diff --git a/robots-at-runes/research/dataset/labeled_image.py b/robots-at-runes/research/dataset/labeled_image.py
new file mode 100644
index 0000000000000000000000000000000000000000..30d0dac6880f554786789cff8364ccac0d93a663
--- /dev/null
+++ b/robots-at-runes/research/dataset/labeled_image.py
@@ -0,0 +1,61 @@
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import List, Dict, Any
+from xml.dom.minidom import parseString
+
+import xmltodict
+from dicttoxml import dicttoxml
+
+from polystar.common.models.image import Image
+
+
+@dataclass
+class PointOfInterest:
+    x: int
+    y: int
+    label: str
+
+    def to_dict(self) -> Dict[str, int]:
+        return self.__dict__
+
+    @classmethod
+    def from_dict(cls, d: Dict[str, Any]):
+        return cls(x=int(d["x"]), y=int(d["y"]), label=d["label"])
+
+    @classmethod
+    def from_annotation_file(cls, annotation_path: Path) -> List["PointOfInterest"]:
+        points = xmltodict.parse(annotation_path.read_text())["annotation"]["point"]
+        return [PointOfInterest.from_dict(p) for p in points]
+
+
+@dataclass
+class LabeledImage:
+    image: Image
+    point_of_interests: List[PointOfInterest] = field(default_factory=list)
+
+    def save(self, directory_path: Path, name: str):
+        Image.save(self.image, directory_path / "image" / f"{name}.jpg")
+        self._save_annotation(directory_path / "image_annotation" / f"{name}.xml")
+
+    def _save_annotation(self, annotation_path: Path):
+        annotation_path.parent.mkdir(exist_ok=True, parents=True)
+        xml = parseString(
+            dicttoxml(
+                {"annotation": {"point": [p.to_dict() for p in self.point_of_interests]}},
+                attr_type=False,
+                root="annotation",
+                item_func=lambda x: x,
+            )
+            .replace(b"<point><point>", b"<point>")
+            .replace(b"</point></point>", b"</point>")
+        ).toprettyxml()
+        annotation_path.write_text(xml)
+
+    @staticmethod
+    def from_directory(directory_path: Path, name: str) -> "LabeledImage":
+        return LabeledImage(
+            image=Image.from_path(directory_path / "image" / f"{name}.jpg"),
+            point_of_interests=PointOfInterest.from_annotation_file(
+                directory_path / "image_annotation" / f"{name}.xml"
+            ),
+        )