Приветствую, сегодня я опробую OpenCV, библиотеку для работы с видео, на примере простой задачи - символами ASCII вывести видеоролик в терминал.

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

Начнем с алгоритма, он вполне интуитивен:

  1. Загружаем видео

  2. Покадрово по нему проходимся, пока кадры не закончатся, для каждого кадра:

    1. Делаем черно-белым

    2. Скейлим его до нужных нам размеров (размеров консоли)

    3. Перебираем пиксели слева направо, сверху вниз, для каждого пикселя:

      1. Получаем его яркость

      2. Ставим в соответствие его яркости символ, который имеет схожую яркость (более яркий символ - значит содержит в себе больше пикселей)

      3. Записываем полученный символ в строку для вывода

    4. Выводим эту строку

Перейдем к делу:

Для удобства пояснение будет в виде комментариев.

Подключаем необходимые библиотеки, ncurses будем использовать для работы с выводом в консоль (можно обойтись и без него):

#include <chrono>
#include <iostream>
#include <opencv2/opencv.hpp>
#include <opencv2/videoio.hpp>
#include <string>
#include <thread> // уже догадались, зачем нужны chrono и thread :D?

// Сишные библиотеки всегда подключаем после плюсовых
#include <curses.h>
#include <ncurses.h>

Загружаем видео в объект VideoCapture, не забывая проверить, загрузилось ли видео:

cv::VideoCapture video_capture("/path/to/video"));
if (!video_capture.isOpened()) {
    std::cerr << "Failed to open video file.\n";
    return -1;
  }

Прописываем все константы:

// Эмпирический коэфицент 
// нужный для сохранения отношения сторон видео при минимальном скейле 
// моей консоли
  const float correction_factor = 4.75;

// Отрисовывать картинку будем 10 раз в секунду
// иначе реальный фреймтайм будет выше того, что в видео
  const int targetfps = 10;
  const int fps = video_capture.get(cv::CAP_PROP_FPS);
  const int fpsdif = fps / targetfps;
  const int frame_duration = 1000 / fps;

// Получаем размеры видео в будущих символах, сохраняя отношение сторон
  const int frame_width = video_capture.get(cv::CAP_PROP_FRAME_WIDTH);
  const int frame_height = video_capture.get(cv::CAP_PROP_FRAME_HEIGHT);
  const int screen_height = 120;
  const int screen_width =
      screen_height * (frame_width / frame_height) * correction_factor;

Теперь напишем функцию для того, чтобы получить символ, соотв. интенсивности пикселя (она у нас будет от 0 до 255):


const char get_ASCII_from_pixel(const int pixelintensity) {
  // Выбор этой строки субъективен, мне нравится так
  std::string chars_by_brightness = "$@B%8&#*/|(-_+;:,.  ";
  // При желании инвертируем
  std::reverse(chars_by_brightness.begin(), chars_by_brightness.end()); 
  
  return chars_by_brightness[static_cast<float>(pixelintensity *
                                                chars_by_brightness.length()) /
                             256.f];
}

Можно перейти к основному циклу:


// будем считать время отрисовки кадра, 
// чтобы вычесть его из фреймтайма
  int starttime = 0, endtime = 0, framedrawtime = 0, i = 0;

