realizator 06 февраля 2020

Сравниваем скорость С++ и Python на примере стереозрения в OpenCV на Raspberry Pi

Мы часто слышим «Python слишком медленный для компьютерного зрения», особенно когда дело касается одноплатных компьютеров типа Raspberry Pi. Давайте разберемся на примере практической задачи.
5

Так что же на самом деле у него с производительностью в задачах компьютерного зрения? Где именно он медленнее C++, и насколько? Ответ на этот вопрос не так однозначен. Например, на Raspberry Pi при построении карты глубин с помощью кода на Python «под капотом» используются бинарные библиотеки, написанные на C++. Они отлично оптимизированы именно под малиновый процессор (Neon и всё такое), и этот код очень шустро работает!

В этой статье мы решили измерить реальную разницу в скорости и найти «бутылочное горлышко» производительности. Подход очень простой. У нас есть серия небольших программ на Python, позволяющая пройти все шаги от первого запуска стереокамеры и ее калибровки до построения карты глубин по видео в реальном времени (и 2D карты пространства в режиме, эмулирующем работу 2D лидара). Мы перенесли весь этот код на C++, и сравниваем производительность на каждом этапе. В качестве «железа» выступает Raspberry Pi Compute Module 3+ Lite, установленный в плату расширения StereoPi.

StereoPi, Raspberry Pi Compute Module 3+ Lite, и 2 камеры Waveshare (160 градусов)
StereoPi, Raspberry Pi Compute Module 3+ Lite, и 2 камеры Waveshare (160 градусов)

Перед тем как мы начнем

Мы живем в эпоху Twitter, Instagram и прочих сервисов коротких сообщений. Поэтому для простоты восприятия мы разделили статью на две секции. Первая – краткий и сжатый обзор экспериментов и производительности. Вторая – подробный разбор полетов и найденных багов, который будет интересен тем, кто решит повторить эксперименты.

Приступим!

Итак, у нас есть стереокамера StereoPi на базе raspberry Pi Compute Module 3+, и мы хотим получить карту глубин по видео в реальном времени. Для этого нам надо пройти несколько этапов:

- Собрать устройство и проверить, что все работает правильно

- Сделать серию снимков для калибровки стереокамеры

- Откалибровать стереокамеру, используя сделанные снимки

- Настроить параметры карты глубин

- Получить карту глубин по видео в реальном времени

- Получить 2D карту пространства в реальном времени

Для каждого из этих шагов у нас есть готовые скрипты на Питоне и они же, перенесенные на C++. На GitHub питоновые скрипты живут тут, а C++ вот тут.

Шаг 1 – тестируем скорость захвата видео

На этом этапе мы просто захватываем стереоскопическое видео с камер и отображаем его на экране. В Python мы используем для этого библиотеку PiCamera, а на C++ – передачу через pipe от самого шустрого из нативных приложений, а именно raspividyuv. Подробности можно прочитать ниже в разделе «Детальный разбор полетов».

Код на C++

Вот как выглядит процедура компиляции и запуска на экране:

Компилируем пример, и сначала запустим код с разрешением стереопары 640х240, то есть с двумя картинками 320х240.

Компилируем:

        g++ /home/pi/stereopi-cpp-tutorial/src/script1.cpp -o /home/pi/stereopi-cpp-tutorial/bin/script1.bin -I/usr/local/include/opencv4 -L/usr/local/lib -lopencv_core -lopencv_highgui -lopencv_imgcodecs -lopencv_imgproc -lopencv_calib3d


    

Запускаем:

        raspividyuv -3d sbs -w 640 -h 240 -fps 30 --luma -t 0 -n -o - | /home/pi/stereopi-cpp-tutorial/bin/script1.bin
    

Выходим из скрипта по кнопке Q и видим:

        Average FPS: 30.3030
    

Давайте попробуем забрать 90 FPS, указав данный параметр в raspividyuv:

        raspividyuv -3d sbs -w 640 -h 240 -fps 90 --luma -t 0 -n -o - | /home/pi/stereopi-cpp-tutorial/bin/script1.bin
    

Итог – 90.9 FPS.

А где же предел? Ставим 150 FPS:

        raspividyuv -3d sbs -w 640 -h 240 -fps 150 --luma -t 0 -n -o - | /home/pi/stereopi-cpp-tutorial/bin/script1.bin
    

