From ca947434f8de799e3df5c04ebdf79e4c5511a666 Mon Sep 17 00:00:00 2001 From: matteo porta Date: Tue, 21 Jun 2022 14:10:52 +0200 Subject: [PATCH] vision wip --- config/machine_settings/defaults.ini | 15 + config/vision/labels/labels.pbtxt | 75 +++ config/vision/recipes/1.json | 34 ++ config/vision/recipes/2.json | 34 ++ config/vision/recipes/3.json | 34 ++ config/vision/recipes/4.json | 34 ++ config/vision/recipes/autotest_nok.json | 34 ++ config/vision/recipes/autotest_ok.json | 34 ++ init.sh | 54 +- simulate.sh | 4 + src/components/__init__.py | 2 + src/components/component.py | 61 +- src/components/dummies/gxpy/__init__.py | 1 + src/components/dummies/gxpy/dummy_camera.py | 106 ++++ src/components/galaxy_camera.py | 249 ++++++++ src/components/renderer.py | 550 ++++++++++++++++++ src/components/terminals.py | 171 ++++++ src/components/vision.py | 338 +++++++++++ src/components/wires.py | 172 ++++++ .../protos/string_int_label_map.proto | 61 ++ .../protos/string_int_label_map_pb2.py | 258 ++++++++ .../object_detection/utils/label_map_util.py | 366 ++++++++++++ src/main.py | 24 +- src/requirements.txt | 4 + src/ui/imgs/camera.png | Bin 0 -> 53845 bytes 25 files changed, 2700 insertions(+), 15 deletions(-) create mode 100644 config/vision/labels/labels.pbtxt create mode 100644 config/vision/recipes/1.json create mode 100644 config/vision/recipes/2.json create mode 100644 config/vision/recipes/3.json create mode 100644 config/vision/recipes/4.json create mode 100644 config/vision/recipes/autotest_nok.json create mode 100644 config/vision/recipes/autotest_ok.json create mode 100644 src/components/dummies/gxpy/__init__.py create mode 100644 src/components/dummies/gxpy/dummy_camera.py create mode 100644 src/components/galaxy_camera.py create mode 100644 src/components/renderer.py create mode 100644 src/components/terminals.py create mode 100644 src/components/vision.py create mode 100644 src/components/wires.py create mode 100644 src/lib/helpers/object_detection/protos/string_int_label_map.proto create mode 100644 src/lib/helpers/object_detection/protos/string_int_label_map_pb2.py create mode 100644 src/lib/helpers/object_detection/utils/label_map_util.py create mode 100644 src/ui/imgs/camera.png diff --git a/config/machine_settings/defaults.ini b/config/machine_settings/defaults.ini index a2baf11..fb41a5f 100644 --- a/config/machine_settings/defaults.ini +++ b/config/machine_settings/defaults.ini @@ -1,6 +1,17 @@ [test] parameter: default +[galaxy_camera] +exposure time: 10000 +horizontal crop offset: 0 +horizontal crop resolution: 2448 +vertical crop offset: 724 +vertical crop resolution: 800 +rotate 90 clockwise times: 0 +balance red: 1.0 +balance green: 1.0 +balance blue: 1.0 + [vision_saver] time_format: %Y-%m-%d_%H-%M-%S path: ./data/images @@ -22,3 +33,7 @@ printer: [tecna_marposs_provaset_t3] address: COM3 baudrate: 115200 + +[vision] +detection_threshold: 0.3 +; recipes_path: ./config/vision_test_recipes diff --git a/config/vision/labels/labels.pbtxt b/config/vision/labels/labels.pbtxt new file mode 100644 index 0000000..07ba4d2 --- /dev/null +++ b/config/vision/labels/labels.pbtxt @@ -0,0 +1,75 @@ +item { + id: 1 + name: 'red_big' + color: 'rgb(255,0,0)' +} +item { + id: 2 + name: 'black_big' + color: 'rgb(50, 50, 50)' +} +item { + id: 3 + name: 'blue_big' + color: 'rgb(0, 0, 255)' +} +item { + id: 4 + name: 'white_big' + color: 'rgb(255, 255, 255)' +} +item { + id: 5 + name: 'green_big' + color: 'rgb(0, 255, 0)' +} +item { + id: 6 + name: 'yellow_big' + color: 'rgb(255, 255, 0)' +} +item { + id: 7 + name: 'orange_big' + color: 'rgb(255, 145, 0)' +} +item { + id: 8 + name: 'brown_big' + color: 'rgb(102, 40, 13)' +} +item { + id: 9 + name: 'red_small' + color: 'rgb(225, 0, 0)' +} +item { + id: 10 + name: 'black_small' + color: 'rgb(70, 70, 70)' +} +item { + id: 11 + name: 'blue_small' + color: 'rgb(0, 0, 225)' +} +item { + id: 12 + name: 'white_small' + color: 'rgb(225, 225, 225)' +} +item { + id: 13 + name: 'green_small' + color: 'rgb(0, 225, 0)' +} +item { + id: 14 + name: 'yellow_small' + color: 'rgb(225, 225, 0)' +} +item { + id: 15 + name: 'ko' + color: 'rgb(255, 0, 255)' +} diff --git a/config/vision/recipes/1.json b/config/vision/recipes/1.json new file mode 100644 index 0000000..392e2bd --- /dev/null +++ b/config/vision/recipes/1.json @@ -0,0 +1,34 @@ +{ + "terminals": { + "t1_1": "ok", + "t1_2": "ok", + "t1_3": "ok", + "t1_4": "ok", + "t1_5": "ok", + + "t2_1": "ok", + "t2_2": "ok", + "t2_3": "ok", + "t2_4": "ok", + "t2_5": "ok", + "t2_6": "ok", + "t2_7": "ok", + "t2_8": "ok" + }, + "wires": { + "w1_1": "red_big", + "w1_2": "black_big", + "w1_3": "white_big", + "w1_4": "green_big", + "w1_5": "yellow_big", + + "w2_1": "brown_big", + "w2_2": "orange_big", + "w2_3": "blue_small", + "w2_4": "yellow_small", + "w2_5": "green_small", + "w2_6": "white_small", + "w2_7": "black_small", + "w2_8": "red_small" + } +} \ No newline at end of file diff --git a/config/vision/recipes/2.json b/config/vision/recipes/2.json new file mode 100644 index 0000000..bd14600 --- /dev/null +++ b/config/vision/recipes/2.json @@ -0,0 +1,34 @@ +{ + "terminals": { + "t1_1": "ok", + "t1_2": "ok", + "t1_3": "empty", + "t1_4": "ok", + "t1_5": "ok", + + "t2_1": "ok", + "t2_2": "ok", + "t2_3": "ok", + "t2_4": "ok", + "t2_5": "ok", + "t2_6": "ok", + "t2_7": "ok", + "t2_8": "ok" + }, + "wires": { + "w1_1": "red_big", + "w1_2": "black_big", + "w1_3": "no_detection", + "w1_4": "blue_big", + "w1_5": "white_big", + + "w2_1": "brown_big", + "w2_2": "orange_big", + "w2_3": "blue_small", + "w2_4": "yellow_small", + "w2_5": "green_small", + "w2_6": "white_small", + "w2_7": "black_small", + "w2_8": "red_small" + } +} \ No newline at end of file diff --git a/config/vision/recipes/3.json b/config/vision/recipes/3.json new file mode 100644 index 0000000..9d29e3c --- /dev/null +++ b/config/vision/recipes/3.json @@ -0,0 +1,34 @@ +{ + "terminals": { + "t1_1": "ok", + "t1_2": "ok", + "t1_3": "empty", + "t1_4": "ok", + "t1_5": "ok", + + "t2_1": "ok", + "t2_2": "ok", + "t2_3": "ok", + "t2_4": "ok", + "t2_5": "ok", + "t2_6": "ok", + "t2_7": "ok", + "t2_8": "ok" + }, + "wires": { + "w1_1": "red_big", + "w1_2": "black_big", + "w1_3": "no_detection", + "w1_4": "green_big", + "w1_5": "yellow_big", + + "w2_1": "brown_big", + "w2_2": "orange_big", + "w2_3": "blue_small", + "w2_4": "yellow_small", + "w2_5": "green_small", + "w2_6": "white_small", + "w2_7": "black_small", + "w2_8": "red_small" + } +} \ No newline at end of file diff --git a/config/vision/recipes/4.json b/config/vision/recipes/4.json new file mode 100644 index 0000000..2f88853 --- /dev/null +++ b/config/vision/recipes/4.json @@ -0,0 +1,34 @@ +{ + "terminals": { + "t1_1": "ok", + "t1_2": "ok", + "t1_3": "empty", + "t1_4": "ok", + "t1_5": "ok", + + "t2_1": "ok", + "t2_2": "ok", + "t2_3": "ok", + "t2_4": "ok", + "t2_5": "ok", + "t2_6": "ok", + "t2_7": "ok", + "t2_8": "ok" + }, + "wires": { + "w1_1": "blue_big", + "w1_2": "white_big", + "w1_3": "no_detection", + "w1_4": "black_big", + "w1_5": "red_big", + + "w2_1": "brown_big", + "w2_2": "orange_big", + "w2_3": "blue_small", + "w2_4": "yellow_small", + "w2_5": "green_small", + "w2_6": "white_small", + "w2_7": "black_small", + "w2_8": "red_small" + } +} \ No newline at end of file diff --git a/config/vision/recipes/autotest_nok.json b/config/vision/recipes/autotest_nok.json new file mode 100644 index 0000000..8191c97 --- /dev/null +++ b/config/vision/recipes/autotest_nok.json @@ -0,0 +1,34 @@ +{ + "terminals": { + "t1_1": "ok", + "t1_2": "ok", + "t1_3": "empty", + "t1_4": "ko", + "t1_5": "ok", + + "t2_1": "ok", + "t2_2": "ko", + "t2_3": "ok", + "t2_4": "ok", + "t2_5": "ok", + "t2_6": "ko", + "t2_7": "ok", + "t2_8": "ok" + }, + "wires": { + "w1_1": "black_big", + "w1_2": "red_big", + "w1_3": "no_detection", + "w1_4": "white_big", + "w1_5": "blue_big", + + "w2_1": "orange_big", + "w2_2": "brown_big", + "w2_3": "yellow_small", + "w2_4": "blue_small", + "w2_5": "white_small", + "w2_6": "green_small", + "w2_7": "red_small", + "w2_8": "black_small" + } +} \ No newline at end of file diff --git a/config/vision/recipes/autotest_ok.json b/config/vision/recipes/autotest_ok.json new file mode 100644 index 0000000..bd14600 --- /dev/null +++ b/config/vision/recipes/autotest_ok.json @@ -0,0 +1,34 @@ +{ + "terminals": { + "t1_1": "ok", + "t1_2": "ok", + "t1_3": "empty", + "t1_4": "ok", + "t1_5": "ok", + + "t2_1": "ok", + "t2_2": "ok", + "t2_3": "ok", + "t2_4": "ok", + "t2_5": "ok", + "t2_6": "ok", + "t2_7": "ok", + "t2_8": "ok" + }, + "wires": { + "w1_1": "red_big", + "w1_2": "black_big", + "w1_3": "no_detection", + "w1_4": "blue_big", + "w1_5": "white_big", + + "w2_1": "brown_big", + "w2_2": "orange_big", + "w2_3": "blue_small", + "w2_4": "yellow_small", + "w2_5": "green_small", + "w2_6": "white_small", + "w2_7": "black_small", + "w2_8": "red_small" + } +} \ No newline at end of file diff --git a/init.sh b/init.sh index dfecb4a..628078e 100755 --- a/init.sh +++ b/init.sh @@ -6,10 +6,56 @@ cd "$here" echo "---------- initialize venv ----------" lsof "./venv/bin/python" | awk 'NR > 1 {print $2}' | xargs kill || : lsof "./venv/Scripts/activate" | awk 'NR > 1 {print $2}' | xargs kill || : -python -m pip install --upgrade pip -python -m venv venv +python="python3.9" +"${python}" -m pip install --upgrade pip +"${python}" -m venv venv source "./venv/bin/activate" || source "./venv/Scripts/activate" || : -python -m pip install --upgrade pip -python -m pip install --upgrade -r "src/requirements.txt" +"${python}" -m pip install --upgrade pip +"${python}" -m pip install --upgrade -r "src/requirements.txt" +# echo "---------- get updated label-map-util ----------" +# wget "https://raw.githubusercontent.com/tensorflow/models/master/research/object_detection/utils/label_map_util.py" -O "./src/lib/helpers/object_detection/utils/label_map_util.py" +# sed -Ei "s/^(\s*from )(object_detection.protos import .*)$/\1lib.helpers.\2/" "./src/lib/helpers/object_detection/utils/label_map_util.py" +# sudo apt-get install -y protobuf-compiler +# wget "https://raw.githubusercontent.com/tensorflow/models/master/research/object_detection/protos/string_int_label_map.proto" -O "./src/lib/helpers/object_detection/protos/string_int_label_map.proto" +# insert="\n\1\/\/ Label color for rendering.\n\1optional string color = 9;" +# sed -Ei "s/^(\s*)(optional string display_name.*)$/\1\2\n${insert}\n/" "./src/lib/helpers/object_detection/protos/string_int_label_map.proto" +# protoc "./src/lib/helpers/object_detection/protos/"*.proto --python_out="." +# echo "---------- install libedgetpu ----------" +# # sudo apt-get install -y apt-transport-https curl gnupg +# # curl -fsSL https://bazel.build/bazel-release.pub.gpg | gpg --dearmor >bazel-archive-keyring.gpg +# # sudo mv bazel-archive-keyring.gpg /usr/share/keyrings +# # echo "deb [arch=amd64 signed-by=/usr/share/keyrings/bazel-archive-keyring.gpg] https://storage.googleapis.com/bazel-apt stable jdk1.8" | sudo tee /etc/apt/sources.list.d/bazel.list +# # sudo apt-get update +# sudo apt install -y build-essential docker # bazel libusb-1.0-0-dev libabsl-dev libflatbuffers-dev +# mkdir -p "$here/tmp" +# cd "$here/tmp" +# # git clone https://github.com/tensorflow/tensorflow || : +# # cd tensorflow +# # git pull +# # cd .. +# git clone "https://github.com/google-coral/libedgetpu" || : +# cd libedgetpu +# git pull +# DOCKER_IMAGE="ubuntu:18.04" DOCKER_TARGETS=libedgetpu make docker-build +# sudo make install +# cd "$here" +echo "---------- install gxlpy ----------" +sudo apt-get install -y g++ libc-bin +mkdir -p "$here/tmp" +cd "$here/tmp" +wget --continue --timestamping "http://downloads.get-cameras.com/Galaxy_Linux-x86_Gige-U3_32bits-64bits_1.2.1911.9122.tar.gz" +7z x -y "Galaxy_Linux-x86_Gige-U3_32bits-64bits_1.2.1911.9122.tar.gz" +7z x -y "Galaxy_Linux-x86_Gige-U3_32bits-64bits_1.2.1911.9122.tar" +cd "Galaxy_Linux-x86_Gige-U3_32bits-64bits_1.2.1911.9122" +chmod +x "Galaxy_camera.run" +echo -en "\nY\nY\nEn\nY\n" | ./Galaxy_camera.run +cd "$here/tmp" +wget --continue --timestamping "http://downloads.get-cameras.com/Galaxy_Linux_Python_1.0.1905.9081.tar.gz" +7z x -y "Galaxy_Linux_Python_1.0.1905.9081.tar.gz" +7z x -y "Galaxy_Linux_Python_1.0.1905.9081.tar" +cd "Galaxy_Linux_Python_1.0.1905.9081/api" +python3 setup.py build +python3 setup.py install +cd "$here" cd "$here" diff --git a/simulate.sh b/simulate.sh index d83ddf3..b88ae80 100755 --- a/simulate.sh +++ b/simulate.sh @@ -15,6 +15,10 @@ export QT_NO_WARNING_OUTPUT=0 python -B -u "./src/main.py" \ --auto-login-admin \ --auto-select \ +--camera-edits \ +--no-edgetpu \ +--no-tflite \ +--sim-camera \ --sim-modbus \ --sim-os-label-printer \ --style windows \ diff --git a/src/components/__init__.py b/src/components/__init__.py index 257932d..ce01fe3 100644 --- a/src/components/__init__.py +++ b/src/components/__init__.py @@ -1,8 +1,10 @@ from .archive_synchronizer import ArchiveSynchronizer +from .galaxy_camera import GalaxyCamera from .modbus_component import ModbusComponent from .os_label_printer import Os_Label_Printer from .remote_api import RemoteAPI from .serial_label_printer import Serial_Label_Printer from .tecna_marposs_provaset_t3 import TecnaMarpossProvasetT3 from .test_component import TestComponent +from .vision import Vision from .vision_saver import VisionSaver diff --git a/src/components/component.py b/src/components/component.py index 63a5b32..8806b4a 100644 --- a/src/components/component.py +++ b/src/components/component.py @@ -5,6 +5,7 @@ from PyQt5.QtCore import QObject, QSemaphore, Qt, QTimer, pyqtSignal class Component(QObject): + """emitted with data from the _get method""" out = pyqtSignal(list) _pause = pyqtSignal() _resume = pyqtSignal() @@ -15,11 +16,20 @@ class Component(QObject): self, config=None, name=None, - period=None, # period to call _get - lazy=True, # whether or not accumulate periodic _get calls if falling behind + period=None, + lazy=True, paused=False, threaded=True, ): + """ + parameters: + config: value for self.config, should be instance of lib.helpers.config_reader.ConfigReader + name: value for self.name, should be used to retrive component configuration section (self.config[self.name]) + period: period in seconds for periodic calls to _get, set to None to disable + lazy: whether or not skip periodic _get calls if falling behind + paused: whether or not periodic calls to_get are paused + threaded: set this to tell the component if it should be thread synchronized or not(shoul be true if calls to methods are done from threads different from the component's one) + """ super().__init__() self.config = config self.name = name if name is not None else str(id(self)) @@ -44,9 +54,26 @@ class Component(QObject): self.log.debug(f"config: {self.config}") def config_changed(self): + """ + this method should be overridden when inheriting from the Component class + and should contain all the initialization code that needs to access self.config + so that the component will reinitialize if configuration changes + this method will be called on start and when self.config (ConfigReader) emits the updated signal + """ pass def start(self): + """ + this method is automatically called if threaded is set to False at object creation + otherwise if the component is in a thread this method should be used like this: + component = ComponentSubclass(threaded=True) + thread = QThread() + thread.setTerminationEnabled(True) + component.moveToThread(thread) + thread.started.connect(component.start) + thread.start() + component.wait_ready() # this is optional and will wait untill the component has finished started + """ self._pause.connect(self._do_pause) self._resume.connect(self._do_resume) self._set_sources.connect(self._do_set_sources) @@ -63,6 +90,7 @@ class Component(QObject): @property def started(self): + """returns True if the component has been started""" if self._threaded: self._lock.acquire(max(self._lock.available(), 1)) started = self._started @@ -72,6 +100,7 @@ class Component(QObject): @property def running(self): + """returns True if the periodic calls to _get are not paused""" if self._threaded: self._lock.acquire(max(self._lock.available(), 1)) running = self._running @@ -80,6 +109,10 @@ class Component(QObject): return running def wait_ready(self, timeout=5): + """ + waits untill the requested action has been completed by the component + this will return immediately if threaded=False was passed at component initialization + """ if self._threaded: timeout = round(timeout * 1000) if self._lock.tryAcquire(max(self._lock.available(), 1), timeout): @@ -89,6 +122,7 @@ class Component(QObject): raise RuntimeError(f"{self.name} was not ready before timeout of {timeout}ms") def pause(self): + """will pause periodic calls to _get and sources trigghers""" if self._threaded: self._lock.acquire(max(self._lock.available(), 1)) if self._running is False: @@ -102,6 +136,7 @@ class Component(QObject): self._do_pause() def resume(self): + """will resume periodic calls to _get and sources trigghers""" if self._threaded: self._lock.acquire(max(self._lock.available(), 1)) if self._running is True: @@ -115,6 +150,12 @@ class Component(QObject): self._do_resume() def set_sources(self, sources=None): # sources should be {"source_name": signal_to_connect} + """ + connect the given sources to trigger a call to _get + the sources parameter should be: + a dict of signals might containing one optional argument that will be passed as data to _get + or None to disconnect all sources + """ if self._threaded: self._lock.acquire(max(self._lock.available(), 1)) self._set_sources.emit(sources) @@ -134,6 +175,7 @@ class Component(QObject): self.log.debug("no init periodic") def set_period(self, period=None, lazy=True): + """will set the period for periodic calls to _get and whether or not those are lazy (see init parameters)""" if self._threaded: self._lock.acquire(max(self._lock.available(), 1)) self._set_sources.emit({"period": period, "lazy": lazy}) @@ -214,13 +256,24 @@ class Component(QObject): self._lock.release() def _get(self, data=None): + """ + this method should be overridden when inheriting from the Component class + the overriding method should retrive all the data and then call super()._get(data) + this will emit the data in the proper format + """ if data is None: data = [None] - got = [{"time": timing(), self.name: d} for d in data] + t = timing() + got = [{"time": t, self.name: d} for d in data] self.out.emit(got) self.log.debug(f"_get: {got}") - if self._single_shot: + if self._timer is not None and self._single_shot: self._timer.start() def set(self, val): + """ + this method should be overridden when inheriting from the Component class + the overriding method should set the requested val and then call super()._set(set_value) + this will log the value that has been set + """ self.log.debug(f"set: {val}") diff --git a/src/components/dummies/gxpy/__init__.py b/src/components/dummies/gxpy/__init__.py new file mode 100644 index 0000000..5b084b8 --- /dev/null +++ b/src/components/dummies/gxpy/__init__.py @@ -0,0 +1 @@ +from .dummy_camera import DummyCamera diff --git a/src/components/dummies/gxpy/dummy_camera.py b/src/components/dummies/gxpy/dummy_camera.py new file mode 100644 index 0000000..f5dabab --- /dev/null +++ b/src/components/dummies/gxpy/dummy_camera.py @@ -0,0 +1,106 @@ +class DummyCamera: + # class Imager: + # def get_image(self): + # pass + # + # data_stream = [Imager()] + + class BalanceRatioSelectorClass: + def __init__(self, balance_selected, balance_values): + self.balance_selected = balance_selected + self.balance_values = balance_values + + def get(self): + print(f"{self.__class__.__name__}.get() -> {self.balance_selected}") + return self.balance_selected + + def set(self, selector): + self.balance_selected = selector + print(f"{self.__class__.__name__}.set({selector}) -> {self.balance_selected}") + + class BalanceRatioClass: + def __init__(self, balance_selected, balance_values): + self.balance_selected = balance_selected + self.balance_values = balance_values + + def get(self): + if self.balance_selected in self.balance_values: + print(f"{self.__class__.__name__}.get({self.balance_selected}) -> {self.balance_values[self.balance_selected]}") + return self.balance_values[self.balance_selected] + else: + print(f"{self.__class__.__name__}.get({self.balance_selected}) -> Not present -> None") + return None + + def set(self, value): + self.balance_values[self.balance_selected] = value + print(f"{self.__class__.__name__}.set({self.balance_selected}, {value}) -> {self.balance_values[self.balance_selected]}") + + balance_selected = None + balance_values = {} + + BalanceRatioSelector = BalanceRatioSelectorClass(balance_selected, balance_values) + BalanceRatio = BalanceRatioClass(balance_selected, balance_values) + + class GetSetReadable: + value = None + + @classmethod + def get(cls): + print(f"{cls.__name__}.get() -> {cls.value}") + return cls.value + + @classmethod + def set(cls, value): + cls.value = value + print(f"{cls.__name__}.set({value}) -> {cls.value}") + + @classmethod + def is_readable(cls): + print(f"{cls.__name__}.is_readable() -> {cls.value is not None}") + return cls.value is not None + + class TriggerSoftware(GetSetReadable): + @classmethod + def send_command(cls, *args, **kwargs): + pass + + class OffsetX(GetSetReadable): + pass + + class OffsetY(GetSetReadable): + pass + + class Width(GetSetReadable): + pass + + class Height(GetSetReadable): + pass + + class TriggerMode(GetSetReadable): + pass + + class ExposureTime(GetSetReadable): + pass + + class Gain(GetSetReadable): + pass + + class GammaParam(GetSetReadable): + pass + + class ContrastParam(GetSetReadable): + pass + + class ColorCorrectionParam(GetSetReadable): + pass + + class TriggerSource(GetSetReadable): + pass + + @classmethod + def stream_on(cls): + pass + + @classmethod + def stream_off(cls): + pass diff --git a/src/components/galaxy_camera.py b/src/components/galaxy_camera.py new file mode 100644 index 0000000..8610097 --- /dev/null +++ b/src/components/galaxy_camera.py @@ -0,0 +1,249 @@ +import pathlib +import sys +from itertools import cycle + +import cv2 +import gxipy as gx +import imutils +import numpy as np +from PyQt5.QtCore import QMutex, Qt, QThread, pyqtSignal +from PyQt5.QtGui import QImage, QPixmap +from PyQt5.QtWidgets import (QDialog, QFormLayout, QLabel, QMessageBox, + QPushButton, QSizePolicy, QSlider) + +if "--sim-camera" in sys.argv: + from components.dummies.gxpy import DummyCamera + +from datetime import datetime + +from .component import Component + + +class GalaxyCamera(Component): + _edits_new_frame = pyqtSignal(list) + + def __init__(self, config=None, name=None, period=1, lazy=True, paused=False, threaded=True, registers=None): + super().__init__(config=config, name=name, period=period, lazy=lazy, paused=paused, threaded=threaded) + self.lock = QMutex() + self.simulate = "--sim-camera" in sys.argv + self._last_frame = None + + def config_changed(self): + self._period = int(self.config[self.name].get("frame time ms", 300)) / 1000 + self.exposure_time = int(self.config[self.name]["exposure time"]) + self.roi = { + "x": int(self.round_4(self.config[self.name]["horizontal crop offset"])), + "w": int(self.round_4(self.config[self.name]["horizontal crop resolution"])), + "y": int(self.round_4(self.config[self.name]["vertical crop offset"])), + "h": int(self.round_4(self.config[self.name]["vertical crop resolution"])), + "r": int(self.config[self.name]["rotate 90 clockwise times"]), + } + self.auto_white_balance = bool(self.config[self.name].get("auto white balance", False)) + self.balance = { + "r": float(self.config[self.name].get("balance red", 1)), + "g": float(self.config[self.name].get("balance green", 1)), + "b": float(self.config[self.name].get("balance blue", 1)), + } + self.lock.lock() + self.camera.stream_off() + self.camera.TriggerMode.set(gx.GxSwitchEntry.OFF) + self.camera.ExposureTime.set(self.exposure_time) + self.camera.Gain.set(1.0) + self.camera.OffsetX.set(self.roi["x"]) + self.camera.Width.set(self.roi["w"]) + self.camera.OffsetY.set(self.roi["y"]) + self.camera.Height.set(self.roi["h"]) + if self.auto_white_balance: + self.camera.BalanceWhiteAuto.set(gx.GxAutoEntry.ONCE) + QThread.msleep(3000) + self.camera.BalanceRatioSelector.set(gx.GxBalanceRatioSelectorEntry.RED) + self.balance["r"] = self.camera.BalanceRatio.get() + self.camera.BalanceRatioSelector.set(gx.GxBalanceRatioSelectorEntry.GREEN) + self.balance["g"] = self.camera.BalanceRatio.get() + self.camera.BalanceRatioSelector.set(gx.GxBalanceRatioSelectorEntry.BLUE) + self.balance["b"] = self.camera.BalanceRatio.get() + else: + self.camera.BalanceRatioSelector.set(gx.GxBalanceRatioSelectorEntry.RED) + self.camera.BalanceRatio.set(self.balance["r"]) + self.camera.BalanceRatioSelector.set(gx.GxBalanceRatioSelectorEntry.GREEN) + self.camera.BalanceRatio.set(self.balance["g"]) + self.camera.BalanceRatioSelector.set(gx.GxBalanceRatioSelectorEntry.BLUE) + self.camera.BalanceRatio.set(self.balance["b"]) + if self.camera.GammaParam.is_readable(): + self.gamma_lut = gx.Utility.get_gamma_lut(self.camera.GammaParam.get()) + else: + self.gamma_lut = None + if self.camera.ContrastParam.is_readable(): + self.contrast_lut = gx.Utility.get_contrast_lut(self.camera.ContrastParam.get()) + else: + self.contrast_lut = None + if self.camera.ColorCorrectionParam.is_readable(): + self.color_correction = self.camera.ColorCorrectionParam.get() + else: + self.color_correction = 0 + self.camera.TriggerMode.set(gx.GxSwitchEntry.ON) + self.camera.TriggerSource.set(gx.GxTriggerSourceEntry.SOFTWARE) + self.camera.stream_on() + self.edits = None + self.lock.unlock() + self.edits_enabled = "--camera-edits" in sys.argv + if self.edits_enabled: + self.init_edits() + + @staticmethod + def round_4(x): + return 4 * round(int(x) / 4) + + def start(self): + if self.simulate: + self.camera = DummyCamera() + self.sim_imgs = cycle(sorted(pathlib.Path("data/simulation_images/").glob("*.png"))) + else: + self.device_manager = gx.DeviceManager() + # create a device manager + dev_num, dev_info_list = self.device_manager.update_device_list() + if dev_num == 0: + self.log.exception("camera not detected") + QMessageBox.critical(None, "Errore hardware", "Telecamera non rilevata.\nControllare connessione usb") + quit() + self.camera = self.device_manager.open_device_by_index(1) + super().start() + + def _get(self): + # print("GALAXY CAMERA", str(int(QThread.currentThreadId())), flush=True) + frame = None + self.lock.lock() + if self.simulate: + img_path = next(self.sim_imgs) + self.log.debug(f"loading image {img_path}") + frame = cv2.cvtColor(cv2.imread(str(img_path)), cv2.COLOR_BGR2RGB) + else: + self.camera.TriggerSoftware.send_command() + frame = self.camera.data_stream[0].get_image() + if frame is not None: + if frame.get_status() == gx.GxFrameStatusList.INCOMPLETE: + self.log.error("incomplete frame") + frame = None + else: + frame = frame.convert("RGB") + frame.image_improvement(self.color_correction, self.contrast_lut, self.gamma_lut) + frame = frame.get_numpy_array() + frame = np.rot90(frame, self.roi["r"]) + self.lock.unlock() + if frame is None: + self.log.error("failed to get frame") + elif self.edits_enabled: + frame = self.edit(frame, self.edits) + self._edits_new_frame.emit([frame]) + super()._get([frame]) + + def __del__(self, event=None): + self.camera.stream_off() + self.camera.close_device() + + def init_edits(self): + self.edits_enabled = True + self.edits_dialog = EditsDialog(self.roi) + self.edits_dialog.edits_changed.connect(self.set_edits) + self._edits_new_frame.connect(self.edits_dialog.save_and_show_edits_new_frame) + + def set_edits(self, edits=None): + self.edits = edits + + def edit(self, img, edits=None): + if edits is None: + return img + # BRIGHTNESS AND CONTRAST + contrast = float(edits.get("contrast", 0)) + brightness = float(edits.get("brightness", 0)) + if not (contrast == 0 and brightness == 0): + img = imutils.adjust_brightness_contrast(img, brightness=brightness, contrast=contrast / 255 * 500) + # ROTATE AND SCALE + rotation = -float(edits.get("rotation", 0)) + scale = float(edits.get("scale", 1)) + if not (rotation == 0 and scale == 1): + img = imutils.rotate(img, rotation, scale=scale) + # TRANSLATE + translation_x = float(edits.get("translation_x", 0)) + translation_y = float(edits.get("translation_y", 0)) + if not (translation_x == 0 and translation_y == 0): + img = imutils.translate(img, translation_x, translation_y) + return img + + +class EditsDialog(QDialog): + edits_changed = pyqtSignal(dict) + + def __init__(self, roi): + super().__init__() + self.frame = None + self.edits = { + "brightness": 0, + "contrast": 0, + "rotation": 0, + "scale": 1, + "translation_x": 0, + "translation_y": 0, + } + self.edits_specs = { + "brightness": [[-255, 255], 1, QSlider(Qt.Horizontal), QLabel()], + "contrast": [[-255, 255], 1, QSlider(Qt.Horizontal), QLabel()], + "rotation": [[-180, 180], 1, QSlider(Qt.Horizontal), QLabel()], + "scale": [[0, 5], 100, QSlider(Qt.Horizontal), QLabel()], + "translation_x": [[-roi["w"], roi["w"]], 1, QSlider(Qt.Horizontal), QLabel()], + "translation_y": [[-roi["h"], roi["h"]], 1, QSlider(Qt.Horizontal), QLabel()], + } + layout = QFormLayout() + self.edits_frame_l = QLabel() + self.edits_frame_l.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + layout.addRow(self.edits_frame_l) + for edit, limits in self.edits_specs.items(): + limit, multiplier, slider, label = limits + slider.setRange(limit[0] * multiplier, limit[1] * multiplier) + slider.setSingleStep(1) + slider.setValue(self.edits[edit] * multiplier) + label.setText(f"{edit}: {self.edits[edit]}") + layout.addRow(label, slider) + slider.valueChanged.connect(self.update_edits) + self.edits_save_frame_b = QPushButton("save frame") + layout.addRow(self.edits_save_frame_b) + self.edits_save_frame_b.clicked.connect(self.edits_save_frame) + self.setLayout(layout) + self.update_edits() + self.show() + + def update_edits(self): + for edit, limits in self.edits_specs.items(): + limit, multiplier, slider, label = limits + self.edits[edit] = slider.value() / multiplier + label.setText(f"{edit}: {self.edits[edit]}") + self.edits_changed.emit(self.edits) + + def save_and_show_edits_new_frame(self, frame): + self.frame = frame[0] + if self.frame is not None: + self.edits_frame_l.setPixmap( + QPixmap.fromImage( + QImage( + self.frame.data, + self.frame.shape[1], # width + self.frame.shape[0], # height + self.frame.shape[2] * self.frame.shape[1], # width * channels + QImage.Format_RGB888 + ) + ).scaled( + max(self.edits_frame_l.width(), 640), + max(self.edits_frame_l.height(), 480), + Qt.KeepAspectRatio, + Qt.SmoothTransformation + ) + ) + + def edits_save_frame(self): + if self.frame is None: + return + out_path = f"./{datetime.now().isoformat()}.png" + print(f"saving frame: {out_path!r}") + img = cv2.cvtColor(self.frame, cv2.COLOR_RGB2BGR) + cv2.imwrite(out_path, img) + return out_path diff --git a/src/components/renderer.py b/src/components/renderer.py new file mode 100644 index 0000000..1183a98 --- /dev/null +++ b/src/components/renderer.py @@ -0,0 +1,550 @@ +import copy +import gc +import os +import sys +import traceback +from configparser import ConfigParser + +import lib.helpers.label_map_util as label_map_util +import numpy +import numpy as np +import tensorflow as tf +from pycoral.adapters import detect + +if "--no-edgetpu" not in sys.argv: + from pycoral.utils.edgetpu import make_interpreter +else: + def make_interpreter(*args, **kwargs): + raise ValueError("\"--no-edgetpu\" in sys.argv") + +from lib.helpers.log import log_msg +from PyQt5.QtCore import (QFileSystemWatcher, QMutex, QObject, QPointF, QRectF, + Qt, pyqtSignal) +from PyQt5.QtGui import QBrush, QColor, QPainter, QPen +from tflite_runtime.interpreter import Interpreter +from ui.test.test import CycleState + +os.environ["CUDA_VISIBLE_DEVICES"] = "-1" + +# Patch the location of gfile +tf.gfile = tf.io.gfile + +IMG_SCALE = 0.75 # ORIGINAL IMAGE FRAME TO QT WINDOW RATIO + + +class LibVision(QObject): + status_signal = pyqtSignal(dict) + loading_model_signal = pyqtSignal(dict) + + def __init__(self, main_window): + super().__init__() + self.arrow = None + self.last_arrow = None + self.main_window = main_window + self.config = main_window.config + self.watcher = main_window.watcher + self.img_scale = IMG_SCALE + self.pixel_size = self.config["CAMERA"]["pixel size"] + self.image_width_qt = int(self.config["CAMERA"]["horizontal crop resolution"] * IMG_SCALE) + self.image_height_qt = int(self.config["CAMERA"]["vertical crop resolution"] * IMG_SCALE) + self.meas_tolerance = self.config["VISION"]["measurement tolerance"] + self.align_tolerance = self.config["VISION"]["alignment tolerance"] + self.autotest_tolerance = self.config["VISION"]["autotest tolerance"] + self.autotest_positions = self.config["VISION"]["autotest positions"] + self.detection_crop_qt = (0, 0, self.image_width_qt, self.image_height_qt) + self.img_center_x_qt = int(self.image_width_qt / 2) + self.img_center_y_qt = int(self.image_height_qt / 2) + self.img_q1_y_qt = 100 + self.qtcolor = {"orange": QColor("#FF8400")} + # OBJECT DETECTION + self.detection_threshold = self.config["VISION"]["detection threshold"] + self.image_width_inf = 1024 + self.image_height_inf = 128 + self.measure_ok = False + self.edge_detected = False + self.edge_detection = None + self.edge_fitting_distance = None + self.edge_fitting_distance_real = None + self.detected_cal_positions = None + self.cal_detected = None + # recipe + self.zones = None + self.labels = None + self.recipes_dir = "config/vision_test_recipes" + self.recipe_watcher = QFileSystemWatcher([]) + self.set_recipe(None) + self.recipe_watcher.fileChanged.connect(self.set_recipe) + self.NEURAL_NETWORK_MODEL = self.config["VISION"]["neural network"] + # MODEL + self.model = None + self.model_lock = QMutex() + self.tflite_mode = False + self.edgeTPU_mode = False + if "--tflite" in sys.argv: + self.tflite_mode = True + self.edgeTPU_mode = True + self.load_model(self.NEURAL_NETWORK_MODEL) + # CATEGORY INDEX + # label_map = label_map_util.load_labelmap("config/vision_test_labels/labels-onlydots.pbtxt") + label_map = label_map_util.load_labelmap("config/vision_test_labels/labels.pbtxt") + self.num_classes = len(label_map.item) + categories = label_map_util.convert_label_map_to_categories(label_map, max_num_classes=self.num_classes, use_display_name=True) + self.config.category_index = label_map_util.create_category_index(categories) + self.config.classes_map = {c["name"]: k for k, c in self.config.category_index.items()} + + def mm_to_qt(self, val): + return int(val * self.img_scale / self.pixel_size) + + def set_recipe(self, recipe_path=None): + log_msg(f"LOADING RECIPE {recipe_path!r}") + watched = self.recipe_watcher.files() + if recipe_path == "" and len(watched) == 0: # skip bad watcher signals + return + if len(watched) > 0: + self.recipe_watcher.removePaths(watched) + self.recipe_path = recipe_path + if self.recipe_path is None: + self.zones = None + self.labels = None + else: + try: + if not os.path.isfile(self.recipe_path): + raise AssertionError(f"Recipe file {self.recipe_path!r} could not be found.") + config = ConfigParser(inline_comment_prefixes="#") + read = config.read(self.recipe_path) + if len(read) != 1 or self.recipe_path not in read: + raise AssertionError(f"Recipe file {self.recipe_path!r} could not be read.") + recipe = config._sections.get("camera_shapes", None) + if recipe is None: + raise AssertionError( + f"Recipe file {self.recipe_path!r} does not contain the 'camera_shapes' section.") + self.points = self.parse_points(recipe) + zones = config._sections.get("camera_zones", None) + if zones is None: + raise AssertionError( + f"Recipe file {self.recipe_path!r} does not contain the 'camera_zones' section.") + self.zones = self.parse_zones(zones) + labels = config._sections.get("labels", None) + if labels is not None: + self.labels = self.parse_labels(labels) + else: + self.labels = None + self.recipe_watcher.addPath(self.recipe_path) + except Exception: + print(*traceback.format_stack(), sep="", file=sys.stderr, flush=True) + traceback.print_exc(file=sys.stderr) + self.zones = None + if self.zones is None and self.labels is None: + self.status_signal.emit({"vision_recipe": 0}) + else: + self.status_signal.emit({"vision_recipe": os.path.splitext(os.path.basename(recipe_path))[0]}) + + @staticmethod + def parse_points(config): + points = {} + for point_name, point_spec in config.items(): + try: + center, size, fill_color, border_color, border_thickness, shape = point_spec.split(" ") + center = list(map(float, center.split(","))) + center[1] = -center[1] + size = list(map(float, size.split(","))) + if len(size) == 1: + size = [size[0], size[0]] + fill_color = QColor(fill_color.replace("0x", "#")) + border_color = QColor(border_color.replace("0x", "#")) + border_thickness = float(border_thickness) + shape = shape.lower() + points[point_name] = { + "center": center, + "size": size, + "fill_color": fill_color, + "border_color": border_color, + "border_thickness": border_thickness, + "shape": shape, + } + except Exception: + print(*traceback.format_stack(), sep="", file=sys.stderr, flush=True) + traceback.print_exc(file=sys.stderr) + log_msg(f"point {point_name!r} could not be parsed. spec: {point_spec!r}", file=sys.stderr) + return points + + @staticmethod + def draw_points(points, qimage, pixel_mm, mm_offset=None, painter=None): + if mm_offset is None: + mm_offset = [0, 0] + if painter is None: + painter = QPainter() + painter.begin(qimage) + avg_pixel_mm = (sum(pixel_mm) / len(pixel_mm)) + for point_name, point in points.items(): + try: + x = (point["center"][0] + mm_offset[0]) / pixel_mm[0] + qimage.width() / 2 + y = -(point["center"][1] + mm_offset[1]) / pixel_mm[1] + qimage.height() / 2 + x2 = x + (point["size"][0] + mm_offset[0]) / pixel_mm[0] + y2 = y - (point["size"][1] + mm_offset[1]) / pixel_mm[1] + w = point["size"][0] / pixel_mm[0] + h = point["size"][1] / pixel_mm[1] + painter.setBrush(QBrush(point["fill_color"], Qt.SolidPattern)) + painter.setPen( + QPen(point["border_color"], point["border_thickness"] / avg_pixel_mm, Qt.SolidLine, Qt.SquareCap, + Qt.MiterJoin)) + if point["shape"] == "ellipse": + painter.drawEllipse(QPointF(x, y), w / 2, h / 2) + elif point["shape"] == "cross": + painter.drawLine(x, y - h / 2, x, y + h / 2) + painter.drawLine(x - w / 2, y, x + w / 2, y) + elif point["shape"] == "line": + painter.drawLine(x, y, x2, y2) + else: + raise NotImplementedError(f"point {point_name!r} has an invalid shape: {point['shape']!r}") + except Exception: + print(*traceback.format_stack(), sep="", file=sys.stderr, flush=True) + traceback.print_exc(file=sys.stderr) + log_msg(f"point {point_name!r} could not be drawn.", file=sys.stderr) + + def parse_zones(self, config): + zones = {} + for zone_name, zone_spec in config.items(): + zone_name = zone_name.upper() + try: + center, margin, d_class = zone_spec.split(" ") + center = list(map(float, center.split(","))) + center[1] = -center[1] + if margin == "none": + margin = None + elif "," in margin: + margin = list(map(float, margin.split(","))) + else: + margin = float(margin) + zones[zone_name] = { + "center": center, + "margin": margin, + "class": self.config.category_index[self.config.classes_map[d_class]], + } + except Exception: + print(*traceback.format_stack(), sep="", file=sys.stderr, flush=True) + traceback.print_exc(file=sys.stderr) + log_msg(f"Region {zone_name!r} could not be parsed. spec: {zone_spec!r}", file=sys.stderr) + zones["fitting"] = { + "center": [0, 0], + "margin": [140, 140], + "class": self.config.category_index[self.config.classes_map["fitting"]], + } + return zones + + def parse_labels(self, config): + labels = {} + for label_name, label_spec in config.items(): + try: + location, font_size, fill_color, border_color, border_thickness, text = label_spec.split(" ", 5) + location = list(map(float, location.split(","))) + location[1] = -location[1] + font_size = float(font_size) + fill_color = QColor(fill_color.replace("0x", "#")) + border_color = QColor(border_color.replace("0x", "#")) + border_thickness = float(border_thickness) + text = text.replace("\\n", "\n").replace("\\t", "\t") + labels[label_name] = { + "location": location, + "font_size": font_size, + "fill_color": fill_color, + "border_color": border_color, + "border_thickness": border_thickness, + "text": text, + } + except Exception: + print(*traceback.format_stack(), sep="", file=sys.stderr, flush=True) + traceback.print_exc(file=sys.stderr) + log_msg(f"Label {label_name!r} could not be parsed. spec: {label_spec!r}", file=sys.stderr) + return labels + + # def zone_center(self, zone): + # return (int((zone["xmax"] + zone["xmin"]) / 2), int((zone["ymax"] + zone["ymin"]) / 2)) + + def get_center(self, rect): + return [(rect[0] + rect[2]) / 2, (rect[1] + rect[3]) / 2] + + def get_distance(self, p1, p2): + return pow((p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2, 1 / 2) + + def load_lodel_tflite(self, model=None): + # Creates tflite interpreter + try: + self.interpreter = make_interpreter(f"neural_networks/{model}/{model}_edgetpu.tflite") + self.interpreter.allocate_tensors() + self.interpreter.invoke() # warmup + self.tflite_input_details = self.interpreter.get_input_details() + self.tflite_output_details = self.interpreter.get_output_details() + self.inf_width = self.tflite_input_details[0]['shape'][2] + self.inf_height = self.tflite_input_details[0]['shape'][1] + log_msg("edge TPU model initialized") + except ValueError: + # edge TPU not found + log_msg("edge TPU not found") + self.edgeTPU_mode = False + self.interpreter = Interpreter(f"neural_networks/{model}/{model}.tflite") + self.interpreter.allocate_tensors() + self.interpreter.invoke() # warmup + self.tflite_input_details = self.interpreter.get_input_details() + self.tflite_output_details = self.interpreter.get_output_details() + self.inf_width = self.tflite_input_details[0]['shape'][2] + self.inf_height = self.tflite_input_details[0]['shape'][1] + log_msg("TFlite model on CPU initialized") + + def run_inference_tflite(self, image, threshold=0.3): + self.interpreter.set_tensor(self.tflite_input_details[0]['index'], image) + self.interpreter.invoke() + objs = detect.get_objects(self.interpreter, threshold, (1, 1)) + boxes = [(obj.bbox.ymin / self.inf_height, obj.bbox.xmin / self.inf_width, obj.bbox.ymax / self.inf_height, obj.bbox.xmax / self.inf_width) for obj in objs] + classes = [obj.id + 1 for obj in objs] + scores = [obj.score for obj in objs] + + return {"detection_boxes": boxes, "detection_classes": classes, "detection_scores": scores, + "num_detections": 10} + + def load_model(self, model=None): + log_msg("NEURAL NETWORK MODEL CHOSEN: {}".format(model)) + if model.lower() in [ + "", + "any", + "last", + "latest", + "newest", + "none", + None, + ]: + model = \ + sorted([d for d in os.listdir("neural_networks") if os.path.isdir(os.path.join("neural_networks", d))], + reverse=True)[0] + self.NEURAL_NETWORK_MODEL = model + self.loading_model_signal.emit({"status": "loading"}) + self.model_lock.lock() + log_msg("LOADING NEURAL NETWORK MODEL: {}".format(model)) + try: + if self.tflite_mode: + self.load_lodel_tflite(self.NEURAL_NETWORK_MODEL) + else: + model = tf.saved_model.load(f"neural_networks/{model}") + except Exception as e: + self.model_lock.unlock() + self.loading_model_signal.emit({"status": "aborted"}) + raise e + if self.model is not None: + self.model = None + tf.keras.backend.clear_session() + gc.collect() + self.model = model + self.model_lock.unlock() + self.loading_model_signal.emit({"status": "done"}) + + def check_features(self, image_np): + self.watcher.profile_start() + image = np.asarray(image_np) + self.watcher.profile_measure("INF_NP") + input_tensor = np.expand_dims(image, axis=0) + self.watcher.profile_measure("INF_SHAPE") + # Run inference + self.model_lock.lock() + if self.tflite_mode: + detections = self.run_inference_tflite(input_tensor, threshold=self.detection_threshold) + else: + detections = self.model(input_tensor) + num_detections = int(detections.pop("num_detections")) + detections = {key: value[0, :num_detections].numpy() for key, value in detections.items()} + detections["num_detections"] = num_detections + + self.model_lock.unlock() + self.watcher.profile_measure("INF_INF") + + parsed_detections = [] + for d_class, d_score, d_box in zip(detections["detection_classes"], detections["detection_scores"], + detections["detection_boxes"]): + if d_score >= self.detection_threshold: + ymin, xmin, ymax, xmax = d_box # d_box.tolist() + box = [ + xmin, + ymin, + xmax, + ymax, + ] + center = self.get_center(box) + detection = { + "class": self.config.category_index[int(d_class)]["name"], + "color": self.config.category_index[int(d_class)]["color_qt"], + "score": float(d_score), + "box": box, + "center": center, + "pos_rel_mm": self.get_pos_rel_mm(center), + } + parsed_detections.append(detection) + self.watcher.profile_measure("INF_POST") + + return parsed_detections + + def get_pos_rel_mm(self, center): + x_rel_px = (center[0] - 0.5) * self.config["CAMERA"]["horizontal crop resolution"] + return x_rel_px * self.pixel_size + + def process_detections(self, detections): + self.last_arrow = copy.deepcopy(self.arrow) + for detection in detections: + self.edge_detected = None + self.measure_ok = False + if detection["class"] == "edge-left": + self.edge_detected = True + self.edge_detection = detection + self.edge_fitting_distance = self.main_window.cam_offset_mm - self.main_window.drawing.after_insertion_offset - (self.main_window.drawing.fitting_offset + self.main_window.cutting_offset_mm) + detection["pos_rel_mm"] + self.edge_fitting_distance_real = self.main_window.cam_offset_mm - (self.main_window.test_fitting_offset + self.main_window.cutting_offset_mm) + detection["pos_rel_mm"] + # DECIDE ARROW DIRECTION (NORMAL IN FIRST TEST, INVERTED IN SECOND TEST) + if (self.edge_fitting_distance_real > self.main_window.drawing.dipstick_offset) != (self.main_window.test_widget.current_state == CycleState.VISION_2_VERIFY): + self.arrow = "left" + else: + self.arrow = "right" + if abs(self.edge_fitting_distance_real - self.main_window.drawing.dipstick_offset) < self.main_window.test_tolerance: + self.measure_ok = True + self.arrow = "ok" + return + else: + self.measure_ok = False + return + break + # NO EDGE DETECTED + self.arrow = "none" + self.edge_fitting_distance = None + self.edge_fitting_distance_real = None + self.edge_detection = None + + def process_detections_calibration(self, detections): + self.detected_cal_positions = [{"ok": False, "pos": None} for x in enumerate(self.autotest_positions)] + cal_positions = [] + for detection in detections: + self.edge_detected = False + self.measure_ok = False + if detection["class"] == "cal": + cal_positions.append({"pos": detection["pos_rel_mm"], "detection": detection, "ok": False}) + + for n, pos in enumerate(self.autotest_positions): + if len(cal_positions) == 0: + break + nearest_idx = min(range(len(cal_positions)), key=lambda i: abs(cal_positions[i]["pos"] - pos)) + if abs(cal_positions[nearest_idx]["pos"] - pos) < 5: + self.detected_cal_positions[n] = cal_positions[nearest_idx] + del(cal_positions[nearest_idx]) + + self.edge_detected = True + self.measure_ok = True + self.arrow = "ok" + for i, detected, expected in zip(range(len(self.detected_cal_positions)), self.detected_cal_positions, self.autotest_positions): + if detected["pos"] is not None and abs(detected["pos"] - expected) < self.autotest_tolerance: + self.detected_cal_positions[i]["ok"] = True + else: + self.arrow = "ko" + self.measure_ok = False + if detected["pos"] is None: + self.edge_detected = False + + def visualize_calibration(self, image): + painter = QPainter() + painter.begin(image) + if len(self.detected_cal_positions) > 0: + for detection in self.detected_cal_positions: + if detection["pos"] is not None: + center = detection["detection"]["center"] + det_x_qt = int(center[0] * self.image_width_qt) + det_y_qt = int(center[1] * self.image_height_qt) + # DRAW VERTICAL EDGE POSITION MARK + if detection["ok"]: + color = Qt.green + else: + color = Qt.red + painter.setPen(QPen(color, 4, Qt.DashLine)) + refline = det_x_qt, det_y_qt + 40, det_x_qt, det_y_qt - 40 + painter.drawLine(*refline) + + # DRAW COLORED RECTANGLES IDENTIFYING DETECTIONS + + def visualize_detections(self, image, detections): + painter = QPainter() + painter.begin(image) + # DRAW DETECTIONS + detections_thickness_px = 4 + if detections is not None: + painter.setOpacity(1) + for detection in detections: + result_color = QColor(*detection["color"], 150) + # center = detection["center"] + xmin, ymin, xmax, ymax = detection["box"] + x = xmin * self.image_width_qt - detections_thickness_px + y = ymin * self.image_height_qt - detections_thickness_px + w = xmax * self.image_width_qt - x + detections_thickness_px + h = ymax * self.image_height_qt - y + detections_thickness_px + painter.setPen(QPen(result_color, detections_thickness_px, Qt.SolidLine, Qt.SquareCap, Qt.MiterJoin)) + painter.setBrush(QBrush()) + painter.drawRect(QRectF(x, y, w, h)) + + def visualize_ref(self, image): + painter = QPainter() + painter.begin(image) + ref_x_qt = self.main_window.ref_x_qt + + # DRAW H/V CAMERA CENTER AXIS + if self.main_window.display_camera_center: + painter.setPen(QPen(Qt.yellow, 2, Qt.DashLine)) + refline = self.img_center_x_qt, 0, self.img_center_x_qt, self.image_height_qt + painter.drawLine(*refline) + refline = 0, self.img_center_y_qt, self.image_width_qt, self.img_center_y_qt + painter.drawLine(*refline) + + tolerance_px = self.mm_to_qt(self.main_window.test_tolerance) + if self.main_window.test_state in (CycleState.VISION_2_VERIFY, CycleState.VISION_1_ALIGNMENT): + # DRAW VERTICAL REFERENCE POSITION AXIS + painter.setPen(QPen(Qt.cyan, 4, Qt.DashLine)) + refline = ref_x_qt, 0, ref_x_qt, self.image_height_qt + painter.drawLine(*refline) + # DRAW TOLERANCE LIMITS AXIS + painter.setPen(QPen(Qt.cyan, 2, Qt.DashDotLine)) + refline = ref_x_qt + tolerance_px, 0, ref_x_qt + tolerance_px, self.image_height_qt + painter.drawLine(*refline) + refline = ref_x_qt - tolerance_px, 0, ref_x_qt - tolerance_px, self.image_height_qt + painter.drawLine(*refline) + # DRAW ORIGIN(out of screen) TO REFERENCE ARROW + painter.setPen(QPen(Qt.blue, 4, Qt.SolidLine)) + painter.drawLine(0, self.img_q1_y_qt, ref_x_qt, self.img_q1_y_qt) + painter.drawLine(ref_x_qt - 20, self.img_q1_y_qt - 20, ref_x_qt, self.img_q1_y_qt) + painter.drawLine(ref_x_qt - 20, self.img_q1_y_qt + 20, ref_x_qt, self.img_q1_y_qt) + if self.main_window.test_state == CycleState.AUTOTEST: + # DRAW AUTOTEST MARKS + for mark in self.main_window.config["VISION"]["autotest positions"]: + mark_x_qt = self.img_center_x_qt + self.mm_to_qt(mark) + # DRAW MARK POSITION AXIS + painter.setPen(QPen(self.qtcolor["orange"], 4, Qt.DashLine)) + refline = mark_x_qt, 0, mark_x_qt, self.image_height_qt + painter.drawLine(*refline) + # DRAW TOLERANCE LIMITS AXIS + painter.setPen(QPen(self.qtcolor["orange"], 2, Qt.DashDotLine)) + refline = mark_x_qt + tolerance_px, 0, mark_x_qt + tolerance_px, self.image_height_qt + painter.drawLine(*refline) + refline = mark_x_qt - tolerance_px, 0, mark_x_qt - tolerance_px, self.image_height_qt + painter.drawLine(*refline) + + def visualize_edge(self, image): + painter = QPainter() + painter.begin(image) + if self.edge_detection is not None: + center = self.edge_detection["center"] + det_x_qt = int(center[0] * self.image_width_qt) + det_y_qt = int(center[1] * self.image_height_qt) + # DRAW VERTICAL EDGE POSITION MARK + if self.measure_ok: + color = Qt.green + else: + color = Qt.red + painter.setPen(QPen(color, 4, Qt.DashLine)) + refline = det_x_qt, det_y_qt + 40, det_x_qt, det_y_qt - 40 + painter.drawLine(*refline) + + # DRAW ORIGIN(out of screen) TO DETECTION ARROW + painter.setPen(QPen(color, 4, Qt.SolidLine)) + painter.drawLine(0, det_y_qt, det_x_qt, det_y_qt) + painter.drawLine(det_x_qt - 20, det_y_qt - 20, det_x_qt, det_y_qt) + painter.drawLine(det_x_qt - 20, det_y_qt + 20, det_x_qt, det_y_qt) diff --git a/src/components/terminals.py b/src/components/terminals.py new file mode 100644 index 0000000..96e8ca0 --- /dev/null +++ b/src/components/terminals.py @@ -0,0 +1,171 @@ +import gc +import os +import random +import re +import sys + +import numpy as np +import tensorflow as tf +from lib.helpers import log_msg +from lib.object_detection.utils import label_map_util +from PyQt5.QtCore import QMutex, pyqtSignal, pyqtSlot + +from .input import Input + +# Patch the location of gfile +tf.gfile = tf.io.gfile + + +class Terminals(Input): + loading_model_signal = pyqtSignal(dict) + + def __init__(self, bench, name, config): + super().__init__(bench, name, config) + self.simulate = "--sim-vision" in sys.argv + self.source = self.bench.inputs[self.config["source"]] + self.model_name = self.config["model"] + self.num_classes = self.config["num_classes"] + self.matching_distance = self.config["matching_distance"] + self.crop = self.bench.zones["terminals"]["box"] + self.subzones = {k: self.bench.zones[k] for k in self.bench.zones if re.search("t[0-9].*", k)} + self.threshold = self.config["threshold"] + if not self.simulate: + # MODEL + self.model = None + self.model_lock = QMutex() + self.load_model(self.model_name) + label_map = label_map_util.load_labelmap("config/vision_test_labels/terminals.pbtxt") + categories = label_map_util.convert_label_map_to_categories(label_map, max_num_classes=self.num_classes, use_display_name=True) + self.category_index = label_map_util.create_category_index(categories) + # TO INITIALIZE TENSORFLOW MODELS + if not self.simulate: + img = np.zeros([1, 1, 3], dtype=np.uint8) + input_tensor = tf.convert_to_tensor(img) + input_tensor = input_tensor[tf.newaxis, ...] + self.model_lock.lock() + self.bench.gpu_mutex.lock() + self.model(input_tensor) + self.bench.gpu_mutex.unlock() + self.model_lock.unlock() + self.last_frame = None + + def load_model(self, model_name=None): + log_msg("TERMINALS NEURAL NETWORK MODEL CHOSEN: {}".format(model_name)) + if model_name.lower() in [ + "", + "any", + "last", + "latest", + "newest", + "none", + None, + ]: + model_name = sorted([d for d in os.listdir("neural_networks") if os.path.isdir(os.path.join("neural_networks", d)) and d.startswith("t")], reverse=True)[0] + self.loading_model_signal.emit({"status": "loading"}) + self.model_lock.lock() + log_msg(f"LOADING TERMINALS NEURAL NETWORK MODEL: {model_name}", msg_type="tensorflow") + try: + with tf.device('/device:GPU:0'): + model = tf.saved_model.load(f"neural_networks/{model_name}") + # if "--terminals-tf-default-signature" in sys.argv: + # model = model.signatures["serving_default"] + self.model_name = model_name + except Exception as e: + self.model_lock.unlock() + self.loading_model_signal.emit({"status": "aborted"}) + raise e + if self.model is not None: + self.model = None + tf.keras.backend.clear_session() + gc.collect() + self.model = model + self.model_lock.unlock() + self.loading_model_signal.emit({"status": "done"}) + + @pyqtSlot(list) + def _get(self, frame): + img = frame[1] + img = img[self.crop[1]:self.crop[3], self.crop[0]:self.crop[2]] + self.last_frame = img + if self.simulate: + w = self.crop[2] - self.crop[0] + h = self.crop[3] - self.crop[1] + detected_zones = {} + for zone_name, zone in self.subzones.items(): + detected_zones[zone_name] = { + "class": random.choice(list(self.category_index.values())), + "score": 1, + "box": [ + (zone["box"][1] - self.crop[1]) / h, + (zone["box"][0] - self.crop[0]) / w, + (zone["box"][3] - self.crop[1]) / h, + (zone["box"][2] - self.crop[0]) / w, + ], + "center": [ + (zone["center"][1] - self.crop[1]) / h, + (zone["center"][0] - self.crop[0]) / w, + ], + } + # self.update.emit([frame[0], detected_zones, self.name]) + self.out.emit([frame[0], detected_zones, self.name]) + return + input_tensor = tf.convert_to_tensor(np.asarray(img)) + input_tensor = input_tensor[tf.newaxis, ...] + if not self.simulate: + # RUN INFERENCE + self.model_lock.lock() + self.bench.gpu_mutex.lock() + output = self.model(input_tensor) + self.bench.gpu_mutex.unlock() + self.model_lock.unlock() + else: + output = {"detection_classes": [[None]], "detection_scores": [[0]], "detection_boxes": [[None]]} + # create detections + detections = [] + for d_class, d_score, d_box in zip(output["detection_classes"][0], output["detection_scores"][0], output["detection_boxes"][0]): + if d_score < self.threshold: + continue + detections.append({ + "class": self.category_index[int(d_class)], + "score": d_score.numpy().tolist(), + "box": d_box.numpy().tolist(), + "center": self.bench.get_center(d_box.numpy().tolist()), + }) + # match detections with zones + w = self.crop[2] - self.crop[0] + h = self.crop[3] - self.crop[1] + detected_zones = {zone_name: None for zone_name in self.subzones} + for detection in detections: + d_center = [self.crop[0] + detection["center"][1] * w, self.crop[1] + detection["center"][0] * h] + min_distance = sys.maxsize + closest_zone = None + for zone_name, zone in self.subzones.items(): + distance = self.bench.get_distance(d_center, zone["center"]) + if distance < min_distance and distance <= self.matching_distance: + min_distance = distance + closest_zone = zone_name + if closest_zone is None: + continue + if detected_zones[closest_zone] is not None: + old_center = [self.crop[0] + detected_zones[closest_zone]["center"][1] * w, self.crop[1] + detected_zones[closest_zone]["center"][0] * h] + if self.bench.get_distance(old_center, zone["center"]) <= self.bench.get_distance(d_center, zone["center"]): + continue + detected_zones[closest_zone] = detection + for zone_name, zone in self.subzones.items(): + if detected_zones[zone_name] is None: + detected_zones[zone_name] = { + "class": {"id": None, "name": "no_detection", "color": "rgb(0,0,0)", "color_qt": [0, 0, 0]}, + "score": 1, + "box": [ + (zone["box"][1] - self.crop[1]) / h, + (zone["box"][0] - self.crop[0]) / w, + (zone["box"][3] - self.crop[1]) / h, + (zone["box"][2] - self.crop[0]) / w, + ], + "center": [ + (zone["center"][1] - self.crop[1]) / h, + (zone["center"][0] - self.crop[0]) / w, + ], + } + # self.update.emit([frame[0], detected_zones, self.name]) + self.out.emit([frame[0], detected_zones, self.name]) diff --git a/src/components/vision.py b/src/components/vision.py new file mode 100644 index 0000000..d3f1b07 --- /dev/null +++ b/src/components/vision.py @@ -0,0 +1,338 @@ +import os +import sys +import traceback +from configparser import ConfigParser +from pathlib import Path + +import numpy +import numpy as np +import tensorflow as tf +from lib.helpers.object_detection.utils import label_map_util +from PyQt5.QtCore import QFileSystemWatcher, QMutex, pyqtSignal +from PyQt5.QtGui import QColor + +from .component import Component + +if "--no-edgetpu" not in sys.argv: + if "--no-tflite" not in sys.argv: + from pycoral.utils.edgetpu import make_interpreter +else: + def make_interpreter(*args, **kwargs): + raise ValueError("\"--no-edgetpu\" in sys.argv") + +if "--no-tflite" not in sys.argv: + from pycoral.adapters import detect + from tflite_runtime.interpreter import Interpreter +else: + def Interpreter(*args, **kwargs): + raise ValueError("\"--no-tflite\" in sys.argv") + + +# os.environ["CUDA_VISIBLE_DEVICES"] = "-1" +# +# # Patch the location of gfile +# tf.gfile = tf.io.gfile + + +class Vision(Component): + status_signal = pyqtSignal(dict) + loading_model_signal = pyqtSignal(dict) + + def __init__(self, config=None, name=None, period=None, lazy=True, paused=False, threaded=True, registers=None): + super().__init__(config=config, name=name, period=period, lazy=lazy, paused=paused, threaded=threaded) + self.lock = QMutex() + self.simulate = "--sim-vision" in sys.argv + self.model = None + + def config_changed(self): + # OBJECT DETECTION + self.detection_threshold = float(self.config[self.name]["detection_threshold"]) + # recipe + self.zones = None + self.labels = None + # LOAD RECIPE + self.recipes_dir = Path(self.config[self.name].get("recipes_dir", "./config/vision/recipes")) + self.set_recipe(None) + self.recipe_watcher = QFileSystemWatcher([]) + self.recipe_watcher.fileChanged.connect(self._set_recipe) + # LOAD MODEL + self.models_dir = Path(self.config[self.name].get("models_dir", "./data/neural_networks")) + self.allowed_modes = dict.fromkeys([ + "edgetpu", + "tflite_cpu", + "normal", + ]) + if "--no-edgetpu" in sys.argv: + self.allowed_modes.pop("edgetpu", None) + if "--no-tflite" in sys.argv: + self.allowed_modes.pop("edgetpu", None) + self.allowed_modes.pop("tflite_cpu", None) + self.load_model(self.config[self.name].get("neural_network", None)) + # LOAD LABELS + label_map = label_map_util.load_labelmap("./config/vision/labels/labels.pbtxt") + self.num_classes = len(label_map.item) + categories = label_map_util.convert_label_map_to_categories(label_map, max_num_classes=self.num_classes, use_display_name=True) + self.category_index = label_map_util.create_category_index(categories) + self.classes_map = {c["name"]: k for k, c in self.category_index.items()} + + def clear_recipe(self): + self.recipe = None + self.points = {} + self.zones = {} + self.labels = {} + + def _set_recipe(self, recipe_path): + recipe_path = str(recipe_path) + self.log.info(f"changing recipe to {recipe_path!r}") + watched = self.recipe_watcher.files() + if recipe_path == "" and len(watched) == 0: # skip bad watcher signals + return + if len(watched) > 0: + self.recipe_watcher.removePaths(watched) + self.recipe_path = recipe_path + try: + if not os.path.isfile(self.recipe_path): + raise AssertionError(f"Recipe file {self.recipe_path!r} could not be found.") + config = ConfigParser(inline_comment_prefixes="#") + read = config.read(self.recipe_path) + if len(read) != 1 or self.recipe_path not in read: + raise AssertionError("Recipe could not be read.") + os.path.splitext(os.path.basename(read[0]))[0] + self.points = self.parse_points(config.get["shapes"]) + self.zones = self.parse_zones(config.get["zones"]) + self.labels = self.parse_labels(config.get("labels", None)) + self.recipe_watcher.addPath(str(self.recipe_path)) + except Exception: + self.log.exception(traceback.format_exc()) + self.log.exception(f"Error reading {self.recipe_path!r}:") + self.clear_recipe() + self.status_signal.emit({ + "recipe": self.recipe, + "points": self.points, + "zones": self.zones, + "labels": self.labels, + }) + + def set_recipe(self, recipe=None): + if recipe is None: + self.clear_recipe() + else: + self._set_recipe(self.recipes_dir / str(recipe)) + + def parse_points(self, config=None): + if config is None: + raise AssertionError(f"Recipe file {self.recipe_path!r} does not contain the 'shapes' section.") + config = {} + points = {} + for point_name, point_spec in config.items(): + try: + center, size, fill_color, border_color, border_thickness, shape = point_spec.split(" ") + center = list(map(float, center.split(","))) + center[1] = -center[1] + size = list(map(float, size.split(","))) + if len(size) == 1: + size = [size[0], size[0]] + fill_color = QColor(fill_color.replace("0x", "#")) + border_color = QColor(border_color.replace("0x", "#")) + border_thickness = float(border_thickness) + shape = shape.lower() + points[point_name] = { + "center": center, + "size": size, + "fill_color": fill_color, + "border_color": border_color, + "border_thickness": border_thickness, + "shape": shape, + } + except Exception: + self.log.exception(traceback.format_exc()) + self.log.exception(f"point {point_name!r} in recipe file {self.recipe_path!r} could not be parsed. spec: {point_spec!r}") + return points + + def parse_zones(self, config=None): + if config is None: + raise AssertionError(f"Recipe file {self.recipe_path!r} does not contain the 'zones' section.") + config = {} + zones = {} + for zone_name, zone_spec in config.items(): + zone_name = zone_name.upper() + try: + center, margin, d_class = zone_spec.split(" ") + center = list(map(float, center.split(","))) + center[1] = -center[1] + if margin == "none": + margin = None + elif "," in margin: + margin = list(map(float, margin.split(","))) + else: + margin = float(margin) + zones[zone_name] = { + "center": center, + "margin": margin, + "class": self.category_index[self.classes_map[d_class]], + } + except Exception: + self.log.exception(traceback.format_exc()) + self.log.exception(f"region {zone_name!r} in recipe file {self.recipe_path!r} could not be parsed. spec: {zone_spec!r}") + return zones + + def parse_labels(self, config=None): + if config is None: + config = {} + labels = {} + for label_name, label_spec in config.items(): + try: + location, font_size, fill_color, border_color, border_thickness, text = label_spec.split(" ", 5) + location = list(map(float, location.split(","))) + location[1] = -location[1] + font_size = float(font_size) + fill_color = QColor(fill_color.replace("0x", "#")) + border_color = QColor(border_color.replace("0x", "#")) + border_thickness = float(border_thickness) + text = text.replace("\\n", "\n").replace("\\t", "\t") + labels[label_name] = { + "location": location, + "font_size": font_size, + "fill_color": fill_color, + "border_color": border_color, + "border_thickness": border_thickness, + "text": text, + } + except Exception: + self.log.exception(traceback.format_exc()) + self.log.exception(f"label {label_name!r} in recipe file {self.recipe_path!r} could not be parsed. spec: {label_spec!r}") + return labels + + def zone_center(self, zone): + return (int((zone["xmax"] + zone["xmin"]) / 2), int((zone["ymax"] + zone["ymin"]) / 2)) + + def get_center(self, rect): + return [(rect[0] + rect[2]) / 2, (rect[1] + rect[3]) / 2] + + def get_distance(self, p1, p2): + return pow((p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2, 1 / 2) + + def load_model(self, model=None): + self.log.info(f"requested neural network: {model!r}") + if model is None or model.lower() in [ + "", + "any", + "last", + "latest", + "newest", + "none", + ]: + model_name = sorted([d for d in os.listdir(self.models_dir) if os.path.isdir(self.models_dir / d)], reverse=True)[0] + self.log.info(f"loading neural network: {model_name!r}") + self.loading_model_signal.emit({"status": "loading"}) + self.lock.lock() + tf_mode = None + # reset tflite variables + interpreter = None + if tf_mode is None and "edgetpu" in self.allowed_modes: + try: + # create tflite edgetpu interpreter + interpreter = make_interpreter(self.models_dir / model_name / f"{model_name}_edgetpu.tflite") + tf_mode = "edgetpu" + except Exception: + self.log.exception(traceback.format_exc()) + if tf_mode is None and "tflite_cpu" in self.allowed_modes: + try: + # create tflite cpu interpreter + interpreter = Interpreter(self.models_dir / model_name / f"{model_name}.tflite") + tf_mode = "tflite_cpu" + except Exception: + self.log.exception(traceback.format_exc()) + # reset tensorflow variables + model = None + if tf_mode is None and "normal" in self.allowed_modes: + try: + # create tensorflow model + model = tf.saved_model.load(self.models_dir / model_name) + tf_mode = "normal" + except Exception: + self.log.exception(traceback.format_exc()) + self.lock.unlock() + if tf_mode is None: + raise RuntimeError("failed initialize any neural network model") + self.tf_mode = tf_mode + self.model_name = model_name + if interpreter is not None: + # if there is a new tflite interpreter initialize it and the related values + interpreter.allocate_tensors() + interpreter.invoke() # warmup + self.tflite_input_details = interpreter.get_input_details() + self.tflite_output_details = interpreter.get_output_details() + self.inf_index = self.tflite_input_details[0]["index"] + self.inf_width = self.tflite_input_details[0]["shape"][2] + self.inf_height = self.tflite_input_details[0]["shape"][1] + else: + self.tflite_input_details = None + self.tflite_output_details = None + self.inf_index = None + self.inf_width = None + self.inf_height = None + self.interpreter = interpreter + # if there is a new model to be used, remove previous model if present + if model is not None and self.model is not None: + tf.keras.backend.clear_session() + self.model = model + self.log.info(f"initialized model {self.model!r} with mode {self.tf_mode!r}") + self.loading_model_signal.emit({"status": "done"}) + + def check_features(self, image, lock=True): + tensor = np.expand_dims(np.asarray(image), axis=0) + # Run inference + if lock: + self.lock.lock() + if self.tf_mode in {"edgetpu", "tflite_cpu"}: + self.interpreter.set_tensor(self.inf_index, tensor) + self.interpreter.invoke() + objs = detect.get_objects(self.interpreter, self.detection_threshold, (1, 1)) + if lock: + self.lock.unlock() + boxes = [(obj.bbox.ymin / self.inf_height, obj.bbox.xmin / self.inf_width, obj.bbox.ymax / self.inf_height, obj.bbox.xmax / self.inf_width) for obj in objs] + classes = [obj.id + 1 for obj in objs] + scores = [obj.score for obj in objs] + detections = { + "detection_boxes": boxes, + "detection_classes": classes, + "detection_scores": scores, + "num_detections": 10, + } + else: + detections = self.model(tensor) + if lock: + self.lock.unlock() + parsed_detections = [] + for d_box, d_class, d_score, d_mask in zip( + detections["detection_boxes"][0], + detections["detection_classes"][0], + detections["detection_scores"][0], + detections["detection_masks"][0], + ): + if d_score < self.detection_threshold: + continue + center = self.get_center(d_box.numpy().tolist()) + detection = { + "class": self.category_index[int(d_class)]["name"], + "color": self.category_index[int(d_class)]["color_qt"], + "score": d_score.numpy().tolist(), + "mask": d_mask.numpy().tolist(), + "box": d_box.numpy().tolist(), + "center": center, + "pos_rel_mm": self.get_pos_rel_mm(center), + } + parsed_detections.append(detection) + return parsed_detections + + def _get(self, data): + # print("VISION", str(int(QThread.currentThreadId())), flush=True) + if not self.lock.tryLock(): + self.log.debug("skipped frame") + return + self.log.debug("detecting...") + detections = self.check_features(data[-1][list(self.sources)[0]], lock=False) + self.lock.unlock() + self.log.debug(f"detected {detections}") + super()._get([detections]) diff --git a/src/components/wires.py b/src/components/wires.py new file mode 100644 index 0000000..87f7950 --- /dev/null +++ b/src/components/wires.py @@ -0,0 +1,172 @@ +import gc +import os +import random +import re +import sys + +import numpy as np +import tensorflow as tf +from lib.helpers import log_msg +from lib.object_detection.utils import label_map_util +from PyQt5.QtCore import QMutex, pyqtSignal, pyqtSlot + +from .input import Input + +# Patch the location of gfile +tf.gfile = tf.io.gfile + + +class Wires(Input): + loading_model_signal = pyqtSignal(dict) + + def __init__(self, bench, name, config): + super().__init__(bench, name, config) + self.simulate = "--sim-vision" in sys.argv + self.source = self.bench.inputs[self.config["source"]] + self.model_name = self.config["model"] + self.num_classes = self.config["num_classes"] + self.matching_distance = self.config["matching_distance"] + self.crop = self.bench.zones["wires"]["box"] + self.subzones = {k: self.bench.zones[k] for k in self.bench.zones if re.search("w[0-9].*", k)} + self.threshold = self.config["threshold"] + if not self.simulate: + # MODEL + self.model = None + self.model_lock = QMutex() + self.load_model(self.model_name) + label_map = label_map_util.load_labelmap("config/vision_test_labels/wires.pbtxt") + categories = label_map_util.convert_label_map_to_categories(label_map, max_num_classes=self.num_classes, use_display_name=True) + self.category_index = label_map_util.create_category_index(categories) + # TO INITIALIZE TENSORFLOW MODELS + if not self.simulate: + img = np.zeros([1, 1, 3], dtype=np.uint8) + input_tensor = tf.convert_to_tensor(img) + input_tensor = input_tensor[tf.newaxis, ...] + self.model_lock.lock() + self.bench.gpu_mutex.lock() + self.model(input_tensor) + self.bench.gpu_mutex.unlock() + self.model_lock.unlock() + self.last_frame = None + + def load_model(self, model_name=None): + log_msg("WIRES NEURAL NETWORK MODEL CHOSEN: {}".format(model_name)) + if model_name.lower() in [ + "", + "any", + "last", + "latest", + "newest", + "none", + None, + ]: + model_name = sorted([d for d in os.listdir("neural_networks") if os.path.isdir(os.path.join("neural_networks", d)) and d.startswith("w")], reverse=True)[0] + self.loading_model_signal.emit({"status": "loading"}) + self.model_lock.lock() + log_msg(f"LOADING WIRES NEURAL NETWORK MODEL: {model_name}", msg_type="tensorflow") + try: + with tf.device('/device:GPU:1'): + model = tf.saved_model.load(f"neural_networks/{model_name}") + # if "--wires-tf-default-signature" in sys.argv: + # model = model.signatures["serving_default"] + self.model_name = model_name + except Exception as e: + self.model_lock.unlock() + self.loading_model_signal.emit({"status": "aborted"}) + raise e + if self.model is not None: + self.model = None + tf.keras.backend.clear_session() + gc.collect() + self.model = model + self.model_lock.unlock() + self.loading_model_signal.emit({"status": "done"}) + + @pyqtSlot(list) + def _get(self, frame): + img = frame[1] + img = img[self.crop[1]:self.crop[3], self.crop[0]:self.crop[2]] + self.last_frame = img + if self.simulate: + w = self.crop[2] - self.crop[0] + h = self.crop[3] - self.crop[1] + detected_zones = {} + for zone_name, zone in self.subzones.items(): + detected_zones[zone_name] = { + "class": random.choice(list(self.category_index.values())), + "score": 1, + "box": [ + (zone["box"][1] - self.crop[1]) / h, + (zone["box"][0] - self.crop[0]) / w, + (zone["box"][3] - self.crop[1]) / h, + (zone["box"][2] - self.crop[0]) / w, + ], + "mask": np.full((15, 15), 0, dtype=np.float32).tolist(), + "center": [ + (zone["center"][1] - self.crop[1]) / h, + (zone["center"][0] - self.crop[0]) / w, + ], + } + # self.update.emit([frame[0], detected_zones, self.name]) + self.out.emit([frame[0], detected_zones, self.name]) + return + input_tensor = tf.convert_to_tensor(np.asarray(img)) + input_tensor = input_tensor[tf.newaxis, ...] + if not self.simulate: + # RUN INFERENCE + self.bench.gpu_mutex.lock() + output = self.model(input_tensor) + self.bench.gpu_mutex.unlock() + else: + output = {"detection_classes": [[None]], "detection_scores": [[0]], "detection_boxes": [[None]], "detection_masks": [[None]]} + # create detections + detections = [] + for d_class, d_score, d_box, d_mask in zip(output["detection_classes"][0], output["detection_scores"][0], output["detection_boxes"][0], output["detection_masks"][0]): + if d_score < self.threshold: + continue + detections.append({ + "class": self.category_index[int(d_class)], + "score": d_score.numpy().tolist(), + "box": d_box.numpy().tolist(), + "mask": d_mask.numpy().tolist(), + "center": self.bench.get_center(d_box.numpy().tolist()), + }) + # match detections with zones + w = self.crop[2] - self.crop[0] + h = self.crop[3] - self.crop[1] + detected_zones = {zone_name: None for zone_name in self.subzones} + for detection in detections: + d_center = [self.crop[0] + detection["center"][1] * w, self.crop[1] + detection["center"][0] * h] + min_distance = sys.maxsize + closest_zone = None + for zone_name, zone in self.subzones.items(): + distance = self.bench.get_distance(d_center, zone["center"]) + if distance < min_distance and distance <= self.matching_distance: + min_distance = distance + closest_zone = zone_name + if closest_zone is None: + continue + if detected_zones[closest_zone] is not None: + old_center = [self.crop[0] + detected_zones[closest_zone]["center"][1] * w, self.crop[1] + detected_zones[closest_zone]["center"][0] * h] + if self.bench.get_distance(old_center, zone["center"]) <= self.bench.get_distance(d_center, zone["center"]): + continue + detected_zones[closest_zone] = detection + for zone_name, zone in self.subzones.items(): + if detected_zones[zone_name] is None: + detected_zones[zone_name] = { + "class": {"id": None, "name": "no_detection", "color": "rgb(0,0,0)", "color_qt": [0, 0, 0]}, + "score": 1, + "box": [ + (zone["box"][1] - self.crop[1]) / h, + (zone["box"][0] - self.crop[0]) / w, + (zone["box"][3] - self.crop[1]) / h, + (zone["box"][2] - self.crop[0]) / w, + ], + "mask": np.full((15, 15), 0, dtype=np.float32).tolist(), + "center": [ + (zone["center"][1] - self.crop[1]) / h, + (zone["center"][0] - self.crop[0]) / w, + ], + } + # self.update.emit([frame[0], detected_zones, self.name]) + self.out.emit([frame[0], detected_zones, self.name]) diff --git a/src/lib/helpers/object_detection/protos/string_int_label_map.proto b/src/lib/helpers/object_detection/protos/string_int_label_map.proto new file mode 100644 index 0000000..6de404d --- /dev/null +++ b/src/lib/helpers/object_detection/protos/string_int_label_map.proto @@ -0,0 +1,61 @@ +// Message to store the mapping from class label strings to class id. Datasets +// use string labels to represent classes while the object detection framework +// works with class ids. This message maps them so they can be converted back +// and forth as needed. +syntax = "proto2"; + +package object_detection.protos; + +// LVIS frequency: +enum LVISFrequency { + UNSPECIFIED = 0; + FREQUENT = 1; + COMMON = 2; + RARE = 3; +} + +message StringIntLabelMapItem { + // String name. The most common practice is to set this to a MID or synsets + // id. + optional string name = 1; + + // Integer id that maps to the string name above. Label ids should start from + // 1. + optional int32 id = 2; + + // Human readable string label. + optional string display_name = 3; + + // Label color for rendering. + optional string color = 9; + + + // Name of class specific keypoints for each class object and their respective + // keypoint IDs. + message KeypointMap { + // Id for the keypoint. Id must be unique within a given class, however, it + // could be shared across classes. For example "nose" keypoint can occur + // in both "face" and "person" classes. Hence they can be mapped to the same + // id. + // + // Note: It is advised to assign ids in range [1, num_unique_keypoints] to + // encode keypoint targets efficiently. + optional int32 id = 1; + // Label for the keypoint. + optional string label = 2; + } + repeated KeypointMap keypoints = 4; + + // Label ids for the elements that are connected in the hierarchy with the + // current element. Value should correspond to another label id element. + repeated int32 ancestor_ids = 5; + repeated int32 descendant_ids = 6; + + // LVIS specific label map fields + optional LVISFrequency frequency = 7; + optional int32 instance_count = 8; +}; + +message StringIntLabelMap { + repeated StringIntLabelMapItem item = 1; +}; diff --git a/src/lib/helpers/object_detection/protos/string_int_label_map_pb2.py b/src/lib/helpers/object_detection/protos/string_int_label_map_pb2.py new file mode 100644 index 0000000..ba71b25 --- /dev/null +++ b/src/lib/helpers/object_detection/protos/string_int_label_map_pb2.py @@ -0,0 +1,258 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: src/lib/helpers/object_detection/protos/string_int_label_map.proto + +from google.protobuf.internal import enum_type_wrapper +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from google.protobuf import reflection as _reflection +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor.FileDescriptor( + name='src/lib/helpers/object_detection/protos/string_int_label_map.proto', + package='object_detection.protos', + syntax='proto2', + serialized_options=None, + create_key=_descriptor._internal_create_key, + serialized_pb=b'\nBsrc/lib/helpers/object_detection/protos/string_int_label_map.proto\x12\x17object_detection.protos\"\xd0\x02\n\x15StringIntLabelMapItem\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\n\n\x02id\x18\x02 \x01(\x05\x12\x14\n\x0c\x64isplay_name\x18\x03 \x01(\t\x12\r\n\x05\x63olor\x18\t \x01(\t\x12M\n\tkeypoints\x18\x04 \x03(\x0b\x32:.object_detection.protos.StringIntLabelMapItem.KeypointMap\x12\x14\n\x0c\x61ncestor_ids\x18\x05 \x03(\x05\x12\x16\n\x0e\x64\x65scendant_ids\x18\x06 \x03(\x05\x12\x39\n\tfrequency\x18\x07 \x01(\x0e\x32&.object_detection.protos.LVISFrequency\x12\x16\n\x0einstance_count\x18\x08 \x01(\x05\x1a(\n\x0bKeypointMap\x12\n\n\x02id\x18\x01 \x01(\x05\x12\r\n\x05label\x18\x02 \x01(\t\"Q\n\x11StringIntLabelMap\x12<\n\x04item\x18\x01 \x03(\x0b\x32..object_detection.protos.StringIntLabelMapItem*D\n\rLVISFrequency\x12\x0f\n\x0bUNSPECIFIED\x10\x00\x12\x0c\n\x08\x46REQUENT\x10\x01\x12\n\n\x06\x43OMMON\x10\x02\x12\x08\n\x04RARE\x10\x03' +) + +_LVISFREQUENCY = _descriptor.EnumDescriptor( + name='LVISFrequency', + full_name='object_detection.protos.LVISFrequency', + filename=None, + file=DESCRIPTOR, + create_key=_descriptor._internal_create_key, + values=[ + _descriptor.EnumValueDescriptor( + name='UNSPECIFIED', index=0, number=0, + serialized_options=None, + type=None, + create_key=_descriptor._internal_create_key), + _descriptor.EnumValueDescriptor( + name='FREQUENT', index=1, number=1, + serialized_options=None, + type=None, + create_key=_descriptor._internal_create_key), + _descriptor.EnumValueDescriptor( + name='COMMON', index=2, number=2, + serialized_options=None, + type=None, + create_key=_descriptor._internal_create_key), + _descriptor.EnumValueDescriptor( + name='RARE', index=3, number=3, + serialized_options=None, + type=None, + create_key=_descriptor._internal_create_key), + ], + containing_type=None, + serialized_options=None, + serialized_start=517, + serialized_end=585, +) +_sym_db.RegisterEnumDescriptor(_LVISFREQUENCY) + +LVISFrequency = enum_type_wrapper.EnumTypeWrapper(_LVISFREQUENCY) +UNSPECIFIED = 0 +FREQUENT = 1 +COMMON = 2 +RARE = 3 + + + +_STRINGINTLABELMAPITEM_KEYPOINTMAP = _descriptor.Descriptor( + name='KeypointMap', + full_name='object_detection.protos.StringIntLabelMapItem.KeypointMap', + filename=None, + file=DESCRIPTOR, + containing_type=None, + create_key=_descriptor._internal_create_key, + fields=[ + _descriptor.FieldDescriptor( + name='id', full_name='object_detection.protos.StringIntLabelMapItem.KeypointMap.id', index=0, + number=1, type=5, cpp_type=1, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='label', full_name='object_detection.protos.StringIntLabelMapItem.KeypointMap.label', index=1, + number=2, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=b"".decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=392, + serialized_end=432, +) + +_STRINGINTLABELMAPITEM = _descriptor.Descriptor( + name='StringIntLabelMapItem', + full_name='object_detection.protos.StringIntLabelMapItem', + filename=None, + file=DESCRIPTOR, + containing_type=None, + create_key=_descriptor._internal_create_key, + fields=[ + _descriptor.FieldDescriptor( + name='name', full_name='object_detection.protos.StringIntLabelMapItem.name', index=0, + number=1, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=b"".decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='id', full_name='object_detection.protos.StringIntLabelMapItem.id', index=1, + number=2, type=5, cpp_type=1, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='display_name', full_name='object_detection.protos.StringIntLabelMapItem.display_name', index=2, + number=3, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=b"".decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='color', full_name='object_detection.protos.StringIntLabelMapItem.color', index=3, + number=9, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=b"".decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='keypoints', full_name='object_detection.protos.StringIntLabelMapItem.keypoints', index=4, + number=4, type=11, cpp_type=10, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='ancestor_ids', full_name='object_detection.protos.StringIntLabelMapItem.ancestor_ids', index=5, + number=5, type=5, cpp_type=1, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='descendant_ids', full_name='object_detection.protos.StringIntLabelMapItem.descendant_ids', index=6, + number=6, type=5, cpp_type=1, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='frequency', full_name='object_detection.protos.StringIntLabelMapItem.frequency', index=7, + number=7, type=14, cpp_type=8, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='instance_count', full_name='object_detection.protos.StringIntLabelMapItem.instance_count', index=8, + number=8, type=5, cpp_type=1, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + ], + extensions=[ + ], + nested_types=[_STRINGINTLABELMAPITEM_KEYPOINTMAP, ], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=96, + serialized_end=432, +) + + +_STRINGINTLABELMAP = _descriptor.Descriptor( + name='StringIntLabelMap', + full_name='object_detection.protos.StringIntLabelMap', + filename=None, + file=DESCRIPTOR, + containing_type=None, + create_key=_descriptor._internal_create_key, + fields=[ + _descriptor.FieldDescriptor( + name='item', full_name='object_detection.protos.StringIntLabelMap.item', index=0, + number=1, type=11, cpp_type=10, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=434, + serialized_end=515, +) + +_STRINGINTLABELMAPITEM_KEYPOINTMAP.containing_type = _STRINGINTLABELMAPITEM +_STRINGINTLABELMAPITEM.fields_by_name['keypoints'].message_type = _STRINGINTLABELMAPITEM_KEYPOINTMAP +_STRINGINTLABELMAPITEM.fields_by_name['frequency'].enum_type = _LVISFREQUENCY +_STRINGINTLABELMAP.fields_by_name['item'].message_type = _STRINGINTLABELMAPITEM +DESCRIPTOR.message_types_by_name['StringIntLabelMapItem'] = _STRINGINTLABELMAPITEM +DESCRIPTOR.message_types_by_name['StringIntLabelMap'] = _STRINGINTLABELMAP +DESCRIPTOR.enum_types_by_name['LVISFrequency'] = _LVISFREQUENCY +_sym_db.RegisterFileDescriptor(DESCRIPTOR) + +StringIntLabelMapItem = _reflection.GeneratedProtocolMessageType('StringIntLabelMapItem', (_message.Message,), { + + 'KeypointMap' : _reflection.GeneratedProtocolMessageType('KeypointMap', (_message.Message,), { + 'DESCRIPTOR' : _STRINGINTLABELMAPITEM_KEYPOINTMAP, + '__module__' : 'src.lib.helpers.object_detection.protos.string_int_label_map_pb2' + # @@protoc_insertion_point(class_scope:object_detection.protos.StringIntLabelMapItem.KeypointMap) + }) + , + 'DESCRIPTOR' : _STRINGINTLABELMAPITEM, + '__module__' : 'src.lib.helpers.object_detection.protos.string_int_label_map_pb2' + # @@protoc_insertion_point(class_scope:object_detection.protos.StringIntLabelMapItem) + }) +_sym_db.RegisterMessage(StringIntLabelMapItem) +_sym_db.RegisterMessage(StringIntLabelMapItem.KeypointMap) + +StringIntLabelMap = _reflection.GeneratedProtocolMessageType('StringIntLabelMap', (_message.Message,), { + 'DESCRIPTOR' : _STRINGINTLABELMAP, + '__module__' : 'src.lib.helpers.object_detection.protos.string_int_label_map_pb2' + # @@protoc_insertion_point(class_scope:object_detection.protos.StringIntLabelMap) + }) +_sym_db.RegisterMessage(StringIntLabelMap) + + +# @@protoc_insertion_point(module_scope) diff --git a/src/lib/helpers/object_detection/utils/label_map_util.py b/src/lib/helpers/object_detection/utils/label_map_util.py new file mode 100644 index 0000000..fe4d246 --- /dev/null +++ b/src/lib/helpers/object_detection/utils/label_map_util.py @@ -0,0 +1,366 @@ +# Copyright 2017 The TensorFlow Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== +"""Label map utility functions.""" + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +import collections +import logging + +import numpy as np +from six import string_types +from six.moves import range +import tensorflow.compat.v1 as tf +from google.protobuf import text_format +from lib.helpers.object_detection.protos import string_int_label_map_pb2 + +_LABEL_OFFSET = 1 + + +def _validate_label_map(label_map): + """Checks if a label map is valid. + + Args: + label_map: StringIntLabelMap to validate. + + Raises: + ValueError: if label map is invalid. + """ + for item in label_map.item: + if item.id < 0: + raise ValueError('Label map ids should be >= 0.') + if (item.id == 0 and item.name != 'background' and + item.display_name != 'background'): + raise ValueError('Label map id 0 is reserved for the background label') + + +def create_category_index(categories): + """Creates dictionary of COCO compatible categories keyed by category id. + + Args: + categories: a list of dicts, each of which has the following keys: + 'id': (required) an integer id uniquely identifying this category. + 'name': (required) string representing category name + e.g., 'cat', 'dog', 'pizza'. + + Returns: + category_index: a dict containing the same entries as categories, but keyed + by the 'id' field of each category. + """ + category_index = {} + for cat in categories: + category_index[cat['id']] = cat + return category_index + + +def get_max_label_map_index(label_map): + """Get maximum index in label map. + + Args: + label_map: a StringIntLabelMapProto + + Returns: + an integer + """ + return max([item.id for item in label_map.item]) + + +def convert_label_map_to_categories(label_map, + max_num_classes, + use_display_name=True): + """Given label map proto returns categories list compatible with eval. + + This function converts label map proto and returns a list of dicts, each of + which has the following keys: + 'id': (required) an integer id uniquely identifying this category. + 'name': (required) string representing category name + e.g., 'cat', 'dog', 'pizza'. + 'keypoints': (optional) a dictionary of keypoint string 'label' to integer + 'id'. + We only allow class into the list if its id-label_id_offset is + between 0 (inclusive) and max_num_classes (exclusive). + If there are several items mapping to the same id in the label map, + we will only keep the first one in the categories list. + + Args: + label_map: a StringIntLabelMapProto or None. If None, a default categories + list is created with max_num_classes categories. + max_num_classes: maximum number of (consecutive) label indices to include. + use_display_name: (boolean) choose whether to load 'display_name' field as + category name. If False or if the display_name field does not exist, uses + 'name' field as category names instead. + + Returns: + categories: a list of dictionaries representing all possible categories. + """ + categories = [] + list_of_ids_already_added = [] + if not label_map: + label_id_offset = 1 + for class_id in range(max_num_classes): + categories.append({ + 'id': class_id + label_id_offset, + 'name': 'category_{}'.format(class_id + label_id_offset) + }) + return categories + for item in label_map.item: + if not 0 < item.id <= max_num_classes: + logging.info( + 'Ignore item %d since it falls outside of requested ' + 'label range.', item.id) + continue + if use_display_name and item.HasField('display_name'): + name = item.display_name + else: + name = item.name + if item.id not in list_of_ids_already_added: + list_of_ids_already_added.append(item.id) + category = {'id': item.id, 'name': name} + if item.HasField('frequency'): + if item.frequency == string_int_label_map_pb2.LVISFrequency.Value( + 'FREQUENT'): + category['frequency'] = 'f' + elif item.frequency == string_int_label_map_pb2.LVISFrequency.Value( + 'COMMON'): + category['frequency'] = 'c' + elif item.frequency == string_int_label_map_pb2.LVISFrequency.Value( + 'RARE'): + category['frequency'] = 'r' + if item.HasField('instance_count'): + category['instance_count'] = item.instance_count + if item.keypoints: + keypoints = {} + list_of_keypoint_ids = [] + for kv in item.keypoints: + if kv.id in list_of_keypoint_ids: + raise ValueError('Duplicate keypoint ids are not allowed. ' + 'Found {} more than once'.format(kv.id)) + keypoints[kv.label] = kv.id + list_of_keypoint_ids.append(kv.id) + category['keypoints'] = keypoints + categories.append(category) + return categories + + +def load_labelmap(path): + """Loads label map proto. + + Args: + path: path to StringIntLabelMap proto text file. + Returns: + a StringIntLabelMapProto + """ + with tf.io.gfile.GFile(path, 'r') as fid: + label_map_string = fid.read() + label_map = string_int_label_map_pb2.StringIntLabelMap() + try: + text_format.Merge(label_map_string, label_map) + except text_format.ParseError: + label_map.ParseFromString(label_map_string) + _validate_label_map(label_map) + return label_map + + +def get_label_map_dict(label_map_path_or_proto, + use_display_name=False, + fill_in_gaps_and_background=False): + """Reads a label map and returns a dictionary of label names to id. + + Args: + label_map_path_or_proto: path to StringIntLabelMap proto text file or the + proto itself. + use_display_name: whether to use the label map items' display names as keys. + fill_in_gaps_and_background: whether to fill in gaps and background with + respect to the id field in the proto. The id: 0 is reserved for the + 'background' class and will be added if it is missing. All other missing + ids in range(1, max(id)) will be added with a dummy class name + ("class_") if they are missing. + + Returns: + A dictionary mapping label names to id. + + Raises: + ValueError: if fill_in_gaps_and_background and label_map has non-integer or + negative values. + """ + if isinstance(label_map_path_or_proto, string_types): + label_map = load_labelmap(label_map_path_or_proto) + else: + _validate_label_map(label_map_path_or_proto) + label_map = label_map_path_or_proto + + label_map_dict = {} + for item in label_map.item: + if use_display_name: + label_map_dict[item.display_name] = item.id + else: + label_map_dict[item.name] = item.id + + if fill_in_gaps_and_background: + values = set(label_map_dict.values()) + + if 0 not in values: + label_map_dict['background'] = 0 + if not all(isinstance(value, int) for value in values): + raise ValueError('The values in label map must be integers in order to' + 'fill_in_gaps_and_background.') + if not all(value >= 0 for value in values): + raise ValueError('The values in the label map must be positive.') + + if len(values) != max(values) + 1: + # there are gaps in the labels, fill in gaps. + for value in range(1, max(values)): + if value not in values: + # TODO(rathodv): Add a prefix 'class_' here once the tool to generate + # teacher annotation adds this prefix in the data. + label_map_dict[str(value)] = value + + return label_map_dict + + +def get_keypoint_label_map_dict(label_map_path_or_proto): + """Reads a label map and returns a dictionary of keypoint names to ids. + + Note that the keypoints belong to different classes will be merged into a + single dictionary. It is expected that there is no duplicated keypoint names + or ids from different classes. + + Args: + label_map_path_or_proto: path to StringIntLabelMap proto text file or the + proto itself. + + Returns: + A dictionary mapping keypoint names to the keypoint id (not the object id). + + Raises: + ValueError: if there are duplicated keyoint names or ids. + """ + if isinstance(label_map_path_or_proto, string_types): + label_map = load_labelmap(label_map_path_or_proto) + else: + label_map = label_map_path_or_proto + + label_map_dict = {} + for item in label_map.item: + for kpts in item.keypoints: + if kpts.label in label_map_dict.keys(): + raise ValueError('Duplicated keypoint label: %s' % kpts.label) + if kpts.id in label_map_dict.values(): + raise ValueError('Duplicated keypoint ID: %d' % kpts.id) + label_map_dict[kpts.label] = kpts.id + return label_map_dict + + +def get_label_map_hierarchy_lut(label_map_path_or_proto, + include_identity=False): + """Reads a label map and returns ancestors and descendants in the hierarchy. + + The function returns the ancestors and descendants as separate look up tables + (LUT) numpy arrays of shape [max_id, max_id] where lut[i,j] = 1 when there is + a hierarchical relationship between class i and j. + + Args: + label_map_path_or_proto: path to StringIntLabelMap proto text file or the + proto itself. + include_identity: Boolean to indicate whether to include a class element + among its ancestors and descendants. Setting this will result in the lut + diagonal being set to 1. + + Returns: + ancestors_lut: Look up table with the ancestors. + descendants_lut: Look up table with the descendants. + """ + if isinstance(label_map_path_or_proto, string_types): + label_map = load_labelmap(label_map_path_or_proto) + else: + _validate_label_map(label_map_path_or_proto) + label_map = label_map_path_or_proto + + hierarchy_dict = { + 'ancestors': collections.defaultdict(list), + 'descendants': collections.defaultdict(list) + } + max_id = -1 + for item in label_map.item: + max_id = max(max_id, item.id) + for ancestor in item.ancestor_ids: + hierarchy_dict['ancestors'][item.id].append(ancestor) + for descendant in item.descendant_ids: + hierarchy_dict['descendants'][item.id].append(descendant) + + def get_graph_relations_tensor(graph_relations): + graph_relations_tensor = np.zeros([max_id, max_id]) + for id_val, ids_related in graph_relations.items(): + id_val = int(id_val) - _LABEL_OFFSET + for id_related in ids_related: + id_related -= _LABEL_OFFSET + graph_relations_tensor[id_val, id_related] = 1 + if include_identity: + graph_relations_tensor += np.eye(max_id) + return graph_relations_tensor + + ancestors_lut = get_graph_relations_tensor(hierarchy_dict['ancestors']) + descendants_lut = get_graph_relations_tensor(hierarchy_dict['descendants']) + return ancestors_lut, descendants_lut + + +def create_categories_from_labelmap(label_map_path, use_display_name=True): + """Reads a label map and returns categories list compatible with eval. + + This function converts label map proto and returns a list of dicts, each of + which has the following keys: + 'id': an integer id uniquely identifying this category. + 'name': string representing category name e.g., 'cat', 'dog'. + 'keypoints': a dictionary of keypoint string label to integer id. It is only + returned when available in label map proto. + + Args: + label_map_path: Path to `StringIntLabelMap` proto text file. + use_display_name: (boolean) choose whether to load 'display_name' field + as category name. If False or if the display_name field does not exist, + uses 'name' field as category names instead. + + Returns: + categories: a list of dictionaries representing all possible categories. + """ + label_map = load_labelmap(label_map_path) + max_num_classes = max(item.id for item in label_map.item) + return convert_label_map_to_categories(label_map, max_num_classes, + use_display_name) + + +def create_category_index_from_labelmap(label_map_path, use_display_name=True): + """Reads a label map and returns a category index. + + Args: + label_map_path: Path to `StringIntLabelMap` proto text file. + use_display_name: (boolean) choose whether to load 'display_name' field + as category name. If False or if the display_name field does not exist, + uses 'name' field as category names instead. + + Returns: + A category index, which is a dictionary that maps integer ids to dicts + containing categories, e.g. + {1: {'id': 1, 'name': 'dog'}, 2: {'id': 2, 'name': 'cat'}, ...} + """ + categories = create_categories_from_labelmap(label_map_path, use_display_name) + return create_category_index(categories) + + +def create_class_agnostic_category_index(): + """Creates a category index with a single `object` class.""" + return {1: {'id': 1, 'name': 'object'}} diff --git a/src/main.py b/src/main.py index 6820d0e..fba07fc 100644 --- a/src/main.py +++ b/src/main.py @@ -48,8 +48,10 @@ logging.basicConfig( try: # IMPORT PROJECT ONLY AFTER SETTING UP SIGNAL, FAULTHANDLER AND LOGGHING - from components import (ArchiveSynchronizer, Os_Label_Printer, RemoteAPI, - TecnaMarpossProvasetT3, TestComponent, VisionSaver) + from components import (ArchiveSynchronizer, GalaxyCamera, + Os_Label_Printer, RemoteAPI, + TecnaMarpossProvasetT3, TestComponent, Vision, + VisionSaver) from lib.db import Users from lib.helpers import ConfigReader from PyQt5.QtCore import QObject, QThread, pyqtSignal @@ -74,11 +76,13 @@ try: # INIT COMPONENT self.components_specs = { "archive_synchronizer": {"c": ArchiveSynchronizer}, - "remote_api": {"c": RemoteAPI, "k": {"main": self}}, - "test_component": {"c": TestComponent}, - "vision_savert": {"c": VisionSaver, "t": False}, + "galaxy_camera": {"c": GalaxyCamera}, "label_printer": {"c": Os_Label_Printer, "t": False}, + "remote_api": {"c": RemoteAPI, "k": {"main": self}}, "tecna_marposs_provaset_t3": {"c": TecnaMarpossProvasetT3}, + "test_component": {"c": TestComponent}, + "vision_saver": {"c": VisionSaver, "t": False}, + "vision": {"c": Vision}, } self.components = {} self.threads = {} @@ -92,11 +96,17 @@ try: component = self.components[component_name] thread.started.connect(component.start) thread.start() - component.wait_ready() + if component_name == "vision": + component.wait_ready(timeout=60) + else: + component.wait_ready() except Exception as e: logging.exception(traceback.format_exc()) QMessageBox.critical(None, "Errore Banco", f"Non e stato possibile connettersi al banco:\n\n{e}") quit() + # CONNECT VISION WORKFLOW + self.components["vision"].set_sources({"galaxy_camera": self.components["galaxy_camera"].out}) + self.components["vision"].wait_ready() # GUI INIT # self.main_window = Main_Window(self.bench) self.main_window = Main_Window() @@ -124,7 +134,7 @@ try: elif "--full-screen" in sys.argv: self.main_window.showFullScreen() else: - self.main_window.show() + self.main_window.showFullScreen() def open_archive(self): self.main_window.open_dialog(Archive()) diff --git a/src/requirements.txt b/src/requirements.txt index d7726bb..9746b87 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -1,12 +1,16 @@ +--extra-index-url https://google-coral.github.io/py-repo/ argon2-cffi bottle google-cloud-storage +imutils numpy opencv-python-headless peewee pillow +pycoral pymodbus pyqt5 pyserial requests +tensorflow zebra diff --git a/src/ui/imgs/camera.png b/src/ui/imgs/camera.png new file mode 100644 index 0000000000000000000000000000000000000000..0cda7152b91b267ddf54d94fa9aef38426770bba GIT binary patch literal 53845 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 PC_!>k%Fj?wjo