// Объекты, которые будем использовать в цикле
// cv::Mat - специальный n-мерный массив,
// в который будем загружать кадр (элементы - пиксели)
  cv::Mat original_frame, grayscaled_frame, grayscaled_resized_frame;

  initscr(); // переводим терминал в curses режим
  for (;;) {
    // засекаем время
    starttime = std::chrono::duration_cast<std::chrono::milliseconds>(
                    std::chrono::system_clock::now().time_since_epoch())
                    .count();
    
    // выкидываем кадры, которые отрисовывать не будем
    i = fpsdif;
    while (--i) {
      video_capture.grab();
    }

    // оператор >> у cv::VideoCapture возвращает нам следующий кадр
    video_capture >> original_frame;

    // проверяем, не закончилось ли видео
    if (original_frame.empty())
      break;

    // проводим манипуляции согласно алгоритму
    cv::cvtColor(original_frame, grayscaled_frame, cv::COLOR_BGR2GRAY);
    cv::resize(grayscaled_frame, grayscaled_resized_frame,
               cv::Size(screen_width, screen_height), 0, 0, cv::INTER_LINEAR);

    for (int x = 0; x < screen_height; ++x) {
      for (int y = 0; y < screen_width; ++y) {
        // перемещаем каретку curses на нужные координаты и помещаем туда символ
        mvaddch(x, y,
                get_ASCII_from_pixel(grayscaled_resized_frame.at<uchar>(x, y)));
      }
    }

    // показываем результат в консоли
    refresh();

  
    endtime = std::chrono::duration_cast<std::chrono::milliseconds>(
                  std::chrono::system_clock::now().time_since_epoch())
                  .count();
    
    // столько времени прошло уже
    framedrawtime = endtime - starttime;

    // теперь итерация длится сколько нам надо
    std::this_thread::sleep_for(
        std::chrono::milliseconds(fpsdif * frame_duration - framedrawtime));
  }

  endwin(); // завершение curses режима
Весь код:

src/main.cpp

#include <chrono>
#include <iostream>
#include <opencv2/opencv.hpp>
#include <opencv2/videoio.hpp>
#include <string>
#include <thread>

#include <ncurses.h>

const std::string getpathto(const char *file) {
  std::stringstream ss;
  ss << RESOURCES_PATH << file;
  return ss.str();
}

const char get_ASCII_from_pixel(const int pixelintensity) {
  std::string chars_by_brightness = "$@B%8&#*/|(-_+;:,.  ";
  std::reverse(chars_by_brightness.begin(), chars_by_brightness.end());
  return chars_by_brightness[static_cast<float>(pixelintensity *
                                                chars_by_brightness.length()) /
                             256.f];
}

int main() {
  cv::VideoCapture video_capture(getpathto("vid1.mp4"));

  if (!video_capture.isOpened()) {
    std::cerr << "Failed to open video file.\n";
    return -1;
  }

  const float correction_factor = 4.75;
  const int targetfps = 10;
  const int fps = video_capture.get(cv::CAP_PROP_FPS);
  const int fpsdif = fps / targetfps;
  const int frame_duration = 1000 / fps;
  const int frame_width = video_capture.get(cv::CAP_PROP_FRAME_WIDTH);
  const int frame_height = video_capture.get(cv::CAP_PROP_FRAME_HEIGHT);
  const int screen_height = 120;
  const int screen_width =
      screen_height * (frame_width / frame_height) * correction_factor;

  int starttime = 0, endtime = 0, framedrawtime = 0, i = 0;
  cv::Mat original_frame, grayscaled_frame, grayscaled_resized_frame;

  initscr();
  for (;;) {
    starttime = std::chrono::duration_cast<std::chrono::milliseconds>(
                    std::chrono::system_clock::now().time_since_epoch())
                    .count();
    i = fpsdif;
    while (--i) {
      video_capture.grab();
    }

    video_capture >> original_frame;
    if (original_frame.empty())
      break;

    cv::cvtColor(original_frame, grayscaled_frame, cv::COLOR_BGR2GRAY);
    cv::resize(grayscaled_frame, grayscaled_resized_frame,
               cv::Size(screen_width, screen_height), 0, 0, cv::INTER_LINEAR);

    for (int x = 0; x < screen_height; ++x) {
      for (int y = 0; y < screen_width; ++y) {
        mvaddch(x, y,
                get_ASCII_from_pixel(grayscaled_resized_frame.at<uchar>(x, y)));
      }
    }

    refresh();

    endtime = std::chrono::duration_cast<std::chrono::milliseconds>(
                  std::chrono::system_clock::now().time_since_epoch())
                  .count();

    framedrawtime = endtime - starttime;

    std::this_thread::sleep_for(
        std::chrono::milliseconds(fpsdif * frame_duration - framedrawtime));
  }

  endwin();

  return 0;
}