Итог:

        Average FPS: 90.909088
    

Ну что-же, имеем лимит 90 fps. Неплохо!

А теперь попробуем захват на большем разрешении. Для этого делаем два изменения:

  1. В коде указываем разрешение 1280х480
  2. В параметрах raspividyuv указываем разрешение 1280х480

Компилируем, а затем запускаем вот с такими параметрами:

        raspividyuv -3d sbs -w 1280 -h 480 -fps 90 --luma -t 0 -n -o - | /home/pi/stereopi-cpp-tutorial/bin/script1.bin
    

Итог – 39 FPS.

Мы прогнали этот код с разными FPS, и вот что у нас получилось:

Разрешение FPS запрос/результат FPS запрос/результат FPS запрос/результат FPS оптимум
640х240 250 / 90.9 150 / 90.9 90 / 90.9 Up to 90
1280х480 150 / 43.5 90 / 43.47 45 /45.5 Up to 40

Замечено, что если выставлять FPS для raspividyuv существенно выше достижимого, то наблюдается небольшая задержка видео относительно реального времени.

Код на Python

Вот как выглядит работа этого скрипта:

Итак, пытаемся захватить картинку 768х240, указав желаемый FPS на 30. (почему разрешение именно такое – см. в секции «Детальный разбор полетов»)

Идем в папку stereopi-fisheye-robot, и запускаем:

        python 1_test.py
    

Мы видим, что при движении в кадре картинка сильно «тормозит», и после остановки скрипта средний FPS равен 9.75

Хм, результат не очень хороший. Запросили 30, а получили 9.7 FPS. Но есть нюанс. В отличие от решения на C++, Питон гораздо более чувствителен к корректной установке FPS. Если вы запрашиваете существенно больше, чем нужно, вы получите не максимально доступное FPS, а его резкое падение.

Давайте захватим нашу картинку при 20 FPS.

Итог: FPS 20.8211

Другое дело! Мы вдвое увеличили эффективность захвата, указав корректный для данной ситуации FPS.

Вот краткая сводка запрошенных и полученных FPS нашего скрипта номер один:

Разрешение (sbs стерео) FPS запрос/результат FPS запрос/результат FPS запрос/результат FPS оптимум
768х240 50 / 7.99 30 / 9.44 20 / 20.8 20
1280х480 50 / 5.65 30 / 5.26 20 /15.4 15
640х240 (скейл силами GPU) 50 / 6.1 30 / 13.94 20 / 19.96 20

Итог сравнения скорости захвата кадров силами кода на C++ и на Python, с рекомендуемыми установками:

1280x480 (stereo) 640x240 (stereo)
C++ 40 fps 90 fps
Python 15 fps 20 fps

СКРИПТ 2

На втором этапе мы делаем серию снимков с шахматной доской для последующей калибровки. Тут нет необходимости сравнивать производительность, так как сохранение серии снимков через каждые 3-5 секунд не представляет трудности ни для кода на C++, ни для Python.

Для этого этапа можно отметить лишь два отличия:

  • Код на C++ показывает ЧБ картинку
  • Код на С++ позволяет показывать окно предпросмотра фотографии с более высоким FPS

В остальном разницы между скриптами нет.

Компиляция и запуск примера на C++:

Запуск кода на Python:

Скрипт 3 – нарезка на пары

Этот скрипт имеет очень простую логику, поэтому мы не стали выносить его в отдельный бинарник на С++, а добавили его функции в код предыдущей программы.

Поэтому в нашем коде на С++ можно сразу переходить к скрипту 4, а в коде на Питоне нужно запустить 3_pairs_cut.py для нарезки картинок на пары. Процесс резки на пары скриптом Python мы показали в конце предыдущего видео.

Для чистоты эксперимента наших дальнейших замеров, мы скопировали все изображения (папку scenes), снятые с помощью кода на C++, в аналогичную папку скриптов питона, а затем запустили скрипт нарезки. Таким образом, оба решения у нас будут работать с одинаковым набором изображений.

Скрипт 4 – калибровка

Результаты:

Python: 17 секунд импорт (и поиск доски), 13 секунд калибровка

C++: импорт 19 секунд, калибровка 9,27

