Приветствую, сегодня я опробую OpenCV, библиотеку для работы с видео, на примере простой задачи - символами ASCII вывести видеоролик в терминал.
Те, кто ей пользовались, могут сказать, что я забиваю дрелью гвозди - создана она для работы с алгоритмами компьютерного зрения.
Начнем с алгоритма, он вполне интуитивен:
Загружаем видео
-
Покадрово по нему проходимся, пока кадры не закончатся, для каждого кадра:
Делаем черно-белым
Скейлим его до нужных нам размеров (размеров консоли)
-
Перебираем пиксели слева направо, сверху вниз, для каждого пикселя:
Получаем его яркость
Ставим в соответствие его яркости символ, который имеет схожую яркость (более яркий символ - значит содержит в себе больше пикселей)
Записываем полученный символ в строку для вывода
-
Выводим эту строку
Перейдем к делу:
Для удобства пояснение будет в виде комментариев.
Подключаем необходимые библиотеки, 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)
Apoheliy
19.10.2023 07:20Можно добавить тэг "Ненормальное программирование".
По коду: вопросы пропорций картинки - наверное, нужно сразу задаться "прямоугольностью" текстового пикселя и изменять размер в правильных пропорциях; возврат строки с единственным символом - так себе решение; вместо игрищь с "clear" можно использовать ncurses - возможно, получится быстрее; отсчёт времени не учитывает собственно вывод на экран (это может занять существенное время по меркам длительности кадра) и неточность sleep-а (лучше поспать, потом сделать замер, сколько поспал и на следующем кадре скорректировать).
И таблица символов с яркостью не понятно, откуда взялась - её можно подлиннее сделать.
В целом: прикольно.
xanderxanderfto Автор
19.10.2023 07:20спасибо, вечером подправлю
Apoheliy
19.10.2023 07:20Ещё можно поправить:
chars_by_brightness[pixelintensity * chars_by_brightness.length() / 255]
который при pixelintensity равном 255 может выдать интересные результаты.
D3Nd3R
19.10.2023 07:20Для независемой обработке пикселей у cv::Mat есть метод
forEach
, метод принимает лямбды, функторы и т.д.Самое главное, если openCV собран с каким-либо бэкэндом для многопоточности (openmp, tbb...), то получаем распараллеливание из коробки
jidckii
19.10.2023 07:20cvlc file.mp4
в Линуксе без Х будет в консоли показывать. Ещё в 2014 смотрел так...
unreal_undead2
Интересно было бы с aalib сравнить.
xanderxanderfto Автор
aalib, конечно, покруче будет
Tuxman
Заголовок должен быть "Aalib на коленке".
Tuxman
Вот вы здесь хихи да хаха, а я, между прочим, в ~98-99ом линуксовую кваку (первую тогда ещё), которая под svga библиотекой, под aalib переиначил, и там даже как-то играть можно было в консоле, только не комильфо конечно, но распознать что куда можно было.