CMakeLists.txt

cmake_minimum_required(VERSION 3.25)
project(ASCII_video)
set(OpenCV_DIR /usr/lib/opencv4/opencv2)
find_package(OpenCV REQUIRED)
find_package(Curses REQUIRED)
# find_package(VTK REQUIRED)
include_directories(${OpenCV_INCLUDE_DIRS} ${CURSES_INCLUDE_DIR})

add_executable(MyExecutable src/main.cpp) 
target_link_libraries(MyExecutable ${OpenCV_LIBS} ${VTK_LIBRARIES} ${CURSES_LIBRARIES})
target_compile_definitions(MyExecutable PUBLIC RESOURCES_PATH="${CMAKE_CURRENT_SOURCE_DIR}/resources/")

Итог:

Видео:

Оно же, но в консоли:

Комментарии (16)


  1. unreal_undead2
    19.10.2023 07:20
    +2

    Интересно было бы с aalib сравнить.


    1. xanderxanderfto Автор
      19.10.2023 07:20
      +2

      aalib, конечно, покруче будет


    1. Tuxman
      19.10.2023 07:20

      Заголовок должен быть "Aalib на коленке".


    1. Tuxman
      19.10.2023 07:20
      +1

      Вот вы здесь хихи да хаха, а я, между прочим, в ~98-99ом линуксовую кваку (первую тогда ещё), которая под svga библиотекой, под aalib переиначил, и там даже как-то играть можно было в консоле, только не комильфо конечно, но распознать что куда можно было.


  1. Apoheliy
    19.10.2023 07:20

    Можно добавить тэг "Ненормальное программирование".

    По коду: вопросы пропорций картинки - наверное, нужно сразу задаться "прямоугольностью" текстового пикселя и изменять размер в правильных пропорциях; возврат строки с единственным символом - так себе решение; вместо игрищь с "clear" можно использовать ncurses - возможно, получится быстрее; отсчёт времени не учитывает собственно вывод на экран (это может занять существенное время по меркам длительности кадра) и неточность sleep-а (лучше поспать, потом сделать замер, сколько поспал и на следующем кадре скорректировать).

    И таблица символов с яркостью не понятно, откуда взялась - её можно подлиннее сделать.

    В целом: прикольно.


    1. xanderxanderfto Автор
      19.10.2023 07:20

      спасибо, вечером подправлю


      1. Apoheliy
        19.10.2023 07:20

        Ещё можно поправить:

        chars_by_brightness[pixelintensity * chars_by_brightness.length() / 255]

        который при pixelintensity равном 255 может выдать интересные результаты.


        1. xanderxanderfto Автор
          19.10.2023 07:20

          updated


  1. codecity
    19.10.2023 07:20

    Можно так по SSH зайти на сервак и быстренько просмотреть видео?


    1. xanderxanderfto Автор
      19.10.2023 07:20

      Можно, конечно, но scp, мне кажется, побыстрее будет)


    1. alexs963
      19.10.2023 07:20

      Для этого можно использовать mpv -vo tct


    1. slonopotamus
      19.10.2023 07:20

      telnet towel.blinkenlights.nl


  1. blind_oracle
    19.10.2023 07:20
    +1

    libcaca

    /thread


  1. D3Nd3R
    19.10.2023 07:20

    Для независемой обработке пикселей у cv::Mat есть метод forEach, метод принимает лямбды, функторы и т.д.

    Самое главное, если openCV собран с каким-либо бэкэндом для многопоточности (openmp, tbb...), то получаем распараллеливание из коробки


  1. yamifa_1234
    19.10.2023 07:20

    Есть браузер для консоли) тоже умеет видео воспроизводить .

    https://habr.com/ru/companies/timeweb/articles/715134/


  1. jidckii
    19.10.2023 07:20

    cvlc file.mp4

    в Линуксе без Х будет в консоли показывать. Ещё в 2014 смотрел так...