Как видим, питон импортирует картинки чуточку быстрее, а код на C++ быстрее производит процедуру калибровки. Но общие показатели примерно равны. Интересно то, что одинаковый код с одинаковыми настройками с разной эффективностью находит на изображениях шахматную доску (Python оказался лучше). Подробности, как мы и договорились, во второй секции статьи.

Видео работы кода калибровки на C++:

Видео работы кода калибровки на Python:

Скрипт 5 – настройка параметров карты глубин

На данном шаге мы проводим настройку параметров карты глубин, чтобы ее качество нас устраивало. Сразу скажем, что тут сравнение не в пользу Python. Для отображения карты глубин на Python мы использовали библиотеку matplotlib. Она гибкая, удобная, но не предназначена для отображения данных, быстро изменяющихся в реальном времени. Поэтому с момента изменения любого параметра до отображения обновленной карты глубин проходит чуть меньше секунды. И для настроек мы используем лишь одно статичное изображение.

А вот на C++ руки у нас развязаны, поэтому мы смогли сделать настройку по видео в реальном времени.

Вот как это выглядит на Python:

А вот как на C++:

Скрипт 6 – карта глубин по видео

Ну вот мы и дошли до одной из самых интересных частей – скорость работы кода на прикладной задаче.

Вот как выглядит работа кода на Python. Чтобы видео не было скучным, мы сначала запустим скрипт с обычными параметрами, покажем частую проблему «прыгающих цветов» на карте глубин, а затем включим автонастройку цвета и посмотрим на карту глубин еще раз.

В процессе работы скрипт выводит среднее время построения каждой карты глубин. Мы видим, что оно варьируется в пределах от 0.05 до 0.1 секунды. В итоге мы имеем примерно 17 FPS. Замечу, что FPS может зависеть от ваших настроек построения карты глубин!

А теперь та-же задача, но на C++:

Средний результат – тоже примерно 17 FPS.

Пара выводов:

  • И код на Python, и код на C++ дают примерно одинаковую производительность при расчете карты глубин. Как мы упоминали в начале статьи, Python для этих расчетов вызывает бинарные библиотеки, которые хорошо оптимизированы под процессор Raspberry Pi.
  • Несмотря на то, что C++ способен захватывать видео при гораздо большем FPS, это преимущество теряет смысл, так как система не успевает считать карту глубин с нужной скоростью.

Скрипт 7 – 2D карта пространства в режиме сканирующего лидара

Идея этого скрипта очень проста. Мы будем строить карту глубин не по всему изображению, а лишь по его части – это горизонтальная полоса по центру изображения, высотой в треть от всего кадра. В этом случае работа стереокамеры больше похожа на работу сканирующего дальномера. Меняя высоту этой полосы, вы можете регулировать чувствительность системы. После получения карты глубин мы делаем ее проекцию на плоскость, и получаем двумерную карту препятствий для робота.

Вот как выглядит работа кода на Python:

И вот вариант на C++:

Вывод по этим тестам. Мы считаем карту глубин лишь по части изображения, что снижает нагрузку и повышает FPS получаемой карты. Но для кода на Python это не дает прироста производительности, так как узким местом является процесс захвата кадров – наш код не может делать это быстрее. А вот код на C++ имеет запас по скорости захвата кадров из видео (до 90FPS), и тут мы имеем неплохой рост. Скажу только, что скорость существенно переваливает за 30 карт в секунду. Точные замеры мы оставляем нашим читателям. ☺

Итоговые выводы по сравнению производительности Python и C++

В большинстве рассмотренных примеров код на C++ оказывается значительно быстрее, но на ключевой задаче – расчете карты глубин по видео – производительность решений одинаковая. Узким местом является пиковая производительность CPU при расчете карты глубин (мы имеем чуть меньше 20 FPS и в C++, и на Python). Так что для тех, кто начинает изучать компьютерное зрение, Python прекрасно подходит.

И второй вывод. Узким местом текущего кода на Python является скорость захвата кадров из видео. На наше счастье, эффективность захвата кадров примерно совпадает с эффективностью расчета карты глубин (порядка 20 кадров в секунду в обоих случаях). Но мы оставили несколько лазеек для тех, кому нужно больше скорости при захвате кадров.

Что можно улучшить?

На самом деле, в Python можно использовать способ захвата, аналогичный применяемому в коде на C++ – передача видео через pipe шустрой утилитой raspividyuv. Второй подход – решение, предложенное автором PiCamera в одном из ответов на Raspberry.stackexchange.

