diff --git a/dataset/dji_roco/robomaster_Central China Regional Competition/digits/.changes b/dataset/dji_roco/robomaster_Central China Regional Competition/digits/.changes
new file mode 100644
index 0000000000000000000000000000000000000000..9e26dfeeb6e641a33dae4961196235bdb965b21b
Binary files /dev/null and b/dataset/dji_roco/robomaster_Central China Regional Competition/digits/.changes differ
diff --git a/dataset/dji_roco/robomaster_South China Regional Competition/digits/.changes b/dataset/dji_roco/robomaster_South China Regional Competition/digits/.changes
new file mode 100644
index 0000000000000000000000000000000000000000..9e26dfeeb6e641a33dae4961196235bdb965b21b
Binary files /dev/null and b/dataset/dji_roco/robomaster_South China Regional Competition/digits/.changes differ
diff --git a/poetry.lock b/poetry.lock
index d853b41c27feb9d0eaed9b23e008abaeed50ccfb..65d8fc1aef4096e6e0f754701d1b9f0652a7731a 100644
Binary files a/poetry.lock and b/poetry.lock differ
diff --git a/polystar_cv/config/settings.toml b/polystar_cv/config/settings.toml
index c81c609731b41f3cdeaab76d868056e0641be6ef..8145dfe306963de2b34a1777eaeae541a47c122a 100644
--- a/polystar_cv/config/settings.toml
+++ b/polystar_cv/config/settings.toml
@@ -1,9 +1,8 @@
 [default]
-CAMERA_WIDTH = 1920
-CAMERA_HEIGHT = 1080
-CAMERA_HORIZONTAL_FOV = 120
+CAMERA = 'RASPI_V2'
 
-MODEL_NAME = 'robots/TRT_ssd_mobilenet_v2_roco.bin'
+OBJECTS_DETECTION_MODEL = "ssd_mobilenet_v2_roco_2018_03_29_20200314_015411_TWITCH_TEMP_733_IMGS_29595steps"
+ARMOR_DIGIT_MODEL = "20210117_145856_kd_cnn/distiled - temp 4.1e+01 - cnn - (32) - lr 7.8e-04 - drop 63.pkl"
 
 [development]
 
diff --git a/polystar_cv/polystar/common/dependency_injection.py b/polystar_cv/polystar/common/dependency_injection.py
index 88eca3aadbaef5d55dbce6cb2492254d271eb408..5482b1ce49695a0612cea7fa0a6b41d71af0aca7 100644
--- a/polystar_cv/polystar/common/dependency_injection.py
+++ b/polystar_cv/polystar/common/dependency_injection.py
@@ -1,13 +1,36 @@
 from dataclasses import dataclass
-from math import pi
+from typing import List
 
-from dynaconf import LazySettings
 from injector import Injector, Module, multiprovider, provider, singleton
+from numpy.core._multiarray_umath import deg2rad
 
-from polystar.common.constants import LABEL_MAP_PATH
+from polystar.common.communication.print_target_sender import PrintTargetSender
+from polystar.common.communication.target_sender_abc import TargetSenderABC
+from polystar.common.constants import LABEL_MAP_PATH, MODELS_DIR
+from polystar.common.frame_generators.camera_frame_generator import RaspiV2CameraFrameGenerator, WebcamFrameGenerator
+from polystar.common.frame_generators.frames_generator_abc import FrameGeneratorABC
 from polystar.common.models.camera import Camera
 from polystar.common.models.label_map import LabelMap
-from polystar.common.settings import settings
+from polystar.common.settings import Settings, settings
+from polystar.common.target_pipeline.armors_descriptors.armors_color_descriptor import ArmorsColorDescriptor
+from polystar.common.target_pipeline.armors_descriptors.armors_descriptor_abc import ArmorsDescriptorABC
+from polystar.common.target_pipeline.armors_descriptors.armors_digit_descriptor import ArmorsDigitDescriptor
+from polystar.common.target_pipeline.detected_objects.detected_objects_factory import DetectedObjectFactory
+from polystar.common.target_pipeline.detected_objects.detected_robot import DetectedRobot
+from polystar.common.target_pipeline.object_selectors.closest_object_selector import ClosestObjectSelector
+from polystar.common.target_pipeline.object_selectors.object_selector_abc import ObjectSelectorABC
+from polystar.common.target_pipeline.objects_detectors.objects_detector_abc import ObjectsDetectorABC
+from polystar.common.target_pipeline.objects_detectors.tf_model_objects_detector import TFModelObjectsDetector
+from polystar.common.target_pipeline.objects_linker.objects_linker_abs import ObjectsLinkerABC
+from polystar.common.target_pipeline.objects_linker.simple_objects_linker import SimpleObjectsLinker
+from polystar.common.target_pipeline.objects_validators.confidence_object_validator import ConfidenceObjectValidator
+from polystar.common.target_pipeline.objects_validators.objects_validator_abc import ObjectsValidatorABC
+from polystar.common.target_pipeline.target_factories.ratio_simple_target_factory import RatioSimpleTargetFactory
+from polystar.common.target_pipeline.target_factories.target_factory_abc import TargetFactoryABC
+from polystar.common.utils.serialization import pkl_load
+from research.common.constants import PIPELINES_DIR
+from research.robots.armor_color.pipeline import ArmorColorPipeline
+from research.robots.armor_color.scripts.benchmark import MeanChannels, RedBlueComparisonClassifier
 
 
 def make_injector() -> Injector:
@@ -16,16 +39,72 @@ def make_injector() -> Injector:
 
 @dataclass
 class CommonModule(Module):
-    settings: LazySettings
+    settings: Settings
 
     @provider
     @singleton
     def provide_camera(self) -> Camera:
-        return Camera(
-            self.settings.CAMERA_HORIZONTAL_FOV / 180 * pi, self.settings.CAMERA_WIDTH, self.settings.CAMERA_HEIGHT
-        )
+        if settings.CAMERA == "RASPI_V2":
+            return Camera(
+                horizontal_fov=deg2rad(62.2),
+                vertical_fov=deg2rad(48.8),
+                pixel_size_m=1.12e-6,
+                focal_m=3.04e-3,
+                vertical_resolution=720,
+                horizontal_resolution=1_280,
+            )
+        raise ValueError(f"Camera {settings.CAMERA} not recognized")
 
     @multiprovider
     @singleton
     def provide_label_map(self) -> LabelMap:
         return LabelMap.from_file(LABEL_MAP_PATH)
+
+    @provider
+    @singleton
+    def provide_objects_detector(self, object_factory: DetectedObjectFactory) -> ObjectsDetectorABC:
+        if self.settings.is_dev:
+            return TFModelObjectsDetector(object_factory, MODELS_DIR / "robots" / settings.OBJECTS_DETECTION_MODEL)
+        import pycuda.autoinit  # This is needed for initializing CUDA driver
+
+        raise NotImplementedError()
+
+    @multiprovider
+    @singleton
+    def provide_armor_descriptors(self) -> List[ArmorsDescriptorABC]:
+        return [
+            ArmorsColorDescriptor(ArmorColorPipeline.from_pipes([MeanChannels(), RedBlueComparisonClassifier()])),
+            ArmorsDigitDescriptor(pkl_load(PIPELINES_DIR / "armor-digit" / settings.ARMOR_DIGIT_MODEL)),
+        ]
+
+    @multiprovider
+    @singleton
+    def provide_objects_validators(self) -> List[ObjectsValidatorABC[DetectedRobot]]:
+        return [ConfidenceObjectValidator(0.6)]
+
+    @provider
+    @singleton
+    def provide_object_selector(self) -> ObjectSelectorABC:
+        return ClosestObjectSelector()
+
+    @provider
+    @singleton
+    def provide_target_factory(self, camera: Camera) -> TargetFactoryABC:
+        return RatioSimpleTargetFactory(camera, 0.3, 0.1)
+
+    @provider
+    @singleton
+    def provide_target_sender(self) -> TargetSenderABC:
+        return PrintTargetSender()
+
+    @provider
+    @singleton
+    def provide_objects_linker(self) -> ObjectsLinkerABC:
+        return SimpleObjectsLinker(min_percentage_intersection=0.8)
+
+    @provider
+    @singleton
+    def provide_webcam(self) -> FrameGeneratorABC:
+        if self.settings.is_prod:
+            return RaspiV2CameraFrameGenerator(1_280, 720)
+        return WebcamFrameGenerator()
diff --git a/polystar_cv/polystar/common/frame_generators/camera_frame_generator.py b/polystar_cv/polystar/common/frame_generators/camera_frame_generator.py
index eb3d4153847100bc476beb04e134b2470c60fcb3..853b7d09cee28561806c69534336388315376067 100644
--- a/polystar_cv/polystar/common/frame_generators/camera_frame_generator.py
+++ b/polystar_cv/polystar/common/frame_generators/camera_frame_generator.py
@@ -1,13 +1,13 @@
+from dataclasses import dataclass
 from typing import Any, Iterable
 
 import cv2
-from dataclasses import dataclass
 
 from polystar.common.frame_generators.cv2_frame_generator_abc import CV2FrameGeneratorABC
 
 
 @dataclass
-class CameraFrameGenerator(CV2FrameGeneratorABC):
+class RaspiV2CameraFrameGenerator(CV2FrameGeneratorABC):
     width: int
     height: int
 
@@ -23,3 +23,9 @@ class CameraFrameGenerator(CV2FrameGeneratorABC):
             "videoconvert ! appsink",
             cv2.CAP_GSTREAMER,
         )
+
+
+@dataclass
+class WebcamFrameGenerator(CV2FrameGeneratorABC):
+    def _capture_params(self) -> Iterable[Any]:
+        return (0,)
diff --git a/polystar_cv/polystar/common/frame_generators/cv2_frame_generator_abc.py b/polystar_cv/polystar/common/frame_generators/cv2_frame_generator_abc.py
index c185c1882f7c0fe842dc8c46896329fd82b19a18..10ec363259b01dee55ea39a4a8b6f24f3b98ffbd 100644
--- a/polystar_cv/polystar/common/frame_generators/cv2_frame_generator_abc.py
+++ b/polystar_cv/polystar/common/frame_generators/cv2_frame_generator_abc.py
@@ -1,8 +1,8 @@
 from abc import ABC, abstractmethod
+from dataclasses import dataclass, field
 from typing import Any, Iterable
 
 import cv2
-from dataclasses import dataclass, field
 
 from polystar.common.frame_generators.frames_generator_abc import FrameGeneratorABC
 from polystar.common.models.image import Image
@@ -15,6 +15,7 @@ class CV2FrameGeneratorABC(FrameGeneratorABC, ABC):
 
     def __enter__(self):
         self._cap = cv2.VideoCapture(*self._capture_params())
+        assert self._cap.isOpened()
 
     def __exit__(self, exc_type, exc_val, exc_tb):
         self._cap.release()
diff --git a/polystar_cv/polystar/common/models/camera.py b/polystar_cv/polystar/common/models/camera.py
index 9e0c28f7c00e079e1d4808057f6353079def7df1..7da33a32b0ee61fa59f89afe27a16200f8aea707 100644
--- a/polystar_cv/polystar/common/models/camera.py
+++ b/polystar_cv/polystar/common/models/camera.py
@@ -3,7 +3,11 @@ from dataclasses import dataclass
 
 @dataclass
 class Camera:
-    horizontal_angle: float
+    horizontal_fov: float
+    vertical_fov: float
 
-    w: int
-    h: int
+    pixel_size_m: float
+    focal_m: float
+
+    vertical_resolution: int
+    horizontal_resolution: int
diff --git a/polystar_cv/polystar/common/models/object.py b/polystar_cv/polystar/common/models/object.py
index 68860b6d5fc1ccdef9bfea31a77b131faa60ec18..1689a45cb32e06f02b806736cbd45820b2812feb 100644
--- a/polystar_cv/polystar/common/models/object.py
+++ b/polystar_cv/polystar/common/models/object.py
@@ -45,19 +45,23 @@ class ArmorDigit(NoCaseEnum):  # CHANGING
         # if self.value <= 5:
         #     return f"{self.value} ({self.name.title()})"
         # return self.name.title()