В бинарном коде построения карты глубин не реализована многопоточность, поэтому в работе используется примерно полтора ядра из четырех. Теоретически у нас есть запас на двукратный рост скорости построения карты глубин.

Раздел 2 – детальный разбор полетов

Подготовка образа Raspbian

Ну что, с места в карьер:

  • OpenCV 4.1.0 для использования с C++ был собран из исходников по инструкции на pyimagesearch.com На большинство вопросов по возникающим ошибкам можно найти ответы в комментариях к этой статье.
  • Для успешной компиляции OpenCV из исходников мы добавили дополнительные swap-разделы. В нашем образе Raspbian они присутствуют, так что вы можете пересобрать OpenCV целиком при необходимости.
  • Версии OpenCV: для C++ – версия 4.1.1, для Python – версия 4.1.0 (устанавливался через pip).
  • Дефолтно используем Python 3 (в биндингах прописан запуск python3 на python).
  • Для включения поддержки второй камеры достаточно положить наш dt-blob.bin в раздел /BOOT. В нашем образе данный файл уже присутствует.
  • В последних ядрах Raspbian случайно поломана поддержка стереорежима. Причина – новый алгоритм баланса белого. Спасибо инженеру Raspberry Pi Foundation за предложенное решение – можно переключить AWB в старый режим, и стерео работает. Команду переключения (sudo vcdbg set awb_mode 0) мы добавили в /etc/rc.local, и она выполняется автоматически.
  • raspividyuv получил поддержку видео относительно недавно, и в последних доступных для скачивания версий Raspbian её нет (по состоянию на январь 2020). Для включения стерео необходимо сделать sudo rpi-update.
  • После rpi-update или даже полного apt-get upgrade возникает ошибка, когда файловый менеджер сам закрывается сразу после открытия. Оказывается, Buster обновил интерфейс. Это лечится командой full-upgrade.
  • Про версии кода. На данный момент нашим самым популярным репозиторием является stereopi-tutorial На самом деле, используемый в статье новый репозиторий, stereopi-fisheye-robot, является более свежей версией, которую мы берем за основу для дальнейшего развития. Помимо поддержки более свежих версий OpenCV, в ней реализована поддержка fisheye-камер, и убрана зависимость от внешних библиотек калибровки. Весь код отлично работает и с обычными камерами (не fisheye).

Скрипт 1 – захват видео, нюансы

C++ и особенности захвата видео

  • Для захвата видео на C++ вы можете воспользоваться низкоуровневым MMAL API. Именно с его использованием и написаны основные приложения raspivid, raspividyuv и другие из этой группы, исходники которых можно найти тут на GitHub. Мы хотели сохранить максимальную близость кода Python и C++, поэтому не стали усложнять программы использованием этих функций.
  • Нами было взято самое быстрое нативное приложение для захвата кадров из видео – raspividyuv. Более того, мы решили выжать из него максимум с помощью настроек. Карта глубин строится по ЧБ картинке, и цвет нам не нужен. Используемая в наших командах запуска опция --luma позволяет передавать только альфа-канал, или черно-белую составляющую изображения. Таким образом, мы передаем в нашу программу в 3 раза меньше данных, чем было бы при передаче цветной картинки.
  • Наше видео от raspividyuv идет в формате RAW. Принимающая сторона по получаемым данным не может понять, где заканчивается один кадр и начинается следующий. Поэтому если вы укажете на принимающей стороне одно разрешение, а в raspividyuv другое – возможны казусы типа вот такого:

Python и особенности захвата видео

Первое, что вам надо знать – PiCamera это очень круто! В отлично написанной документации вы можете найти много уникальной информации об особенностях работы видеосистемы Raspberry Pi (которой больше нет нигде!), и я рекомендую читать ее перед сном. ☺ Просто взгляните, например, на раздел Advanced recipes!

В различных источниках информации можно собрать следующие требования к разрешению картинок, которые не вызывают сложностей при захвате стереоизображения:

- высота итоговой картинки должна быть кратна 16

- ширина должна быть кратна 32

- ширина каждого из изображений стереопары должна быть кратна 128

Пугает, да? Вот пример картинки, захваченной с неверно установленными параметрами:

Тут мы попытались захватить стереокартинку с разрешением 640x240 (два кадра по 320x240). Но 320 не кратно 128. Видно, что кадр с левой камеры более узкий (256 пикселей), а правый нормальной ширины (320 пикселей).

Но пугаться не надо. Есть два пути:

  • либо установить разрешение согласно правилам 384x240 для каждого кадра (или 768x240 для стереокартинки)
  • либо захватывать большее разрешение, и уменьшать до требуемого силами GPU (это вообще решает множество проблем). В нашем коде мы захватываем 1280x480, и уменьшаем картинку в два раза (значение scale_ratio установлено в 0.5 в строке 48 первого скрипта на Python). Мы рекомендуем этот способ, так как он сильно упрощает жизнь (и, кстати, не нагружает дополнительно процессор).

Повторим тут часть из первого раздела статьи. На самом деле, в Python можно использовать способ захвата, аналогичный применяемому в коде на C++ – передача видео через pipe шустрой утилитой raspividyuv. Второй подход – решение, предложенное автором PiCamera в одном из ответов на Raspberry.stackexchange (метод numpy.frombuffer, без лишнего копирования данных в памяти).

Скрипт 4 – калибровка, нюансы

Лайфхак

Сначала пару слов о лайфхаке, который сильно улучшает поиск шахматной доски на изображении. Нам надо откалиброваться для разрешения 320х240, но на картинках такого разрешения шахматная доска детектируется плохо. Поэтому мы снимаем картинки вдаое большего разрешения (640х480), ищем на них координаты углов шахматной доски, а потом уменьшаем найденные координаты в два раза по X и по Y. И только потом отдаем их в механизм калибровки. Если вам нужна еще более высокая точность – можете откалиброваться на 1280х960, и уменьшить найденные координаты в 4 раза.

Одинаковое бывает разным

Занятное наблюдение – при одинаковых параметрах поиска шахматной доски код на питоне находит ее на большем количестве изображений. Вы заметили на видео, что Python откидывает всего одну пару, где он не нашел шахматную доску, а C++ с десяток. Большинство картинок, где не найдена шахматная доска – это фото, где доска находится на большом расстоянии от камеры. Я не вижу иного объяснения кроме как разницы в алгоритмах OpenCV 4.1.1 и 4.1.0 (первая версия у нас используется в C++, вторая в Python).

Баги при калибровке Python.

Если запустить калибровку со всеми картинками, имеющимися в примерах, то калибровка выпадает с ошибкой:

        Traceback (most recent call last):
  File "/home/pi/stereopi-fisheye-robot/4_calibration_fisheye.py", line 297, in <module>
    result = calibrate_one_camera(objpointsRight, imgpointsRight, 'right')
  File "/home/pi/stereopi-fisheye-robot/4_calibration_fisheye.py", line 170, in calibrate_one_camera
    (cv2.TERM_CRITERIA_EPS+cv2.TERM_CRITERIA_MAX_ITER, 30, 1e-6)
cv2.error: OpenCV(4.1.0) /home/pi/opencv-python/opencv/modules/calib3d/src/fisheye.cpp:1372: error: (-215:Assertion failed) fabs(norm_u1) > 0 in function 'InitExtrinsics'

    

Это очень неприятная ошибка, и ее подлость в том, что трудно понять логику ее появления. На самом деле, в данном случае ее вызывает картинка с номером 23. Причем эта картинка вполне хорошо выглядит, на ней отлично детектируется шахматная доска, и ничего не предвещает беды. Единственный способ избавиться от ошибки – это вычислить и удалить проблемную картинку. Я для этого пользуюсь методом «половинного деления». Допустим, у нас 50 изображений для калибровки. Я удаляю 25 картинок и смотрю, повторяется ли ошибка. Если исчезла – оставляю эти картинки, и возвращаю половину из удаленных (12 или 13). Если ошибка появляется – снова удаляю, но возвращаю вторую половину. Далее уже работа с половиной от половины – 6 или 7 картинок. Затем повторяю это уже с 3 картинками, и нахожу хулигана. Как нам удалось понять, виновата не сама картинка, а соотношение ее параметров с другими картинками в наборе. В некоторых случаях проблемными могут стать картинки с другим номером – например, если вы начнете калибровку с 1 картинки и будете добавлять другие по одной.

Баги при калибровке C++