-        return f"{self.value + (self.value >= 2)} ({self.name.title()})"  # hacky, but a number is missing (2)
+        return f"{self.digit} ({self.name.title()})"  # hacky, but a number is missing (2)
 
     @property
     def short(self) -> str:
-        return self.name[0] if self != ArmorDigit.UNKNOWN else "?"
+        return self.digit if self != ArmorDigit.UNKNOWN else "?"
+
+    @property
+    def digit(self) -> int:
+        return self.value + (self.value >= 2)  # hacky, but a number is missing (2)
 
 
 class ObjectType(NoCaseEnum):
-    Car = auto()
-    Watcher = auto()
-    Base = auto()
-    Armor = auto()
-    Ignore = auto()
+    CAR = auto()
+    WATCHER = auto()
+    BASE = auto()
+    ARMOR = auto()
+    IGNORE = auto()
 
 
 @dataclass
@@ -93,7 +97,7 @@ class ObjectFactory:
 
         x, y = max(0, x), max(0, y)
 
-        if t is not ObjectType.Armor:
+        if t is not ObjectType.ARMOR:
             return Object(type=t, box=Box.from_size(x, y, w, h=h))
 
         armor_number = ArmorNumber(int(json["armor_class"])) if json["armor_class"] != "none" else 0
diff --git a/polystar_cv/polystar/common/models/tf_model.py b/polystar_cv/polystar/common/models/tf_model.py
index 468bb49b444edc88a9f9256c4543fef64e8475cd..3ed29141270e65da8666af81a2ee080e8ce363e2 100644
--- a/polystar_cv/polystar/common/models/tf_model.py
+++ b/polystar_cv/polystar/common/models/tf_model.py
@@ -1,5 +1,5 @@
 from typing import NewType
 
-from tensorflow_core.python.eager.wrap_function import WrappedFunction
+from tensorflow.python.eager.wrap_function import WrappedFunction
 
 TFModel = NewType("TFModel", WrappedFunction)
diff --git a/polystar_cv/polystar/common/pipeline/classification/classification_pipeline.py b/polystar_cv/polystar/common/pipeline/classification/classification_pipeline.py
index c85c7d666a7149fbcb5e83c5178aa9ba583e4591..1c1a6a97be1598fd1d84ede43ee0250e49ab591c 100644
--- a/polystar_cv/polystar/common/pipeline/classification/classification_pipeline.py
+++ b/polystar_cv/polystar/common/pipeline/classification/classification_pipeline.py
@@ -40,6 +40,8 @@ class ClassificationPipeline(Pipeline, Generic[IT, EnumT], ABC):
         return self.predict_proba_and_classes(x)[1]
 
     def predict_proba(self, x: Sequence[IT]) -> ndarray:
+        if not len(x):
+            return asarray([])
         proba = super().predict_proba(x)
         missing_classes = self.classifier.n_classes - proba.shape[1]
         if not missing_classes:
diff --git a/polystar_cv/polystar/common/pipeline/classification/keras_classification_pipeline.py b/polystar_cv/polystar/common/pipeline/classification/keras_classification_pipeline.py
index 388e33dcbad7083144dcd902bf98e50c3c644330..d2bcfc09aacbb3edd68a169b459dc6595ec3ad1f 100644
--- a/polystar_cv/polystar/common/pipeline/classification/keras_classification_pipeline.py
+++ b/polystar_cv/polystar/common/pipeline/classification/keras_classification_pipeline.py
@@ -121,6 +121,7 @@ class KerasClassificationPipeline(ClassificationPipeline):
                 input_shape, conv_blocks=conv_blocks, dense_size=dense_size, output_size=cls.n_classes, dropout=dropout,
             ),
             trainer=KerasTrainer(
+                data_preparator=KerasDataPreparator(batch_size=32, steps=100),
                 model_preparator=Distiller(temperature=temperature, teacher_model=teacher_pipeline.classifier.model),
                 compilation_parameters=KerasCompilationParameters(
                     loss=DistillationLoss(temperature=temperature, n_classes=cls.n_classes),
diff --git a/polystar_cv/polystar/common/pipeline/keras/classifier.py b/polystar_cv/polystar/common/pipeline/keras/classifier.py
index 8568a878692d293ec2423d74573ddd66600ff7e7..05d55ba7d464d4babbba1151fe55e64225bfc28d 100644
--- a/polystar_cv/polystar/common/pipeline/keras/classifier.py
+++ b/polystar_cv/polystar/common/pipeline/keras/classifier.py
@@ -3,12 +3,14 @@ from tempfile import NamedTemporaryFile
 from typing import Dict, List, Optional, Sequence
 
 from numpy import asarray
+from tensorflow import Graph, Session
 from tensorflow.python.keras.models import Model, load_model
 from tensorflow.python.keras.utils.np_utils import to_categorical
 
 from polystar.common.models.image import Image
 from polystar.common.pipeline.classification.classifier_abc import ClassifierABC
 from polystar.common.pipeline.keras.trainer import KerasTrainer
+from polystar.common.settings import settings
 from polystar.common.utils.registry import registry
 
 
@@ -30,6 +32,9 @@ class KerasClassifier(ClassifierABC):
         return self
 
     def predict_proba(self, examples: List[Image]) -> Sequence[float]:
+        if settings.is_prod:  # FIXME
+            with self.graph.as_default(), self.session.as_default():
+                return self.model.predict(asarray(examples))
         return self.model.predict(asarray(examples))
 
     def __getstate__(self) -> Dict:
@@ -43,10 +48,14 @@ class KerasClassifier(ClassifierABC):
 
     def __setstate__(self, state: Dict):
         self.__dict__.update(state)
+        self.graph = Graph()
         with NamedTemporaryFile(suffix=".hdf5", delete=True) as fd:
             fd.write(state.pop("model_str"))
             fd.flush()
-            self.model = load_model(fd.name)
+            with self.graph.as_default():
+                self.session = Session(graph=self.graph)
+                with self.session.as_default():
+                    self.model = load_model(fd.name, compile=False)
         self.trainer = None
 
     @property
diff --git a/polystar_cv/polystar/common/pipeline/keras/cnn.py b/polystar_cv/polystar/common/pipeline/keras/cnn.py
index 8e3cecfaacf29b9229d9e62827015c3cf509f250..4f3e45724ae1484fc5822b4164501e862b2bcbe0 100644
--- a/polystar_cv/polystar/common/pipeline/keras/cnn.py
+++ b/polystar_cv/polystar/common/pipeline/keras/cnn.py
@@ -1,6 +1,6 @@
 from typing import Sequence, Tuple
 
-from tensorflow.python.keras import Input, Sequential
+from tensorflow.python.keras import Sequential
 from tensorflow.python.keras.layers import Conv2D, Dense, Dropout, Flatten, MaxPooling2D, Softmax
 
 
@@ -12,10 +12,14 @@ def make_cnn_model(
     dropout: float,
 ) -> Sequential:
     model = Sequential()
-    model.add(Input((*input_shape, 3)))
+    model.add(Conv2D(conv_blocks[0][0], (3, 3), activation="relu", input_shape=(*input_shape, 3)))
 
+    is_first = True
     for conv_sizes in conv_blocks:
         for size in conv_sizes:
+            if is_first:
+                is_first = False
+                continue
             model.add(Conv2D(size, (3, 3), activation="relu"))
         model.add(MaxPooling2D())
 
diff --git a/polystar_cv/polystar/common/pipeline/keras/distillation.py b/polystar_cv/polystar/common/pipeline/keras/distillation.py
index 79b42c630feaedb9e76f591d4748e54efac8922a..6b82771a990de291bc72452ee1f77c2182b53ddb 100644
--- a/polystar_cv/polystar/common/pipeline/keras/distillation.py
+++ b/polystar_cv/polystar/common/pipeline/keras/distillation.py
@@ -2,25 +2,22 @@ from typing import Callable
 
 from tensorflow.python.keras import Input, Model, Sequential
 from tensorflow.python.keras.layers import Softmax, concatenate
-from tensorflow.python.keras.losses import KLDivergence
-from tensorflow.python.keras.models import Model
+from tensorflow.python.keras.losses import Loss, kullback_leibler_divergence
 from tensorflow.python.ops.nn_ops import softmax
 
 from polystar.common.pipeline.keras.model_preparator import KerasModelPreparator
 
 
-class DistillationLoss(KLDivergence):
+class DistillationLoss(Loss):
     def __init__(self, temperature: float, n_classes: int):
-        super().__init__()
+        super().__init__(name="kd_loss")
         self.n_classes = n_classes
         self.temperature = temperature
 
-    def __call__(self, y_true, y_pred, sample_weight=None):
+    def call(self, y_true, y_pred):
         teacher_logits, student_logits = y_pred[:, : self.n_classes], y_pred[:, self.n_classes :]
-        return super().__call__(
-            softmax(teacher_logits / self.temperature, axis=1),
-            softmax(student_logits / self.temperature, axis=1),
-            sample_weight=sample_weight,
+        return kullback_leibler_divergence(
+            softmax(teacher_logits / self.temperature, axis=1), softmax(student_logits / self.temperature, axis=1)
         )
 
 
diff --git a/polystar_cv/polystar/common/pipeline/keras/trainer.py b/polystar_cv/polystar/common/pipeline/keras/trainer.py
index 9db4ba7716ccb2efb09e03440cb0400ff4de40e3..16826b645940eb512f69e2d00b5ce78e8ae2f8b2 100644
--- a/polystar_cv/polystar/common/pipeline/keras/trainer.py
+++ b/polystar_cv/polystar/common/pipeline/keras/trainer.py
@@ -14,7 +14,7 @@ from polystar.common.pipeline.keras.model_preparator import KerasModelPreparator
 class KerasTrainer:
     compilation_parameters: KerasCompilationParameters
     callbacks: List[Callback]
-    data_preparator: KerasDataPreparator = field(default_factory=KerasDataPreparator)
+    data_preparator: KerasDataPreparator
     model_preparator: KerasModelPreparator = field(default_factory=KerasModelPreparator)
     max_epochs: int = 300
     verbose: int = 0
diff --git a/polystar_cv/polystar/common/pipeline/keras/transfer_learning.py b/polystar_cv/polystar/common/pipeline/keras/transfer_learning.py
index a6a7b4747e2186f9f61672538359bd51e2fdd291..e3ff9dcfb63682cff9a48309b41cdf8f6e859f32 100644
--- a/polystar_cv/polystar/common/pipeline/keras/transfer_learning.py
+++ b/polystar_cv/polystar/common/pipeline/keras/transfer_learning.py
@@ -1,6 +1,7 @@
 from typing import Callable, Tuple
 
-from tensorflow.python.keras import Input, Model, Sequential
+from tensorflow.python.keras import Sequential
+from tensorflow.python.keras.engine import InputLayer
 from tensorflow.python.keras.layers import Dense, Dropout, Flatten, Softmax
 from tensorflow.python.keras.models import Model
 
@@ -13,7 +14,7 @@ def make_transfer_learning_model(
 
     return Sequential(
         [
-            Input(input_shape),
+            InputLayer(input_shape),
             base_model,
             Flatten(),
             Dense(dense_size, activation="relu"),
diff --git a/polystar_cv/polystar/common/settings.py b/polystar_cv/polystar/common/settings.py
index 23b13f2a08ec612b40073c131cdc3e172147f516..44ad5c0c653519b7a55638321bd9060bde2a3410 100644
--- a/polystar_cv/polystar/common/settings.py
+++ b/polystar_cv/polystar/common/settings.py
@@ -1,5 +1,4 @@
 from enum import Enum
-from pathlib import Path
 
 from dynaconf import LazySettings
 
@@ -12,18 +11,23 @@ class Environment(str, Enum):
 
 
 class Settings(LazySettings):
-    def get_env(self) -> Environment:
+    @property
+    def env(self) -> Environment:
         return Environment(self.current_env.lower())
 
+    @property
+    def is_prod(self) -> bool:
+        return self.env == Environment.PRODUCTION
 
-def _config_file_for_project(project_name: str) -> Path:
-    return PROJECT_DIR / "config" / "settings.toml"
+    @property
+    def is_dev(self) -> bool:
+        return self.env == Environment.DEVELOPMENT
 
 
 def make_settings() -> LazySettings:
-    return LazySettings(
+    return Settings(
         SILENT_ERRORS_FOR_DYNACONF=False,
-        SETTINGS_FILE_FOR_DYNACONF=f"{PROJECT_DIR  / 'config' / 'settings.toml'}",
+        SETTINGS_FILE_FOR_DYNACONF=f"{PROJECT_DIR / 'polystar_cv'  / 'config' / 'settings.toml'}",
         ENV_SWITCHER_FOR_DYNACONF="POLYSTAR_ENV",
     )
 
diff --git a/polystar_cv/polystar/common/target_pipeline/armors_descriptors/armors_color_descriptor.py b/polystar_cv/polystar/common/target_pipeline/armors_descriptors/armors_color_descriptor.py
index c996df4da35561564156b9e293968a62078ef5ad..9d99d30f94012c97d372b9f6d2237be6ae93cc53 100644
--- a/polystar_cv/polystar/common/target_pipeline/armors_descriptors/armors_color_descriptor.py
+++ b/polystar_cv/polystar/common/target_pipeline/armors_descriptors/armors_color_descriptor.py
@@ -2,15 +2,18 @@ from dataclasses import dataclass
 from typing import List
 
 from polystar.common.models.image import Image
-from polystar.common.pipeline.classification.classification_pipeline import ClassificationPipeline
 from polystar.common.target_pipeline.armors_descriptors.armors_descriptor_abc import ArmorsDescriptorABC
 from polystar.common.target_pipeline.detected_objects.detected_armor import DetectedArmor
+from research.robots.armor_color.pipeline import ArmorColorPipeline
 
 
 @dataclass
 class ArmorsColorDescriptor(ArmorsDescriptorABC):
 
-    image_pipeline: ClassificationPipeline
+    image_pipeline: ArmorColorPipeline
+
+    def __post_init__(self):
+        assert ArmorColorPipeline.classes == self.image_pipeline.classes
 
     def _describe_armors_from_images(self, armors_images: List[Image], armors: List[DetectedArmor]):
         colors_predictions = self.image_pipeline.predict_proba(armors_images)
diff --git a/polystar_cv/polystar/common/target_pipeline/armors_descriptors/armors_digit_descriptor.py b/polystar_cv/polystar/common/target_pipeline/armors_descriptors/armors_digit_descriptor.py
new file mode 100644
index 0000000000000000000000000000000000000000..79c80691758a0141fbc6bedc56126aee53b9a1eb
--- /dev/null
+++ b/polystar_cv/polystar/common/target_pipeline/armors_descriptors/armors_digit_descriptor.py
@@ -0,0 +1,21 @@
+from dataclasses import dataclass
+from typing import List
+
+from polystar.common.models.image import Image
+from polystar.common.target_pipeline.armors_descriptors.armors_descriptor_abc import ArmorsDescriptorABC
+from polystar.common.target_pipeline.detected_objects.detected_armor import DetectedArmor
+from research.robots.armor_digit.pipeline import ArmorDigitPipeline
+
+
+@dataclass
+class ArmorsDigitDescriptor(ArmorsDescriptorABC):
+
+    image_pipeline: ArmorDigitPipeline
+
+    def __post_init__(self):
+        assert ArmorDigitPipeline.classes == self.image_pipeline.classes
+
+    def _describe_armors_from_images(self, armors_images: List[Image], armors: List[DetectedArmor]):
+        digit_predictions = self.image_pipeline.predict_proba(armors_images)
+        for digits_proba, armor in zip(digit_predictions, armors):
+            armor.digits_proba = digits_proba
diff --git a/polystar_cv/polystar/common/target_pipeline/debug_pipeline.py b/polystar_cv/polystar/common/target_pipeline/debug_pipeline.py
index e0e9ec8c0c3c90ddf9b637df96ddc58e9a6328a4..7a44ec74c2821ad735c89210afc87f173da73fa8 100644
--- a/polystar_cv/polystar/common/target_pipeline/debug_pipeline.py
+++ b/polystar_cv/polystar/common/target_pipeline/debug_pipeline.py
@@ -1,6 +1,8 @@
 from dataclasses import dataclass, field
 from typing import List
 
+from injector import inject
+
 from polystar.common.models.image import Image
 from polystar.common.target_pipeline.detected_objects.detected_armor import DetectedArmor
 from polystar.common.target_pipeline.detected_objects.detected_object import DetectedObject
@@ -18,6 +20,7 @@ class DebugInfo:
     target: TargetABC = field(init=False, default=None)
 
 
+@inject
 @dataclass
 class DebugTargetPipeline(TargetPipeline):
     """Wrap a pipeline with debug, to store debug infos"""
diff --git a/polystar_cv/polystar/common/target_pipeline/detected_objects/detected_armor.py b/polystar_cv/polystar/common/target_pipeline/detected_objects/detected_armor.py
index 3bfaf2b654f30647057b271891f414db70970413..2d1a538b480b5e66d906c9993dbcce18c157b04a 100644
--- a/polystar_cv/polystar/common/target_pipeline/detected_objects/detected_armor.py
+++ b/polystar_cv/polystar/common/target_pipeline/detected_objects/detected_armor.py
@@ -1,16 +1,17 @@
 from dataclasses import dataclass, field
 
 import numpy as np
-from numpy import argmax
 
 from polystar.common.models.object import ArmorColor, ArmorDigit, ObjectType
 from polystar.common.target_pipeline.detected_objects.detected_object import DetectedObject
+from research.robots.armor_color.pipeline import ArmorColorPipeline
+from research.robots.armor_digit.pipeline import ArmorDigitPipeline
 
 
 @dataclass
 class DetectedArmor(DetectedObject):
     def __post_init__(self):
-        assert self.type == ObjectType.Armor
+        assert self.type == ObjectType.ARMOR
 
     colors_proba: np.ndarray = field(init=False, default=None)
     digits_proba: np.ndarray = field(init=False, default=None)
@@ -24,7 +25,7 @@ class DetectedArmor(DetectedObject):
             return self._color
 
         if self.colors_proba is not None:
-            self._color = ArmorColor(self.colors_proba.argmax() + 1)
+            self._color = ArmorDigitPipeline.classes[self.colors_proba.argmax()]
             return self._color
 
         return ArmorColor.UNKNOWN
@@ -34,8 +35,8 @@ class DetectedArmor(DetectedObject):
         if self._digit is not None:
             return self._digit
 
-        if self.digits_proba:
-            self._digit = ArmorDigit(argmax(self.colors_proba) + 1)
+        if self.digits_proba is not None:
+            self._digit = ArmorColorPipeline.classes[self.digits_proba.argmax()]
             return self._digit
 
         return ArmorDigit.UNKNOWN
diff --git a/polystar_cv/polystar/common/target_pipeline/detected_objects/detected_objects_factory.py b/polystar_cv/polystar/common/target_pipeline/detected_objects/detected_objects_factory.py
index 543ba72b93f96ada3aa65d363a3efcb31218f9a0..60398099cd23db51e8ef52583e8500722979095d 100644
--- a/polystar_cv/polystar/common/target_pipeline/detected_objects/detected_objects_factory.py
+++ b/polystar_cv/polystar/common/target_pipeline/detected_objects/detected_objects_factory.py
@@ -1,6 +1,8 @@
 from dataclasses import dataclass
 from typing import List, Tuple, Type, Union
 
+from injector import inject
+
 from polystar.common.models.box import Box
 from polystar.common.models.image import Image
 from polystar.common.models.label_map import LabelMap
@@ -21,6 +23,7 @@ class ObjectParams:
 
 
 class DetectedObjectFactory:
+    @inject
     def __init__(self, label_map: LabelMap, armors_descriptors: List[ArmorsDescriptorABC]):
         self.armors_descriptors = armors_descriptors
         self.label_map = label_map
@@ -57,4 +60,4 @@ class DetectedObjectFactory:
 
     @staticmethod
     def _get_object_class_from_type(object_type: ObjectType) -> Type[Union[DetectedRobot, DetectedArmor]]:
-        return DetectedArmor if object_type is ObjectType.Armor else DetectedRobot
+        return DetectedArmor if object_type is ObjectType.ARMOR else DetectedRobot
diff --git a/polystar_cv/polystar/common/target_pipeline/detected_objects/detected_robot.py b/polystar_cv/polystar/common/target_pipeline/detected_objects/detected_robot.py
index 372ed7f156226f47389564355e14fd400dcf88c0..7d9f7ff25a65ff9afd03e6f80296cf6b6b1a4863 100644
--- a/polystar_cv/polystar/common/target_pipeline/detected_objects/detected_robot.py
+++ b/polystar_cv/polystar/common/target_pipeline/detected_objects/detected_robot.py
@@ -13,4 +13,4 @@ class DetectedRobot(DetectedObject):
 
 class FakeDetectedRobot(DetectedRobot):
     def __init__(self, armor: DetectedArmor):
-        super().__init__(type=ObjectType.Car, box=armor.box, confidence=0, armors=[armor])
+        super().__init__(type=ObjectType.CAR, box=armor.box, confidence=0, armors=[armor])
diff --git a/polystar_cv/polystar/common/target_pipeline/objects_detectors/tf_model_objects_detector.py b/polystar_cv/polystar/common/target_pipeline/objects_detectors/tf_model_objects_detector.py
index c4282a55ade9d687d9dcf5268526ba72d358a200..2ef9a49d1e7ae9d5e257f3a166cf5f0475221dc6 100644
--- a/polystar_cv/polystar/common/target_pipeline/objects_detectors/tf_model_objects_detector.py
+++ b/polystar_cv/polystar/common/target_pipeline/objects_detectors/tf_model_objects_detector.py
@@ -1,14 +1,16 @@
-from dataclasses import dataclass
+from dataclasses import InitVar, dataclass
+from pathlib import Path
 from typing import Dict, List, Tuple
 
 import numpy as np
 import tensorflow as tf
+from tensorflow import GraphDef, Session
+from tensorflow.python.platform.gfile import GFile
 
 from polystar.common.models.image import Image
 from polystar.common.models.tf_model import TFModel
 from polystar.common.target_pipeline.detected_objects.detected_armor import DetectedArmor
-from polystar.common.target_pipeline.detected_objects.detected_objects_factory import (DetectedObjectFactory,
-                                                                                       ObjectParams)
+from polystar.common.target_pipeline.detected_objects.detected_objects_factory import ObjectParams
 from polystar.common.target_pipeline.detected_objects.detected_robot import DetectedRobot
 from polystar.common.target_pipeline.objects_detectors.objects_detector_abc import ObjectsDetectorABC
 
@@ -16,6 +18,46 @@ from polystar.common.target_pipeline.objects_detectors.objects_detector_abc impo
 @dataclass
 class TFModelObjectsDetector(ObjectsDetectorABC):
 
+    model_path: InitVar[Path]
+
+    def __post_init__(self, model_path: Path):
+        self.graph = tf.Graph()
+        with self.graph.as_default():
+            od_graph_def = GraphDef()
+            with GFile(str(model_path / "frozen_inference_graph.pb"), "rb") as fid:
+                serialized_graph = fid.read()
+                od_graph_def.ParseFromString(serialized_graph)
+                tf.import_graph_def(od_graph_def, name="")
+
+    def detect(self, image: Image) -> Tuple[List[DetectedRobot], List[DetectedArmor]]:
+        with self.graph.as_default(), Session(graph=self.graph) as session:
+            image_np_expanded = np.expand_dims(image, axis=0)
+            image_tensor = self.graph.get_tensor_by_name("image_tensor:0")
+            boxes = self.graph.get_tensor_by_name("detection_boxes:0")
+            scores = self.graph.get_tensor_by_name("detection_scores:0")
+            classes = self.graph.get_tensor_by_name("detection_classes:0")
+            num_detections = self.graph.get_tensor_by_name("num_detections:0")
+            boxes, scores, classes, num_detections = session.run(
+                [boxes, scores, classes, num_detections], feed_dict={image_tensor: image_np_expanded}
+            )
+            return self._construct_objects_from_tf_results(image, boxes[0], scores[0], classes[0])
+
+    def _construct_objects_from_tf_results(
+        self, image: Image, boxes: np.ndarray, scores: np.ndarray, classes: np.ndarray
+    ) -> Tuple[List[DetectedRobot], List[DetectedArmor]]:
+        return self.objects_factory.make_lists(
+            [
+                ObjectParams(ymin=ymin, xmin=xmin, ymax=ymax, xmax=xmax, score=score, object_class_id=object_class_id)
+                for (ymin, xmin, ymax, xmax), object_class_id, score in zip(boxes, classes, scores)
+                if score >= 0.1
+            ],
+            image,
+        )
+
+
+@dataclass
+class TFV2ModelObjectsDetector(ObjectsDetectorABC):
+
     model: TFModel
 
     def detect(self, image: Image) -> Tuple[List[DetectedRobot], List[DetectedArmor]]:
@@ -30,7 +72,7 @@ class TFModelObjectsDetector(ObjectsDetectorABC):
         return input_tensor
 
     def _make_single_prediction(self, input_tensor: tf.Tensor) -> Dict[str, np.array]:
-        output_dict: Dict[str, tf.Tensor] = self.model(input_tensor)  # typing is correct despite PyCharm's saying
+        output_dict: Dict[str, tf.Tensor] = self.model(input_tensor)
         return self._normalize_prediction(output_dict)
 
     @staticmethod
diff --git a/polystar_cv/polystar/common/target_pipeline/objects_linker/simple_objects_linker.py b/polystar_cv/polystar/common/target_pipeline/objects_linker/simple_objects_linker.py
index 8500e3eb3d0b5fe7c2cd8c2276f8099bdbf54884..86dd41d700442947e2e12d3c070bd5ac664f1d54 100644
--- a/polystar_cv/polystar/common/target_pipeline/objects_linker/simple_objects_linker.py
+++ b/polystar_cv/polystar/common/target_pipeline/objects_linker/simple_objects_linker.py
@@ -14,8 +14,8 @@ class SimpleObjectsLinker(ObjectsLinkerABC):
     def __init__(self, min_percentage_intersection: float):
         super().__init__()
         self.min_percentage_intersection = min_percentage_intersection
-        self.robots_filter = NegationValidator(TypeObjectValidator(ObjectType.Armor))
-        self.armors_filter = TypeObjectValidator(ObjectType.Armor)
+        self.robots_filter = NegationValidator(TypeObjectValidator(ObjectType.ARMOR))
+        self.armors_filter = TypeObjectValidator(ObjectType.ARMOR)
 
     def link_armors_to_robots(
         self, robots: List[DetectedRobot], armors: List[DetectedArmor], image: Image
diff --git a/polystar_cv/polystar/common/target_pipeline/objects_validators/armor_digit_validator.py b/polystar_cv/polystar/common/target_pipeline/objects_validators/armor_digit_validator.py
new file mode 100644
index 0000000000000000000000000000000000000000..060d78c7c3fa6b3949fd384c8ad38b19e6b338d0
--- /dev/null
+++ b/polystar_cv/polystar/common/target_pipeline/objects_validators/armor_digit_validator.py
@@ -0,0 +1,15 @@
+from typing import Iterable
+
+from numpy.core.multiarray import ndarray
+
+from polystar.common.models.object import Armor
+from polystar.common.target_pipeline.detected_objects.detected_robot import DetectedRobot
+from polystar.common.target_pipeline.objects_validators.objects_validator_abc import ObjectsValidatorABC
+
+
+class ArmorDigitValidator(ObjectsValidatorABC[DetectedRobot]):
+    def __init__(self, digits: Iterable[int]):
+        self.digits = digits
+
+    def validate_single(self, armor: Armor, image: ndarray) -> bool:
+        return isinstance(armor, Armor) and armor.number in self.digits
diff --git a/polystar_cv/polystar/common/target_pipeline/objects_validators/objects_validator_abc.py b/polystar_cv/polystar/common/target_pipeline/objects_validators/objects_validator_abc.py
index e75721dbe4e791698b85801226e2d0b8ea355aef..075ce1521f5764bc98ba8fc10ae473514b83d0a8 100644
--- a/polystar_cv/polystar/common/target_pipeline/objects_validators/objects_validator_abc.py
+++ b/polystar_cv/polystar/common/target_pipeline/objects_validators/objects_validator_abc.py
@@ -1,5 +1,5 @@
-from abc import abstractmethod, ABC
-from typing import List, TypeVar, Generic
+from abc import ABC, abstractmethod
+from typing import Generic, List, TypeVar
 
 import numpy as np
 
@@ -8,7 +8,7 @@ from polystar.common.models.object import Object
 ObjectT = TypeVar("ObjectT", bound=Object)
 
 
-class ObjectsValidatorABC(Generic[ObjectT], ABC):
+class ObjectsValidatorABC(Generic[ObjectT], ABC):  # FIXME Filter would do here
     def filter(self, objects: List[ObjectT], image: np.ndarray) -> List[ObjectT]:
         return [obj for obj, is_valid in zip(objects, self.validate(objects, image)) if is_valid]
 
diff --git a/polystar_cv/polystar/common/target_pipeline/target_factories/ratio_target_factory_abc.py b/polystar_cv/polystar/common/target_pipeline/target_factories/ratio_target_factory_abc.py
index 1f8a3917b372eb22ab265813558dba2ddfbad018..c58a2207bf118147b0e06b180a0a3e50cbf515a1 100644
--- a/polystar_cv/polystar/common/target_pipeline/target_factories/ratio_target_factory_abc.py
+++ b/polystar_cv/polystar/common/target_pipeline/target_factories/ratio_target_factory_abc.py
@@ -1,5 +1,5 @@
 from abc import ABC
-from math import sin, asin, sqrt, atan2
+from math import atan2, pi, tan
 from typing import Tuple
 
 import numpy as np
@@ -10,17 +10,16 @@ from polystar.common.target_pipeline.target_factories.target_factory_abc import
 
 
 class RatioTargetFactoryABC(TargetFactoryABC, ABC):
-    def __init__(self, camera: Camera, real_width: float, real_height: float):
-        self._ratio_w = real_width * camera.w // 2 / sin(camera.horizontal_angle)
-        # self._ratio_h = real_height * camera.h // 2 / sin(camera.vertical_angle)
-
-        self._coef_angle = sin(camera.horizontal_angle) / (camera.w // 2)
+    def __init__(self, camera: Camera, real_width_m: float, real_height_m: float):
+        self._vertical_distance_coef = camera.focal_m * real_height_m / camera.pixel_size_m
+        self._vertical_angle_distance = camera.vertical_resolution / (2 * tan(camera.vertical_fov))
+        self._horizontal_angle_distance = camera.horizontal_resolution / (2 * tan(camera.horizontal_fov))
 
     def _calculate_angles(self, obj: DetectedObject, image: np.ndarray) -> Tuple[float, float]:
         x, y = obj.box.x + obj.box.w // 2 - image.shape[1] // 2, image.shape[0] // 2 - obj.box.y + obj.box.h // 2
-        phi = asin(sqrt(x ** 2 + y ** 2) * self._coef_angle)
-        theta = atan2(y, x)
+        phi = -atan2(x, self._horizontal_angle_distance)
+        theta = pi / 2 - atan2(y, self._vertical_angle_distance)
         return phi, theta
 
     def _calculate_distance(self, obj: DetectedObject) -> float:
-        return self._ratio_w / obj.box.w
+        return self._vertical_distance_coef / obj.box.h
diff --git a/polystar_cv/polystar/common/target_pipeline/target_pipeline.py b/polystar_cv/polystar/common/target_pipeline/target_pipeline.py
index f226380a189ce6643cee2cc5a3d78528e46cca4d..9b51450094f407ff103e1d0a5cd76cc41ffaabd7 100644
--- a/polystar_cv/polystar/common/target_pipeline/target_pipeline.py
+++ b/polystar_cv/polystar/common/target_pipeline/target_pipeline.py
@@ -1,6 +1,8 @@
 from dataclasses import dataclass
 from typing import List
 
+from injector import inject
+
 from polystar.common.communication.target_sender_abc import TargetSenderABC
 from polystar.common.models.image import Image
 from polystar.common.target_pipeline.detected_objects.detected_object import DetectedObject
@@ -17,6 +19,7 @@ class NoTargetFoundException(Exception):
     pass
 
 
+@inject
 @dataclass
 class TargetPipeline:
 
diff --git a/polystar_cv/polystar/common/utils/fps.py b/polystar_cv/polystar/common/utils/fps.py
new file mode 100644
index 0000000000000000000000000000000000000000..02e9883eaafb13f2e3a7b203e92d55a36086f05e
--- /dev/null
+++ b/polystar_cv/polystar/common/utils/fps.py
@@ -0,0 +1,23 @@
+from time import time
+
+
+class FPS:
+    def __init__(self, inertia: float = 0.9):
+        self.inertia = inertia
+        self.previous_time = time()
+        self.fps = 0
+
+    def __str__(self) -> str:
+        return str(self.fps)
+
+    def __format__(self, format_spec: str):
+        return format(self.fps, format_spec)
+
+    def tick(self) -> float:
+        t = time()
+        self.fps = self.inertia * self.fps + (1 - self.inertia) / (t - self.previous_time)
+        self.previous_time = t
+        return self.fps
+
+    def skip(self):
+        self.previous_time = time()
diff --git a/polystar_cv/polystar/common/utils/iterable_utils.py b/polystar_cv/polystar/common/utils/iterable_utils.py
index 01bc2da41ef0b4ba3894a14cdf989c745c6829fe..1efac7228ee1aea8d161613ade1422b74525ad13 100644
--- a/polystar_cv/polystar/common/utils/iterable_utils.py
+++ b/polystar_cv/polystar/common/utils/iterable_utils.py
@@ -27,3 +27,14 @@ def group_by(it: Iterable[T], key: Callable[[T], U]) -> Dict[U, List[T]]:
     for item in it:
         rv[key(item)].append(item)
     return rv
+
+
+def chunk(it: Iterable[T], batch_size: float) -> Iterable[List[T]]:
+    batch = []
+    for el in it:
+        batch.append(el)
+        if len(batch) == batch_size:
+            yield batch
+            batch = []
+    if batch:
+        yield batch
diff --git a/polystar_cv/polystar/common/view/results_viewer_abc.py b/polystar_cv/polystar/common/view/results_viewer_abc.py
index 619b47e492abd518f1b3e23eb4bda82058d6e776..6e89ad2bacdd131719d32fe29107548efa70e40e 100644
--- a/polystar_cv/polystar/common/view/results_viewer_abc.py
+++ b/polystar_cv/polystar/common/view/results_viewer_abc.py
@@ -1,6 +1,6 @@
 from abc import ABC, abstractmethod
 from itertools import cycle
-from typing import Iterable, NewType, Sequence, Tuple
+from typing import Iterable, Sequence, Tuple
 
 from polystar.common.models.image import Image
 from polystar.common.models.image_annotation import ImageAnnotation
@@ -8,7 +8,7 @@ from polystar.common.models.object import Object
 from polystar.common.target_pipeline.debug_pipeline import DebugInfo
 from polystar.common.target_pipeline.detected_objects.detected_robot import DetectedRobot, FakeDetectedRobot
 
-ColorView = NewType("ColorView", Tuple[float, float, float])
+ColorView = Tuple[float, float, float]
 
 
 class ResultViewerABC(ABC):
@@ -55,12 +55,15 @@ class ResultViewerABC(ABC):
         self.display_image_with_objects(annotation.image, annotation.objects)
 
     def display_debug_info(self, debug_info: DebugInfo):
+        self.add_debug_info(debug_info)
+        self.display()
+
+    def add_debug_info(self, debug_info: DebugInfo):
         self.new(debug_info.image)
         self.add_robots(debug_info.detected_robots, forced_color=(0.3, 0.3, 0.3))
         self.add_robots(debug_info.validated_robots)
         if debug_info.selected_armor is not None:
             self.add_object(debug_info.selected_armor)
-        self.display()
 
     def add_robot(self, robot: DetectedRobot, forced_color: ColorView = None):
         objects = robot.armors
diff --git a/polystar_cv/research/common/constants.py b/polystar_cv/research/common/constants.py
index 8d90b8acd48eaf627b2bbc55086b0a23996335b4..f2b7e8c7bd365474e9b9a00726c92917f08091d2 100644
--- a/polystar_cv/research/common/constants.py
+++ b/polystar_cv/research/common/constants.py
@@ -20,3 +20,4 @@ TWITCH_DSET_ROBOTS_VIEWS_DIR.mkdir(parents=True, exist_ok=True)
 
 
 EVALUATION_DIR: Path = PROJECT_DIR / "experiments"
+PIPELINES_DIR = PROJECT_DIR / "pipelines"
diff --git a/polystar_cv/research/common/dataset/cleaning/dataset_changes.py b/polystar_cv/research/common/dataset/cleaning/dataset_changes.py
index bdeb4741876923230fc2f1f823bf1c6ddbec0062..937b834051fde928b06c5a31020cafa36ee67e6c 100644
--- a/polystar_cv/research/common/dataset/cleaning/dataset_changes.py
+++ b/polystar_cv/research/common/dataset/cleaning/dataset_changes.py
@@ -1,5 +1,5 @@
 import json
-from contextlib import suppress
+import logging
 from pathlib import Path
 from typing import Dict, List, Set
 
@@ -10,13 +10,18 @@ from polystar.common.utils.time import create_time_id
 from research.common.gcloud.gcloud_storage import GCStorages
 
 INVALIDATED_KEY: str = "invalidated"
+logger = logging.getLogger(__name__)
 
 
 class DatasetChanges:
     def __init__(self, dataset_directory: Path):
         self.changes_file: Path = dataset_directory / ".changes"
-        with suppress(FileNotFoundError):
+        try:
             GCStorages.DEV.download_file_if_missing(self.changes_file)
+        except FileNotFoundError:
+            self.changes_file.write_text("{}")
+        except ConnectionError:
+            logger.warning(f"Can't load {self.changes_file} because of no internet connection")
 
     @property
     def invalidated(self) -> Set[str]:
diff --git a/polystar_cv/research/common/datasets/dataset_builder.py b/polystar_cv/research/common/datasets/dataset_builder.py
index fd8650f91a2d7c8fb88bf916e35b9fdd626acfc9..7b831c86b1bc693a85063c8cd1491109d0f7efaa 100644
--- a/polystar_cv/research/common/datasets/dataset_builder.py
+++ b/polystar_cv/research/common/datasets/dataset_builder.py
@@ -6,6 +6,7 @@ from polystar.common.utils.misc import identity
 from research.common.datasets.dataset import Dataset
 from research.common.datasets.filter_dataset import ExampleU, FilterDataset, TargetU
 from research.common.datasets.lazy_dataset import ExampleT, LazyDataset, TargetT
+from research.common.datasets.shuffle_dataset import ShuffleDataset
 from research.common.datasets.slice_dataset import SliceDataset
 from research.common.datasets.transform_dataset import TransformDataset
 
@@ -58,6 +59,10 @@ class DatasetBuilder(Generic[ExampleT, TargetT], Iterable[Tuple[ExampleT, Target
         self.dataset = TransformDataset(self.dataset, identity, target_transformer)
         return self
 
+    def shuffle(self) -> "DatasetBuilder[ExampleT, TargetU]":
+        self.dataset = ShuffleDataset(self.dataset)
+        return self
+
     def cap(self, n: int) -> "DatasetBuilder[ExampleT, TargetT]":
         self.dataset = SliceDataset(self.dataset, stop=n)
         return self
diff --git a/polystar_cv/research/common/datasets/roco/roco_annotation_filters/roco_annotation_object_filter.py b/polystar_cv/research/common/datasets/roco/roco_annotation_filters/roco_annotation_object_filter.py
new file mode 100644
index 0000000000000000000000000000000000000000..8b9d61db7f1f819f26a7f778ec427189b12a00be
--- /dev/null
+++ b/polystar_cv/research/common/datasets/roco/roco_annotation_filters/roco_annotation_object_filter.py
@@ -0,0 +1,11 @@
+from polystar.common.filters.filter_abc import FilterABC
+from polystar.common.target_pipeline.objects_validators.objects_validator_abc import ObjectsValidatorABC
+from research.common.datasets.roco.roco_annotation import ROCOAnnotation
+
+
+class ROCOAnnotationObjectFilter(FilterABC):
+    def __init__(self, object_validator: ObjectsValidatorABC):
+        self.object_validator = object_validator
+
+    def validate_single(self, annotation: ROCOAnnotation) -> bool:
+        return any(self.object_validator.validate(annotation.objects, None))
diff --git a/polystar_cv/research/common/datasets/roco/roco_dataset_descriptor.py b/polystar_cv/research/common/datasets/roco/roco_dataset_descriptor.py
index 070bc9fb9404db103899c04143e7ededdc15d83a..4f2a76535314daa368cea1fe78fefe30dbf63fd1 100644
--- a/polystar_cv/research/common/datasets/roco/roco_dataset_descriptor.py
+++ b/polystar_cv/research/common/datasets/roco/roco_dataset_descriptor.py
@@ -33,11 +33,11 @@ class ROCODatasetStats:
             rv.n_images += 1
             rv.n_runes += annotation.has_rune
             for obj in annotation.objects:
-                if obj.type == ObjectType.Car:
+                if obj.type == ObjectType.CAR:
                     rv.n_robots += 1
-                elif obj.type == ObjectType.Base:
+                elif obj.type == ObjectType.BASE:
                     rv.n_bases += 1
-                elif obj.type == ObjectType.Watcher:
+                elif obj.type == ObjectType.WATCHER:
                     rv.n_watchers += 1
                 elif isinstance(obj, Armor):
                     rv.armors_color2num2count[obj.color.name.lower()][obj.number] += 1
diff --git a/polystar_cv/research/common/datasets/roco/zoo/roco_dataset_zoo.py b/polystar_cv/research/common/datasets/roco/zoo/roco_dataset_zoo.py
index 12a284c1b977b14641abc4dea10a7f5f96218222..d9e546ec07c698f64e6980ea105d62e714bcd89e 100644
--- a/polystar_cv/research/common/datasets/roco/zoo/roco_dataset_zoo.py
+++ b/polystar_cv/research/common/datasets/roco/zoo/roco_dataset_zoo.py
@@ -11,6 +11,11 @@ class ROCODatasetsZoo(Iterable[Type[ROCODatasets]]):
     DJI = DJIROCODatasets
     TWITCH = TwitchROCODatasets
 
+    DEFAULT_TEST_DATASETS = [TWITCH.T470152838, TWITCH.T470151286]
+    DEFAULT_VALIDATION_DATASETS = [TWITCH.T470152289, TWITCH.T470149568]
+    TWITCH_TRAIN_DATASETS = [TWITCH.T470150052, TWITCH.T470152730, TWITCH.T470153081, TWITCH.T470158483]
+    DEFAULT_TRAIN_DATASETS = TWITCH_TRAIN_DATASETS + [DJI.FINAL, DJI.CENTRAL_CHINA, DJI.NORTH_CHINA, DJI.SOUTH_CHINA]
+
     def __iter__(self) -> Iterator[Type[ROCODatasets]]:
         return iter((self.DJI, self.DJI_ZOOMED, self.TWITCH))
 
diff --git a/polystar_cv/research/common/datasets/shuffle_dataset.py b/polystar_cv/research/common/datasets/shuffle_dataset.py
new file mode 100644
index 0000000000000000000000000000000000000000..ad28020701a07907d64a2ebc48a74dfa73c1ccf9
--- /dev/null
+++ b/polystar_cv/research/common/datasets/shuffle_dataset.py
@@ -0,0 +1,15 @@
+from random import shuffle
+from typing import Iterator, Tuple
+
+from research.common.datasets.lazy_dataset import ExampleT, LazyDataset, TargetT
+
+
+class ShuffleDataset(LazyDataset):
+    def __init__(self, source: LazyDataset[ExampleT, TargetT]):
+        super().__init__(source.name)
+        self.source = source
+
+    def __iter__(self) -> Iterator[Tuple[ExampleT, TargetT]]:
+        data = list(self.source)
+        shuffle(data)
+        return iter(data)
diff --git a/polystar_cv/research/common/datasets/slice_dataset.py b/polystar_cv/research/common/datasets/slice_dataset.py
index ec06e46f0d5abbdfd7e7a94c18c16903f0f48989..348d8be3d3098120367a20634828971d35106e36 100644
--- a/polystar_cv/research/common/datasets/slice_dataset.py
+++ b/polystar_cv/research/common/datasets/slice_dataset.py
@@ -4,7 +4,7 @@ from typing import Iterator, Optional, Tuple
 from research.common.datasets.lazy_dataset import ExampleT, LazyDataset, TargetT
 
 
-class SliceDataset(LazyDataset):
+class SliceDataset(LazyDataset[ExampleT, TargetT]):
     def __init__(
         self,
         source: LazyDataset[ExampleT, TargetT],
diff --git a/polystar_cv/research/common/utils/logs.py b/polystar_cv/research/common/utils/logs.py
new file mode 100644
index 0000000000000000000000000000000000000000..0b580c6220344413e7e482bbbdb2ae0e477d1eba
--- /dev/null
+++ b/polystar_cv/research/common/utils/logs.py
@@ -0,0 +1,6 @@
+import logging
+
+
+def setup_dev_logs():
+    logging.getLogger().setLevel("INFO")
+    logging.info("logs started")
diff --git a/polystar_cv/research/robots/armor_color/datasets.py b/polystar_cv/research/robots/armor_color/datasets.py
index 5c53b9ef1e4cfc6ddc93ba8c53ebf07a97194e97..026aa425688392b2115ac5c57def0ae395d795bd 100644
--- a/polystar_cv/research/robots/armor_color/datasets.py
+++ b/polystar_cv/research/robots/armor_color/datasets.py
@@ -1,12 +1,20 @@
 from typing import List, Tuple
 
+from polystar.common.models.image import FileImage
 from polystar.common.models.object import Armor, ArmorColor
-from research.common.datasets.image_dataset import FileImageDataset
-from research.common.datasets.roco.zoo.roco_dataset_zoo import ROCODatasetsZoo
+from research.common.datasets.dataset import Dataset
 from research.robots.dataset.armor_value_dataset_generator import ArmorValueDatasetGenerator
 from research.robots.dataset.armor_value_target_factory import ArmorValueTargetFactory
 
 
+def make_armor_color_datasets(
+    include_dji: bool = True,
+) -> Tuple[
+    List[Dataset[FileImage, ArmorColor]], List[Dataset[FileImage, ArmorColor]], List[Dataset[FileImage, ArmorColor]]
+]:
+    return make_armor_color_dataset_generator().default_datasets(include_dji)
+
+
 class ArmorColorTargetFactory(ArmorValueTargetFactory[ArmorColor]):
     def from_str(self, label: str) -> ArmorColor:
         return ArmorColor(label)
@@ -17,33 +25,3 @@ class ArmorColorTargetFactory(ArmorValueTargetFactory[ArmorColor]):
 
 def make_armor_color_dataset_generator() -> ArmorValueDatasetGenerator[ArmorColor]:
     return ArmorValueDatasetGenerator("colors", ArmorColorTargetFactory())
-
-
-def make_armor_color_datasets(
-    include_dji: bool = True,
-) -> Tuple[List[FileImageDataset], List[FileImageDataset], List[FileImageDataset]]:
-    color_dataset_generator = make_armor_color_dataset_generator()
-
-    train_roco_datasets = [
-        ROCODatasetsZoo.TWITCH.T470150052,
-        ROCODatasetsZoo.TWITCH.T470152730,
-        ROCODatasetsZoo.TWITCH.T470153081,
-        ROCODatasetsZoo.TWITCH.T470158483,
-    ]
-    if include_dji:
-        train_roco_datasets.extend(
-            [
-                ROCODatasetsZoo.DJI.FINAL,
-                ROCODatasetsZoo.DJI.CENTRAL_CHINA,
-                ROCODatasetsZoo.DJI.NORTH_CHINA,
-                ROCODatasetsZoo.DJI.SOUTH_CHINA,
-            ]
-        )
-
-    train_datasets, validation_datasets, test_datasets = color_dataset_generator.from_roco_datasets(
-        train_roco_datasets,
-        [ROCODatasetsZoo.TWITCH.T470149568, ROCODatasetsZoo.TWITCH.T470152289],
-        [ROCODatasetsZoo.TWITCH.T470152838, ROCODatasetsZoo.TWITCH.T470151286],
-    )
-
-    return train_datasets, validation_datasets, test_datasets
diff --git a/polystar_cv/research/robots/armor_digit/armor_digit_dataset.py b/polystar_cv/research/robots/armor_digit/armor_digit_dataset.py
index f7f743707185a711e00379a4b7e529adb4aa18cd..7ff86d49e46e87937d115f8e87aaa7c91a4e0522 100644
--- a/polystar_cv/research/robots/armor_digit/armor_digit_dataset.py
+++ b/polystar_cv/research/robots/armor_digit/armor_digit_dataset.py
@@ -2,8 +2,9 @@ from itertools import islice
 from typing import List, Set, Tuple
 
 from polystar.common.filters.exclude_filter import ExcludeFilter
+from polystar.common.models.image import FileImage
 from polystar.common.models.object import Armor, ArmorDigit
-from research.common.datasets.image_dataset import FileImageDataset
+from research.common.datasets.dataset import Dataset
 from research.common.datasets.roco.zoo.roco_dataset_zoo import ROCODatasetsZoo
 from research.robots.dataset.armor_value_dataset_generator import ArmorValueDatasetGenerator
 from research.robots.dataset.armor_value_target_factory import ArmorValueTargetFactory
@@ -11,34 +12,14 @@ from research.robots.dataset.armor_value_target_factory import ArmorValueTargetF
 VALID_NUMBERS_2021: Set[int] = {1, 3, 4}  # University League
 
 
-def make_armor_digit_dataset_generator() -> ArmorValueDatasetGenerator[ArmorDigit]:
-    return ArmorValueDatasetGenerator("digits", ArmorDigitTargetFactory(), ExcludeFilter({ArmorDigit.OUTDATED}))
+def default_armor_digit_datasets() -> Tuple[
+    List[Dataset[FileImage, ArmorDigit]], List[Dataset[FileImage, ArmorDigit]], List[Dataset[FileImage, ArmorDigit]]
+]:
+    return make_armor_digit_dataset_generator().default_datasets()
 
 
-def default_armor_digit_datasets() -> Tuple[List[FileImageDataset], List[FileImageDataset], List[FileImageDataset]]:
-    digit_dataset_generator = make_armor_digit_dataset_generator()
-    train_datasets, validation_datasets, test_datasets = digit_dataset_generator.from_roco_datasets(
-        [
-            ROCODatasetsZoo.TWITCH.T470150052,
-            ROCODatasetsZoo.TWITCH.T470152730,
-            ROCODatasetsZoo.TWITCH.T470153081,
-            ROCODatasetsZoo.TWITCH.T470158483,
-            ROCODatasetsZoo.DJI.FINAL,
-            ROCODatasetsZoo.DJI.CENTRAL_CHINA,
-            ROCODatasetsZoo.DJI.NORTH_CHINA,
-            ROCODatasetsZoo.DJI.SOUTH_CHINA,
-        ],
-        [ROCODatasetsZoo.TWITCH.T470149568, ROCODatasetsZoo.TWITCH.T470152289],
-        [ROCODatasetsZoo.TWITCH.T470152838, ROCODatasetsZoo.TWITCH.T470151286],
-    )
-    # train_datasets.append(
-    #     digit_dataset_generator.from_roco_dataset(ROCODatasetsZoo.DJI.FINAL).to_file_images()
-    #     # .cap(2133 + 1764 + 1436)
-    #     # .skip(2133 + 176 + 1436)
-    #     # .cap(5_000)
-    #     .build()
-    # )
-    return train_datasets, validation_datasets, test_datasets
+def make_armor_digit_dataset_generator() -> ArmorValueDatasetGenerator[ArmorDigit]:
+    return ArmorValueDatasetGenerator("digits", ArmorDigitTargetFactory(), ExcludeFilter({ArmorDigit.OUTDATED}))
 
 
 class ArmorDigitTargetFactory(ArmorValueTargetFactory[ArmorDigit]):
diff --git a/polystar_cv/research/robots/armor_digit/scripts/benchmark.py b/polystar_cv/research/robots/armor_digit/scripts/benchmark.py
index 2dde34dbd1f6dd7fd69493ad98cd86361cd41bae..9b349f0164811e1b28e9dfb5a914ba341cc2c0d8 100644
--- a/polystar_cv/research/robots/armor_digit/scripts/benchmark.py
+++ b/polystar_cv/research/robots/armor_digit/scripts/benchmark.py
@@ -2,7 +2,6 @@ import logging
 import warnings
 from pathlib import Path
 
-from polystar.common.constants import PROJECT_DIR
 from polystar.common.pipeline.classification.random_model import RandomClassifier
 from polystar.common.utils.serialization import pkl_load
 from research.common.utils.experiment_dir import prompt_experiment_dir
@@ -34,9 +33,7 @@ if __name__ == "__main__":
     #     input_size=32, logs_dir=_report_dir, dropout=0, lr=0.00021, dense_size=64, model_factory=VGG16
     # )
 
-    _vgg16_pipeline = pkl_load(
-        PROJECT_DIR / "pipelines/armor-digit/20201225_131957_vgg16/VGG16 (32) - lr 2.1e-04 - drop 0.pkl"
-    )
+    _vgg16_pipeline = pkl_load(PIPELINES_DIR / "armor-digit/20201225_131957_vgg16/VGG16 (32) - lr 2.1e-04 - drop 0.pkl")
     _vgg16_pipeline.name = "vgg16_tl"
 
     _distiled_vgg16_into_cnn_pipeline = ArmorDigitKerasPipeline.from_distillation(
diff --git a/polystar_cv/research/robots/armor_digit/scripts/evaluate.py b/polystar_cv/research/robots/armor_digit/scripts/evaluate.py
new file mode 100644
index 0000000000000000000000000000000000000000..39c813414ea7f55250fa009a5dca301614dafa8a
--- /dev/null
+++ b/polystar_cv/research/robots/armor_digit/scripts/evaluate.py
@@ -0,0 +1,61 @@
+from pathlib import Path
+from time import time
+from typing import Iterable
+
+import seaborn
+from matplotlib.pyplot import show, title
+from pandas import DataFrame
+
+from polystar.common.models.image import FileImage, file_images_to_images
+from polystar.common.models.object import ArmorDigit
+from polystar.common.pipeline.classification.classification_pipeline import ClassificationPipeline
+from polystar.common.utils.iterable_utils import chunk
+from polystar.common.utils.serialization import pkl_load
+from research.common.constants import PIPELINES_DIR
+from research.common.datasets.dataset import Dataset
+from research.common.gcloud.gcloud_storage import GCStorages
+from research.common.utils.logs import setup_dev_logs
+from research.robots.armor_digit.armor_digit_dataset import make_armor_digit_dataset_generator
+
+
+def time_digit_pipeline(pipeline_path: Path):
+    seaborn.set()
+    GCStorages.DEV.download_file_if_missing(pipeline_path)
+    pipeline = pkl_load(pipeline_path)
+    test_datasets = make_armor_digit_dataset_generator().default_test_datasets()
+    pipeline.predict_proba_and_classes(file_images_to_images(test_datasets[0].examples[:5]))
+    df = DataFrame(
+        [
+            {"dataset": dataset.name, "time (s)": t, "batch_size": batch_size}
+            for dataset in test_datasets
+            for batch_size in range(1, 16)
+            for t in time_batch_inference(pipeline, dataset, batch_size)
+        ]
+    )
+    seaborn.violinplot(x="batch_size", y="time (s)", data=df, cut=0)
+    title(f"Time inference\n{pipeline.name}")
+    show()
+
+
+def time_batch_inference(
+    pipeline: ClassificationPipeline, dataset: Dataset[FileImage, ArmorDigit], batch_size: int
+) -> Iterable[float]:
+    for file_images in chunk(dataset.examples, batch_size):
+        images = file_images_to_images(file_images)
+        t = time()
+        pipeline.predict_proba_and_classes(images)
+        rv = time() - t
+        if rv > 0.1:
+            print(rv, *[f"file://{f.path}" for f in file_images])
+        yield rv
+
+
+if __name__ == "__main__":
+    setup_dev_logs()
+    time_digit_pipeline(
+        PIPELINES_DIR
+        / "armor-digit/20210117_145856_kd_cnn/distiled - temp 4.1e+01 - cnn - (32) - lr 7.8e-04 - drop 63.pkl"
+    )
+    time_digit_pipeline(
+        PIPELINES_DIR / "armor-digit/20210110_220816_vgg16_full_dset/wrapper (32) - lr 2.1e-04 - drop 0.pkl"
+    )
diff --git a/polystar_cv/research/robots/armor_digit/scripts/hyper_tune_distiled_vgg16_into_cnn.py b/polystar_cv/research/robots/armor_digit/scripts/hyper_tune_distiled_vgg16_into_cnn.py
index 3b035e72dad631fa6437c8e269c0c1cfe6195136..2994f9e3f1153ae3b67ec3b587d2444f0154c19f 100644
--- a/polystar_cv/research/robots/armor_digit/scripts/hyper_tune_distiled_vgg16_into_cnn.py
+++ b/polystar_cv/research/robots/armor_digit/scripts/hyper_tune_distiled_vgg16_into_cnn.py
@@ -4,7 +4,6 @@ from pathlib import Path
 
 from optuna import Trial
 
-from polystar.common.constants import PROJECT_DIR
 from polystar.common.utils.serialization import pkl_load
 from research.common.utils.experiment_dir import make_experiment_dir
 from research.robots.armor_digit.digit_benchmarker import make_default_digit_benchmarker
@@ -14,7 +13,7 @@ from research.robots.evaluation.hyper_tuner import HyperTuner
 
 class DistilledPipelineFactory:
     def __init__(self, teacher_name: str):
-        self.teacher: ArmorDigitKerasPipeline = pkl_load(PROJECT_DIR / "pipelines/armor-digit" / teacher_name)
+        self.teacher: ArmorDigitKerasPipeline = pkl_load(PIPELINES_DIR / "armor-digit" / teacher_name)
 
     def __call__(self, report_dir: Path, trial: Trial) -> ArmorDigitPipeline:
         return ArmorDigitKerasPipeline.from_distillation(
diff --git a/polystar_cv/research/robots/armor_digit/scripts/train_kd_cnn.py b/polystar_cv/research/robots/armor_digit/scripts/train_kd_cnn.py
new file mode 100644
index 0000000000000000000000000000000000000000..13e6b17ccd16642f86a815181bc3c37b08832335
--- /dev/null
+++ b/polystar_cv/research/robots/armor_digit/scripts/train_kd_cnn.py
@@ -0,0 +1,26 @@
+from polystar.common.utils.serialization import pkl_load
+from polystar.common.utils.time import create_time_id
+from research.common.constants import PIPELINES_DIR
+from research.common.utils.logs import setup_dev_logs
+from research.robots.armor_digit.pipeline import ArmorDigitKerasPipeline
+from research.robots.armor_digit.training import train_report_and_upload_digit_pipeline
+
+if __name__ == "__main__":
+    setup_dev_logs()
+
+    _training_dir = PIPELINES_DIR / "armor-digit" / f"{create_time_id()}_kd_cnn"
+
+    _kd_cnn_pipeline = ArmorDigitKerasPipeline.from_distillation(
+        teacher_pipeline=pkl_load(
+            PIPELINES_DIR / "armor-digit/20210110_220816_vgg16_full_dset/wrapper (32) - lr 2.1e-04 - drop 0.pkl"
+        ),
+        conv_blocks=((32, 32), (64, 64)),
+        logs_dir=str(_training_dir),
+        dropout=0.63,
+        lr=0.000776,
+        dense_size=1024,
+        temperature=41.2,
+        verbose=1,
+    )
+
+    train_report_and_upload_digit_pipeline(_kd_cnn_pipeline, training_dir=_training_dir)
diff --git a/polystar_cv/research/robots/armor_digit/scripts/train_vgg16.py b/polystar_cv/research/robots/armor_digit/scripts/train_vgg16.py
index 650aabcf1f444a83ec2f79483c14bd1d7c8d0697..9c54d412a2a3c308676fb6775a2f844cd50274a8 100644
--- a/polystar_cv/research/robots/armor_digit/scripts/train_vgg16.py
+++ b/polystar_cv/research/robots/armor_digit/scripts/train_vgg16.py
@@ -1,21 +1,13 @@
-import logging
-import warnings
-
 from tensorflow.python.keras.applications.vgg16 import VGG16
 
-from polystar.common.constants import PROJECT_DIR
-from polystar.common.utils.serialization import pkl_dump
 from polystar.common.utils.time import create_time_id
-from research.robots.armor_digit.digit_benchmarker import make_default_digit_benchmarker
+from research.common.constants import PIPELINES_DIR
+from research.common.utils.logs import setup_dev_logs
 from research.robots.armor_digit.pipeline import ArmorDigitKerasPipeline
-
-PIPELINES_DIR = PROJECT_DIR / "pipelines"
+from research.robots.armor_digit.training import train_report_and_upload_digit_pipeline
 
 if __name__ == "__main__":
-    logging.getLogger().setLevel("INFO")
-    logging.getLogger("tensorflow").setLevel("ERROR")
-    warnings.filterwarnings("ignore")
-    logging.info("Training vgg16")
+    setup_dev_logs()
 
     _training_dir = PIPELINES_DIR / "armor-digit" / f"{create_time_id()}_vgg16_full_dset"
 
@@ -29,9 +21,4 @@ if __name__ == "__main__":
         verbose=1,
     )
 
-    logging.info(f"Run `tensorboard --logdir={_training_dir}` for realtime logs")
-
-    _benchmarker = make_default_digit_benchmarker(_training_dir)
-    _benchmarker.benchmark([_vgg16_pipeline])
-
-    pkl_dump(_vgg16_pipeline, _training_dir / _vgg16_pipeline.name)
+    train_report_and_upload_digit_pipeline(_vgg16_pipeline, _training_dir)
diff --git a/polystar_cv/research/robots/armor_digit/training.py b/polystar_cv/research/robots/armor_digit/training.py
new file mode 100644
index 0000000000000000000000000000000000000000..c14f5f4a851ba2a899921011e070d40065b26de2
--- /dev/null
+++ b/polystar_cv/research/robots/armor_digit/training.py
@@ -0,0 +1,13 @@
+import pickle
+from pathlib import Path
+
+from research.common.gcloud.gcloud_storage import GCStorages
+from research.robots.armor_digit.digit_benchmarker import make_default_digit_benchmarker
+from research.robots.armor_digit.pipeline import ArmorDigitPipeline
+
+
+def train_report_and_upload_digit_pipeline(pipeline: ArmorDigitPipeline, training_dir: Path):
+    make_default_digit_benchmarker(training_dir).benchmark([pipeline])
+
+    with GCStorages.DEV.open((training_dir / pipeline.name).with_suffix(".pkl"), "wb") as f:
+        pickle.dump(pipeline, f)
diff --git a/polystar_cv/research/robots/dataset/armor_dataset_factory.py b/polystar_cv/research/robots/dataset/armor_dataset_factory.py
index 6a800aefa3a2f2342875b83418756c64473a4170..c14eca1d40b5837ffbb897fd24139ee3794088e7 100644
--- a/polystar_cv/research/robots/dataset/armor_dataset_factory.py
+++ b/polystar_cv/research/robots/dataset/armor_dataset_factory.py
@@ -23,7 +23,7 @@ class ArmorDataset(LazyDataset[Image, Armor]):
 
     @staticmethod
     def _generate_from_single(image: Image, annotation: ROCOAnnotation, name) -> Iterator[Tuple[Image, Armor, str]]:
-        armors: List[Armor] = TypeObjectValidator(ObjectType.Armor).filter(annotation.objects, image)
+        armors: List[Armor] = TypeObjectValidator(ObjectType.ARMOR).filter(annotation.objects, image)
 
         for i, obj in enumerate(armors):
             croped_img = image[obj.box.y1 : obj.box.y2, obj.box.x1 : obj.box.x2]
diff --git a/polystar_cv/research/robots/dataset/armor_value_dataset_generator.py b/polystar_cv/research/robots/dataset/armor_value_dataset_generator.py
index a296d9c814af80914d5ba8e8ddc787221559fb41..f90fd6c4c32679b883034338931f3cd233cb49f7 100644
--- a/polystar_cv/research/robots/dataset/armor_value_dataset_generator.py
+++ b/polystar_cv/research/robots/dataset/armor_value_dataset_generator.py
@@ -1,14 +1,16 @@
 from pathlib import Path
-from typing import Generic, Iterable, List
+from typing import Generic, Iterable, List, Tuple
 
 from polystar.common.filters.exclude_filter import ExcludeFilter
 from polystar.common.filters.filter_abc import FilterABC
 from polystar.common.filters.pass_through_filter import PassThroughFilter
+from polystar.common.models.image import FileImage
 from research.common.dataset.cleaning.dataset_changes import DatasetChanges
-from research.common.datasets.image_dataset import FileImageDataset
+from research.common.datasets.dataset import Dataset
 from research.common.datasets.image_file_dataset_builder import DirectoryDatasetBuilder
 from research.common.datasets.lazy_dataset import TargetT
 from research.common.datasets.roco.roco_dataset_builder import ROCODatasetBuilder
+from research.common.datasets.roco.zoo.roco_dataset_zoo import ROCODatasetsZoo
 from research.robots.dataset.armor_value_dataset_cache import ArmorValueDatasetCache
 from research.robots.dataset.armor_value_target_factory import ArmorValueTargetFactory
 
@@ -29,15 +31,31 @@ class ArmorValueDatasetGenerator(Generic[TargetT]):
         self.task_name = task_name
         self.targets_filter = targets_filter or PassThroughFilter()
 
-    # FIXME signature inconsistency across methods
-    def from_roco_datasets(
-        self, *roco_datasets_list: List[ROCODatasetBuilder]
-    ) -> Iterable[List[FileImageDataset[TargetT]]]:
+    def default_datasets(
+        self, include_dji: bool = True
+    ) -> Tuple[List[Dataset[FileImage, TargetT]], List[Dataset[FileImage, TargetT]], List[Dataset[FileImage, TargetT]]]:
         return (
-            [self.from_roco_dataset(roco_dataset).to_file_images().build() for roco_dataset in roco_datasets]
-            for roco_datasets in roco_datasets_list
+            self.default_train_datasets() if include_dji else self.twitch_train_datasets(),
+            self.default_validation_datasets(),
+            self.default_test_datasets(),
         )
 
+    def default_test_datasets(self) -> List[Dataset[FileImage, TargetT]]:
+        return self.from_roco_datasets(ROCODatasetsZoo.DEFAULT_TEST_DATASETS)
+
+    def default_validation_datasets(self) -> List[Dataset[FileImage, TargetT]]:
+        return self.from_roco_datasets(ROCODatasetsZoo.DEFAULT_VALIDATION_DATASETS)
+
+    def default_train_datasets(self) -> List[Dataset[FileImage, TargetT]]:
+        return self.from_roco_datasets(ROCODatasetsZoo.DEFAULT_TRAIN_DATASETS)
+
+    def twitch_train_datasets(self) -> List[Dataset[FileImage, TargetT]]:
+        return self.from_roco_datasets(ROCODatasetsZoo.TWITCH_TRAIN_DATASETS)
+
+    # FIXME signature inconsistency across methods
+    def from_roco_datasets(self, roco_datasets: Iterable[ROCODatasetBuilder]) -> List[Dataset[FileImage, TargetT]]:
+        return [self.from_roco_dataset(roco_dataset).to_file_images().build() for roco_dataset in roco_datasets]
+
     def from_roco_dataset(self, roco_dataset_builder: ROCODatasetBuilder) -> DirectoryDatasetBuilder[TargetT]:
         cache_dir = roco_dataset_builder.main_dir / self.task_name
         dataset_name = roco_dataset_builder.name
diff --git a/polystar_cv/research/robots/demos/demo_infer.py b/polystar_cv/research/robots/demos/demo_infer.py
deleted file mode 100644
index 51c1cc2258d9ec00b2e680ff2dd01dbb618f2ae0..0000000000000000000000000000000000000000
--- a/polystar_cv/research/robots/demos/demo_infer.py
+++ /dev/null
@@ -1,24 +0,0 @@
-from polystar.common.dependency_injection import make_injector
-from polystar.common.models.label_map import LabelMap
-from polystar.common.target_pipeline.detected_objects.detected_objects_factory import DetectedObjectFactory
-from polystar.common.target_pipeline.objects_detectors.tf_model_objects_detector import TFModelObjectsDetector
-from polystar.common.target_pipeline.objects_validators.confidence_object_validator import ConfidenceObjectValidator
-from polystar.common.utils.tensorflow import patch_tf_v2
-from polystar.common.view.plt_results_viewer import PltResultViewer
-from research.common.datasets.roco.zoo.roco_dataset_zoo import ROCODatasetsZoo
-from research.robots.demos.utils import load_tf_model
-
-if __name__ == "__main__":
-    patch_tf_v2()
-    injector = make_injector()
-
-    objects_detector = TFModelObjectsDetector(DetectedObjectFactory(injector.get(LabelMap), []), load_tf_model())
-    filters = [ConfidenceObjectValidator(confidence_threshold=0.5)]
-
-    with PltResultViewer("Demo of tf model") as viewer:
-        for image, _, _ in ROCODatasetsZoo.DJI.CENTRAL_CHINA.to_images().cap(5):
-            objects = objects_detector.detect(image)
-            for f in filters:
-                objects = f.filter(objects, image)
-
-            viewer.display_image_with_objects(image, objects)
diff --git a/polystar_cv/research/robots/demos/demo_pipeline.py b/polystar_cv/research/robots/demos/demo_pipeline.py
index ff5475b56724deabb5acb4d468ae92f763336e97..44fbbbebfd3460ac59408d7b92be95f994711692 100644
--- a/polystar_cv/research/robots/demos/demo_pipeline.py
+++ b/polystar_cv/research/robots/demos/demo_pipeline.py
@@ -1,51 +1,33 @@
-import cv2
+from injector import inject
 
-from polystar.common.communication.print_target_sender import PrintTargetSender
 from polystar.common.dependency_injection import make_injector
-from polystar.common.models.camera import Camera
-from polystar.common.models.label_map import LabelMap
-from polystar.common.target_pipeline.armors_descriptors.armors_color_descriptor import ArmorsColorDescriptor
 from polystar.common.target_pipeline.debug_pipeline import DebugTargetPipeline
-from polystar.common.target_pipeline.detected_objects.detected_objects_factory import DetectedObjectFactory
-from polystar.common.target_pipeline.object_selectors.closest_object_selector import ClosestObjectSelector
-from polystar.common.target_pipeline.objects_detectors.tf_model_objects_detector import TFModelObjectsDetector
-from polystar.common.target_pipeline.objects_linker.simple_objects_linker import SimpleObjectsLinker
-from polystar.common.target_pipeline.objects_validators.confidence_object_validator import ConfidenceObjectValidator
-from polystar.common.target_pipeline.target_factories.ratio_simple_target_factory import RatioSimpleTargetFactory
+from polystar.common.target_pipeline.objects_validators.armor_digit_validator import ArmorDigitValidator
 from polystar.common.target_pipeline.target_pipeline import NoTargetFoundException
-from polystar.common.utils.tensorflow import patch_tf_v2
 from polystar.common.view.plt_results_viewer import PltResultViewer
+from research.common.datasets.roco.roco_annotation_filters.roco_annotation_object_filter import (
+    ROCOAnnotationObjectFilter,
+)
 from research.common.datasets.roco.zoo.roco_dataset_zoo import ROCODatasetsZoo
-from research.robots.armor_color.pipeline import ArmorColorPipeline
-from research.robots.armor_color.scripts.benchmark import MeanChannels, RedBlueComparisonClassifier
-from research.robots.demos.utils import load_tf_model
 
-if __name__ == "__main__":
-    patch_tf_v2()
-    injector = make_injector()
-
-    pipeline = DebugTargetPipeline(
-        objects_detector=TFModelObjectsDetector(
-            DetectedObjectFactory(
-                injector.get(LabelMap),
-                [ArmorsColorDescriptor(ArmorColorPipeline.from_pipes([MeanChannels(), RedBlueComparisonClassifier()]))],
-            ),
-            load_tf_model(),
-        ),
-        objects_validators=[ConfidenceObjectValidator(0.6)],
-        object_selector=ClosestObjectSelector(),
-        target_factory=RatioSimpleTargetFactory(injector.get(Camera), 300, 100),
-        target_sender=PrintTargetSender(),
-        objects_linker=SimpleObjectsLinker(min_percentage_intersection=0.8),
-    )
 
+@inject
+def demo_pipeline_on_images(pipeline: DebugTargetPipeline):
     with PltResultViewer("Demo of tf model") as viewer:
-        for builder in (ROCODatasetsZoo.TWITCH.T470150052, ROCODatasetsZoo.DJI.CENTRAL_CHINA):
-            for image_path, _, _ in builder.cap(5):
+        for builder in ROCODatasetsZoo.DEFAULT_TEST_DATASETS:
+            for image in (
+                builder.to_images()
+                .filter_targets(ROCOAnnotationObjectFilter(ArmorDigitValidator((1, 3, 4))))
+                .shuffle()
+                .cap(15)
+                .build_examples()
+            ):
                 try:
-                    image = cv2.cvtColor(cv2.imread(str(image_path)), cv2.COLOR_BGR2RGB)
-                    target = pipeline.predict_target(image)
+                    pipeline.predict_target(image)
                 except NoTargetFoundException:
                     pass
-                finally:
-                    viewer.display_debug_info(pipeline.debug_info_)
+                viewer.display_debug_info(pipeline.debug_info_)
+
+
+if __name__ == "__main__":
+    make_injector().call_with_injection(demo_pipeline_on_images)
diff --git a/polystar_cv/research/robots/demos/demo_pipeline_camera.py b/polystar_cv/research/robots/demos/demo_pipeline_camera.py
index 660e09e9620c79f37a6733b34c942da9b916531f..dbddb53a36b08b190e02168282228e06b1d14482 100644
--- a/polystar_cv/research/robots/demos/demo_pipeline_camera.py
+++ b/polystar_cv/research/robots/demos/demo_pipeline_camera.py
@@ -1,60 +1,31 @@
-import sys
-from time import time
+from injector import inject
 
-import pycuda.autoinit  # This is needed for initializing CUDA driver
-
-from polystar.common.communication.file_descriptor_target_sender import FileDescriptorTargetSender
-from polystar.common.constants import MODELS_DIR
 from polystar.common.dependency_injection import make_injector
-from polystar.common.frame_generators.camera_frame_generator import CameraFrameGenerator
-from polystar.common.models.camera import Camera
-from polystar.common.models.label_map import LabelMap
-from polystar.common.models.object import ObjectType
-from polystar.common.models.trt_model import TRTModel
+from polystar.common.frame_generators.frames_generator_abc import FrameGeneratorABC
 from polystar.common.target_pipeline.debug_pipeline import DebugTargetPipeline
-from polystar.common.target_pipeline.object_selectors.closest_object_selector import ClosestObjectSelector
-from polystar.common.target_pipeline.objects_detectors.trt_model_object_detector import TRTModelObjectsDetector
-from polystar.common.target_pipeline.objects_validators.confidence_object_validator import ConfidenceObjectValidator
-from polystar.common.target_pipeline.objects_validators.type_object_validator import TypeObjectValidator
-from polystar.common.target_pipeline.target_factories.ratio_simple_target_factory import RatioSimpleTargetFactory
-from polystar.common.utils.tensorflow import patch_tf_v2
+from polystar.common.target_pipeline.target_pipeline import NoTargetFoundException
+from polystar.common.utils.fps import FPS
 from polystar.common.view.cv2_results_viewer import CV2ResultViewer
-from polystar.robots_at_robots.globals import settings
-
-[pycuda.autoinit]  # So pycharm won't remove the import
-
 
-if __name__ == "__main__":
-    patch_tf_v2()
-    injector = make_injector()
-
-    objects_detector = TRTModelObjectsDetector(
-        TRTModel(MODELS_DIR / settings.MODEL_NAME, (300, 300)), injector.get(LabelMap)
-    )
-    pipeline = DebugTargetPipeline(
-        objects_detector=objects_detector,
-        objects_validators=[ConfidenceObjectValidator(0.6), TypeObjectValidator(ObjectType.Armor)],
-        object_selector=ClosestObjectSelector(),
-        target_factory=RatioSimpleTargetFactory(injector.get(Camera), 300, 100),
-        target_sender=FileDescriptorTargetSender(int(sys.argv[1])),
-    )
 
-    fps = 0
+@inject
+def demo_pipeline_on_camera(pipeline: DebugTargetPipeline, webcam: FrameGeneratorABC):
+    fps, pipeline_fps = FPS(), FPS()
     with CV2ResultViewer("TensorRT demo") as viewer:
-        for image in CameraFrameGenerator(1_280, 720).generate():
-
-            previous_time = time()
-
-            # inference
-            pipeline.predict_target(image)
-
-            # display
-            fps = 0.9 * fps + 0.1 / (time() - previous_time)
-            viewer.new(image)
-            viewer.add_objects(pipeline.debug_info_.validated_objects, forced_color=(0.6, 0.6, 0.6))
-            viewer.add_object(pipeline.debug_info_.selected_object)
-            viewer.add_text(f"FPS: {fps:.1f}", 10, 10, (0, 0, 0))
+        for image in webcam.generate():
+            pipeline_fps.skip()
+            try:
+                pipeline.predict_target(image)
+            except NoTargetFoundException:
+                pass
+            pipeline_fps.tick(), fps.tick()
+            viewer.add_debug_info(pipeline.debug_info_)
+            viewer.add_text(f"FPS: {fps:.1f} / {pipeline_fps:.1f}", 10, 10, (0, 0, 0))
             viewer.display()
-
+            fps.skip()
             if viewer.finished:
-                break
+                return
+
+
+if __name__ == "__main__":
+    make_injector().call_with_injection(demo_pipeline_on_camera)
diff --git a/polystar_cv/research/robots/demos/utils.py b/polystar_cv/research/robots/demos/utils.py
deleted file mode 100644
index 9b103e5e8f62e3d0619ae55e597499355bee559f..0000000000000000000000000000000000000000
--- a/polystar_cv/research/robots/demos/utils.py
+++ /dev/null
@@ -1,15 +0,0 @@
-import tensorflow as tf
-
-from polystar.common.constants import MODELS_DIR
-
-
-def load_tf_model():
-    model = tf.saved_model.load(
-        export_dir=str(
-            MODELS_DIR
-            / "robots"
-            / "ssd_mobilenet_v2_roco_2018_03_29_20200314_015411_TWITCH_TEMP_733_IMGS_29595steps"
-            / "saved_model"
-        )
-    )
-    return model.signatures["serving_default"]
diff --git a/polystar_cv/research/robots/evaluation/benchmarker.py b/polystar_cv/research/robots/evaluation/benchmarker.py
index 0c3123faaefd550caf4097e28e8dbf543a7a9394..d9fc462e751c2fed6584dae2bd8f23412097f416 100644
--- a/polystar_cv/research/robots/evaluation/benchmarker.py
+++ b/polystar_cv/research/robots/evaluation/benchmarker.py
@@ -1,7 +1,8 @@
 import logging
 from dataclasses import dataclass
+from itertools import chain
 from pathlib import Path
-from typing import List
+from typing import List, Sequence
 
 from polystar.common.pipeline.classification.classification_pipeline import ClassificationPipeline
 from research.common.datasets.image_dataset import FileImageDataset
@@ -40,10 +41,10 @@ class Benchmarker:
         return pipeline_performances
 
     def benchmark(
-        self, pipelines: List[ClassificationPipeline], trained_pipelines: List[ClassificationPipeline] = None
+        self, pipelines: Sequence[ClassificationPipeline] = (), trained_pipelines: Sequence[ClassificationPipeline] = ()
     ):
         self.trainer.train_pipelines(pipelines)
-        self.performances += self.evaluator.evaluate_pipelines(pipelines + (trained_pipelines or []))
+        self.performances += self.evaluator.evaluate_pipelines(chain(pipelines, trained_pipelines))
         self.make_report()
 
     def make_report(self):
diff --git a/polystar_cv/research/robots/evaluation/evaluator.py b/polystar_cv/research/robots/evaluation/evaluator.py
index d03c1c9ec6b157c5c0c71b896d2367e6e15f4420..8bd591e7321026de8edad582ae6721d01704fe17 100644
--- a/polystar_cv/research/robots/evaluation/evaluator.py
+++ b/polystar_cv/research/robots/evaluation/evaluator.py
@@ -39,8 +39,9 @@ class ImageClassificationPipelineEvaluator(Generic[TargetT]):
         self, pipeline: ClassificationPipeline, set_: Set
     ) -> Iterable[ContextualizedClassificationPerformance]:
         for dataset in self.set2datasets[set_]:
+            images = file_images_to_images(dataset.examples)
             t = time()
-            proba, classes = pipeline.predict_proba_and_classes(file_images_to_images(dataset.examples))
+            proba, classes = pipeline.predict_proba_and_classes(images)
             mean_time = (time() - t) / len(dataset)
             yield ContextualizedClassificationPerformance(
                 examples=dataset.examples,
diff --git a/polystar_cv/research/robots/evaluation/trainer.py b/polystar_cv/research/robots/evaluation/trainer.py
index 6f5940bb0ab56abc2ce15c8b81f932e30971d1f2..2cc64f648c0a424149ee5a95c250dd2e5ffd555e 100644
--- a/polystar_cv/research/robots/evaluation/trainer.py
+++ b/polystar_cv/research/robots/evaluation/trainer.py
@@ -1,4 +1,4 @@
-from typing import Generic, List
+from typing import Generic, Iterable, List
 
 from tqdm import tqdm
 
@@ -19,7 +19,7 @@ class ImageClassificationPipelineTrainer(Generic[TargetT]):
     def train_pipeline(self, pipeline: ClassificationPipeline):
         pipeline.fit(self.images, self.labels, validation_size=self.validation_size)
 
-    def train_pipelines(self, pipelines: List[ClassificationPipeline]):
+    def train_pipelines(self, pipelines: Iterable[ClassificationPipeline]):
         tqdm_pipelines = tqdm(pipelines, desc="Training Pipelines")
         for pipeline in tqdm_pipelines:
             tqdm_pipelines.set_postfix({"pipeline": pipeline.name}, True)
diff --git a/pyproject.toml b/pyproject.toml
index 82b631031aeb07f1dfbac085901ba6fa997fef3e..48c1e6876b7c863e42f3a90e4cdad54218ff33be 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -35,8 +35,9 @@ pyyaml = "^5.3.1"
 six = "1.15.0"  # https://github.com/googleapis/python-bigquery/issues/70
 
 [tool.poetry.dev-dependencies]
-tensorflow = "2.3.x"
-tensorflow-estimator = "2.3.x"
+tensorflow = "1.14.x"
+tensorflow-estimator = "1.14.x"
+h5py = "<3.0.0"
 kivy = "^1.11.1"
 cloudml-hypertune = "^0.1.0-alpha.6"
 google-api-python-client = "^1.12.8"