Наличие в калибровочной серии картинки 47 вызывает такую вот ошибку:

        terminate called after throwing an instance of 'cv::Exception'
  what():  OpenCV(4.1.1) /home/pi/opencv/modules/calib3d/src/fisheye.cpp:1421: error: (-3:Internal error) CALIB_CHECK_COND - Ill-conditioned matrix for input array 37 in function 'CalibrateExtrinsics'
Aborted

    

Картинка 47 была найдена методом половинного деления, как и в случае с кодом Python (а там, как вы помните, плохо себя вела картинка номер 23).

Надо заметить, что в ошибке есть подсказка – проблема в массиве точек No 37. Это большое достижение, так как в прошлых версиях OpenCV вы не получали даже такой информации.

Мы видим, что ошибку выдала обработка по флагу CALIB_CHECK_COND, который мы явно не устанавливали. Если попробовать этот флаг явно снять (закомментированная строчка //fisheyeFlags &= cv::fisheye::CALIB_CHECK_COND), то мы получаем другую ошибку:

        terminate called after throwing an instance of 'cv::Exception'
  what():  OpenCV(4.1.1) /home/pi/opencv/modules/calib3d/src/fisheye.cpp:1023: error: (-215:Assertion failed) abs_max < threshold in function 'stereoCalibrate'

Aborted

    

Как и в случае с кодом на Python, причина в одном изображении, удаление которого из списка обрабатываемых решает эту проблему.

Ну что можно тут сказать – это очень недружелюбная для пользователя ситуация. Логично было бы добавить, например, игнорирование ошибочных массивов вместо прекращения работы (в качестве дополнительного флага), либо дать пользователям инструмент предварительной проверки корректности массива перед отправкой на расчет. А пока нам остается делать лишь «просев» картинок половинным делением (или править код OpenCV и пересобирать всё целиком). Надеемся, что в следующих релизах OpenCV этот момент с калибровкой будет поправлен.

Скрипт 5 – настройка карты глубин, тонкости

В нашей реализации интерфейса настройки параметров в C++ есть один «кривой» момент – параметр min_disp может быть отрицательным, а бегунок на нашем интерфейсе начинается от нуля. Поэтому мы вычитаем от значения на бегунке число 40, чтобы захватить отрицательный диапазон. Так что 0 на шкале означает -40 в параметрах.

Играясь с параметрами в C++, вы в консоли можете видеть текущий FPS (точнее, DMPS – Depth Maps Per Second). Таким образом можно легко вычислить, какие параметры и как влияют на скорость расчета карты глубин.

Скрипт 6 – карта глубин по видео

Про скорость. Запустив карту глубин, посмотрите загрузку процессора командой top или htop (вторая покажет загрузку по ядрам). Вы увидите, что загружено, как правило, примерно полтора ядра. Текущая реализация Depth Map не поддерживает многопоточность (возможность которой в коде таки заложена). Это значит, что у нас есть примерно двукратный потенциальный запас производительности, но его достижение требует серьезной правки исходников OpenCV.

Скрипт 7 – построение 2D карты пространства в режиме сканирующего лидара

Большинство трюков в этом коде связаны с отображением карты на экране. В некоторых случаях возможны «выбросы» на 2D карте в виде точек, которые находятся далеко от камеры. При этом автоматический масштаб меняется так, что карта становится очень мелкой. На этот случай мы оставили возможность отключения автоматического масштаба, и вы можете установить тот, который наиболее подходит для вашего случая. Надо отметить, что при реальном использовании на роботе эта проблема исчезает, так как вам не надо отображать карту, а нужно лишь сделать необходимые вычисления.

В зависимости от положения камеры на роботе вы можете менять настройки вырезаемой полосы изображения, перемещая ее выше, ниже или меняя высоту. А если у вас StereoPi будет связана с датчиком положения в пространстве (IMU), то возможности использования полученной карты существенно расширяются. Но тут мы уже выходим за рамки статьи (и касаемся темы ROS).

На этом пока всё. Надеюсь, наши эксперименты помогут вам в ваших проектах!

МЕРОПРИЯТИЯ

Комментарии 5

ВАКАНСИИ

Unity 3D Engineer
по итогам собеседования
Team Leader (back-end)
Тверь, от 100000 RUB до 120000 RUB
QA-специалист
от 70000 RUB до 90000 RUB

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ

BUG