Хакер - Теплый ламповый дисплей. Собираем монитор из электронно-лучевой трубки
hacker_frei
Candidum
Содержание статьи
- Электронно-лучевая трубка
- Выбор трубки
- Питание и обвязка трубки
- Видеоусилитель
- ЦАП
- Bluepill и фигуры Лиссажу
- USB/SPI и фигуры Лиссажу
- Графика
- Выводим текст
- Заключение
Монитор с классическим кинескопом сейчас можно отыскать разве что в музее или на блошином рынке, да и там они попадаются нечасто. Тем интереснее построить такой своими руками! В этой статье мы запустим электронно‑лучевую трубку, посмотрим на фигуры Лиссажу, выведем текст и изображение. Будет много схемотехники и кода.
Наверняка все видели часы на электронно‑лучевых трубках или экранах осциллографов. После неоновых часов это, наверное, следующая по популярности ламповая заморочка. Обычно, кроме циферблата и нескольких цифр, на него ничего не выводят, и это таки не случайно! Собственно, о причинах данного явления мы и будем дальше много говорить, а также обсудим те особенности трубок и схемотехники, о которых обычно в интернете не пишут.
INFO
Называть радиолампы трубками, электронными трубками, вакуумными трубками и клапанами безграмотно. Проскакивают такие названия обычно по вине горе‑переводчиков, однако в случае электронно‑лучевых трубок и рентгеновских трубок название плотно укоренилось и теперь общепринято. Такие дела.
ЭЛЕКТРОННО-ЛУЧЕВАЯ ТРУБКА
Электронно‑лучевые трубки, пожалуй, самые сложные радиолампы в плане устройства и управления. Предназначены они, как несложно догадаться, для вывода изображения. Здесь и далее мы будем говорить только об осциллографических трубках с электростатической фокусировкой и электростатическим отклонением.

Как все‑таки работает такая лампа? Электроны испускаются катодом, после чего проходят через систему фокусировки, которая в простейшем случае состоит из трех электродов, как на рисунке выше. Первый электрод управляет яркостью, второй фокусировкой, а третий, ускоряющий, отвечает за астигматизм. После этого пучок пролетает через две пары отклоняющих электродов, отвечающих за горизонтальное и вертикальное отклонение.
Затем электроны долетают до слоя люминофора и заставляют его светиться. Если фокусировка настроена, то на экране горит точка, положение которой определяется напряжением на отклоняющих электродах. Изменяя это напряжение, мы можем выводить изображение. Но это все общие слова, теперь перейдем к конкретике, о которой обычно не пишут, разве что на тематических форумах.
ВЫБОР ТРУБКИ
Для экспериментов я выбрал трубку 6ЛО1И. Мотивировала меня ее низкая стоимость (мне этот девайс обошелся в 400 рублей) и ее компактность.

Однако уже во время сборки и настройки я осознал, насколько это плохой выбор, ведь именно из‑за использования 6ЛО1И я столкнулся с таким количеством трудностей. А дело в том, что у трубки есть такой показатель, как чувствительность отклоняющей системы. Измеряется она в миллиметрах на вольт, и у 6ЛО1И это значение составляет около 0,15 мм/В, для оси X — чуть меньше, для оси Y — чуть больше. Такая чувствительность крайне низкая, и для движения луча по горизонтали от левого края экрана до правого нужно порядка 250 В, а по вертикали около 200 В. Это довольно много и требует от видеоусилителя очень хорошего быстродействия. Собственно, если посмотреть, что именно выпускала промышленность на этих трубках, то становится ясно, что это были «показометры» с шириной полосы не более нескольких десятков килогерц, например ОМШ-3М.
Здесь, правда, можно немного схитрить и понизить анодное напряжение на трубке с паспортного 1200 В до, скажем, 700–1000 В. Яркость при этом снизится, а чувствительность отклоняющей системы заметно возрастет, и в данном случае это разумный компромисс. В общем, советую взять трубку поприличнее — это сильно упростит ковыряния с видеоусилителем.
Но есть у 6ЛО1И и достоинства: устройство ее несложное, поэтому и схема питания у нее простая.
ПИТАНИЕ И ОБВЯЗКА ТРУБКИ
Перед тем как изобретать свой велосипед, неплохо бы ознакомиться с уже изобретенными вариантами. По уму, конечно, стоило бы собрать для анодного напряжения импульсник со стабилизацией. Но поскольку для накала нужно 6,3 В, а в осциллографических трубках большая часть высокого напряжения подается на катод, то есть потенциал катода около –900 В, источник питания накала должен быть надежно изолирован от массы. Проще всего провернуть этот финт, используя накальную обмотку.
А раз уж нужна накальная обмотка, значит, трансформатор будет содержать и анодную обмотку, поэтому высокое напряжение можно получить умножителем. Как говорится, 1000 В — это всего лишь три раза по 330 В. Поэтому, вдохновившись проектом простого осциллографа на 6ЛО1И, я разработал свою схему, в которой от исходной остался только концепт.
WARNING
Разность потенциалов между положительным и отрицательным плечами источника питания превышает 1000 В! Удар таким напряжением смертельно опасен, а кроме того, это очень больно. Поэтому будь крайне внимателен и осторожен! А если нет опыта в работе с высоким напряжением, возможно, лучше и не связываться с этим блоком питания. Я предупредил.

Основой блока питания служит 30-ваттный тороидальный трансформатор с двумя обмотками, накальной и анодной. Анодная обмотка выдает 235 В, которые поступают на выпрямитель и умножитель](https://en.wikipedia.org/wiki/Voltage_multiplier). Выпрямитель применен однополупериодный, так как он хорошо сочетается с умножителем, а токи потребления схемы около 0,5 мА. На выходе выпрямителя получаем около +330 В. На выходе умножителя имеем, соответственно, около –660 В, что в сумме дает нам 1000 В — вполне достаточное напряжение для работы трубки.
Обрати внимание на резисторы, шунтирующие конденсаторы выпрямителя и умножителя: они могут существенно продлить твою жизнь, поскольку конденсаторы — штука коварная (см. предупреждение). Вообще говоря, несмотря на паспортное анодное напряжение 1200 В, 6ЛО1И работает и от 1000 В, и даже от 500 В. При этом повышается чувствительность отклоняющей системы и снижается яркость свечения.
При 1000 В яркость вполне приличная. Обвязка самой 6ЛО1И вполне стандартная, как и в упомянутом выше проекте. Стоит также обратить внимание, что к общему проводу подключен не выход выпрямителя, а средняя точка делителя на резисторах R5/R6. Это нужно, чтобы приподнять напряжение на отклоняющих электродах при использовании окончательного варианта видеоусилителя.
Дело в том, что напряжение на втором аноде (астигматизм) должно быть чуть ниже, чем на отклоняющих электродах. Если напряжение на них низковато, то и на втором аноде его придется занижать, в результате падает яркость, использование же делителя позволяет обойти эту проблему. Да, настройки яркости, фокуса и астигматизма влияют друг на друга. Если включить устройство на этом этапе, после прогрева на экране появится точка, которую можно сфокусировать. Сигнал подается на отклоняющие пластины, выводы 10, 11 определяют отклонение по оси Y, выводы 7, 8 — отклонение по оси X. Теперь перейдем к видеоусилителю.


ВИДЕОУСИЛИТЕЛЬ
Одно из лучших решений для построения видеоусилителя — дифференциальный каскад. При прочих равных такой каскад позволяет получить в два раза больший размах выходного сигнала, а учитывая, что отклоняющие пластины симметричны, дифференциальный каскад напрашивается сам собой. В большинстве описанных в интернете конструкций, выводящих изображение на осциллографическую трубку, используется простейший дифференциальный каскад на маломощных высоковольтных транзисторах, например как здесь. С него я и начал.

Однако это решение неудобно, так как требует дополнительного смещения на базу первого транзистора, в противном случае каскад работает в нелинейном режиме, что совершенно неприемлемо. Хотя если хочется посмотреть фигуры Лиссажу, а в качестве источника сигнала использовать заводской ГСС, где можно задать смещение в пару вольт относительно земли, то такое решение вполне рабочее. Избавиться от необходимости внешнего смещения можно, используя двуполярное питание, что я и сделал.

Усилитель Y-канала идентичен. Как видишь, здесь появился еще один источник питания — 5 В, это усложняет блок питания, но решает проблему смещения, поэтому на вход можно подавать сигнал непосредственно с ЦАПа. Этот вариант усилителя чрезвычайно прост и подходит для экспериментов с трубкой, однако имеет существенные ограничения. И это в первую очередь быстродействие. Так, полоса пропускания данного усилителя будет около 10 кГц, и выше этой частоты усиление достаточно быстро снижается.
И что с того, спросишь ты? А из этого следует, что количество семплов ЦАПа будет ограничено полосой пропускания, что, в свою очередь, будет ограничивать размер изображения (количество точек), которое можно отрисовать без мерцания. В данном случае количество точек будет порядка 500. А если поднять частоту ЦАП, то изображение будет искажаться.
С другой стороны, несколько сотен точек вполне достаточно для отрисовки циферблата и стрелок, несложной геометрической картинки или тех же фигур Лиссажу. Собственно, в большинстве конструкций подобное изображение и выводят. А что делать, если мы хотим большего, например вывести на экран достаточно сложную картинку в пару десятков тысяч точек? Для этого придется поднимать быстродействие, и самый простой способ это сделать — поднять токи выходного каскада.
Кроме того, стоит иметь в виду, что коллекторные резисторы вместе с емкостью отклоняющей системы и выходной емкостью транзистора образуют RC ФНЧ, частоту среза которого можно примерно прикинуть, взяв емкость, скажем, 15 пФ. На практике получается заметно хуже, чем в теории, ну да это как всегда. Для резисторов 220 К получается значение 48,25 кГц, а для резисторов 3 К уже 3,54 МГц — то, что надо.
Несколько усложним схему, использовав каскодное включение транзисторов. Такое включение позволяет сделать схему менее критичной к параметрам высоковольтных транзисторов. В целом каскод работает как идеализированный каскад с общим эмиттером. Нас, конечно, это не спасет, поскольку мы все равно упремся в параметры трубки, зато позволит использовать дешевые высоковольтные транзисторы в верхнем плече, например MJE13003, MJE13005. Однако лучше все‑таки 2SC2611 или КТ940А.
Кроме того, добавим источник тока в эмиттерные цепи — так и работает лучше, и настраивать гораздо удобнее. А сверх того на вход поставим истоковые повторители, чтобы не шунтировать ЦАП. В первом варианте схемы их не было, однако оказалось, что усилитель заметно шунтировал ЦАП и сильно просаживал напряжение, потому повторители пришлось добавить.

Данный усилитель обеспечивает полосу около 1,5 МГц при размахе сигнала на выходе каждого плеча 75 В и усилении около 15. При этом замена транзисторов на MJE13005 дает примерно такой же результат, и улучшить его малыми усилиями уже не получится. Настройка усилителя сводится к подстройке источников тока резисторами RV2 и RV5: нужно добиться на коллекторах транзисторов Q2, Q5, Q7, Q10 напряжения чуть выше половины питания (около 120 В), а также к подбору конденсаторов частотной коррекции С3, С6, С9, С12.
Стоит заметить, что раз мы собираем не осциллограф, а монитор, то добиваться ровной АЧХ усилителя — не оптимальное решение. Поэтому подбор конденсаторов удобно вести, смотря на качество изображения, добиваясь минимальных артефактов. Методика подбора конденсаторов довольно простая — начав с заведомо меньшей емкости, например 1 нФ, необходимо последовательно увеличивать емкость в два раза, наблюдая изменения изображения. Когда емкость окажется чрезмерной, начинай ее уменьшать на половину предыдущего шага, таким образом шагов за пять можно подобрать нужное значение. Обрати внимание, что эмиттерные резисторы в каналах X и Y различны и конденсаторы коррекции, соответственно, тоже. Токи транзисторов также можно настраивать, ориентируясь на изображение.
Конструкция получилась достаточно сложной (12 транзисторов), а еще она заметно греется, поэтому нужен хороший радиатор. Мой, конечно, дико избыточен, но он мне попался под руку и подходил по размерам. Резисторы в коллекторной цепи также сильно греются, поэтому надо взять пятиваттные (не проволочные!). Хорошо, усилитель есть, теперь нужен источник сигнала.

ЦАП
C точки зрения соотношения цена/быстродействие лучшее решение — R-2R ЦАП. Первоначально я планировал использовать Blue Pill как источник сигнала, и в этом случае можно задействовать целый порт сразу на 2 ЦАПа (каналы X и Y). Однако, ориентируясь на данный проект, я решил применить сдвиговые регистры 74HC59. В плане быстродействия мы ничего не теряем, так как GPIO в stm32f103 работают с частотой около 2 МГц, и то при прямой записи в регистры, через обертки получается несколько медленнее. А вот шина SPI недурно работает на частоте 32 МГц, и итоге для двух 8-битных каналов получаем 2 мегасемпла в секунду, при этом ЦАП можно использовать независимо с другими источниками сигнала. А кроме того, ЦАП на 74HC595 выдает сигнал до 5 В, что, учитывая низкую чувствительность трубки, нам только на руку.

Сначала, конечно, схемка была попроще, в ней присутствовали только сдвиговые регистры. Микроконтроллер писал в SPI два байта, а потом дергал ножку RCLK, и все было хорошо, все работало. Потом мне захотелось вымодить массивы побольше, которые не помещались в память контроллера, и тут было два варианта: приладить к контроллеру флешку, или подключить к компу через USB/SPI. Я выбрал второй вариант, а в качестве USB/SPI использовал FT232H.
Это самый быстрый USB/SPI из мне известных, а кроме того, его можно приобрести в виде готового модуля за терпимые деньги (ну, некоторое время назад так и было). Однако у FT232H есть та же проблема, что и у контроллера SPI: порт работает быстро, а GPIO медленно, причем гораздо медленнее, чем в контроллере, поэтому дергать ножку регистра на каждые два байта неразумно. Пришлось малой кровью допилить недо-SPI 74HC595 до «почти SPI». Идея достаточно проста: надо считать тактовые импульсы и каждый 16-й дергать RCLK. Для этого собран делитель на 16 на четырех D-триггерах. А чтобы знать, откуда считать импульсы, по сигналу CS происходит установка триггеров, что срабатывает как синхронизация.
Конечно, делитель проще было собрать на 74HC4040, но это как‑нибудь в другой раз. Так или иначе, мы получили ЦАП, способный выдавать до 2 мегасемплов в секунду, причем его скоростью можно управлять, меняя скорость шины SPI. О резисторах можно сказать, что использовать резисторы одного номинала удобно: получаешь правильное соотношение сопротивлений 1/2. В принципе, можно сэкономить и использовать резисторы 5К1 и 10К. Немного пострадает линейность, что на глаз почти незаметно, впрочем, экономия копеечная и того не стоит.

BLUEPILL И ФИГУРЫ ЛИССАЖУ
Аналоговая часть собрана, и ЦАП у нас есть. Время проверить, как оно работает. Самый простой тестовый сигнал для создания изображения — это два синуса с разными частотами или фазами. Проще всего такой сигнал взять с ГСС и подать на входы видеоусилителя, однако если ГСС под рукой нет, то сигнал можно сгенерировать в микроконтроллере буквально несколькими десятками строк.
Генерировать синус в микроконтроллере можно тремя способами. Во‑первых, используя библиотеку math.h и функцию sin(), однако это далеко не лучший вариант по быстродействию и расходованию ресурсов. Работа с плавающей точкой — это не то, для чего предназначены микроконтроллеры, впрочем, данный метод работает. Другой достаточно интересный вариант генерации синуса — на основе разностных схем — упоминается здесь. Уравнения там достаточно простые, и с первого взгляда даже не скажешь, что на выходе получается синус.
V -= X*R
X += V
Здесь R — это константа. На осознание вывода этих формул меня не хватило, впрочем, даже в момент окончания универа граница моих математических способностей лежала где‑то в районе дивергенции градиента, а с тех пор стало только хуже. Но при реализации в целочисленной математике оно работает, и работает неплохо. Уж точно намного быстрее, чем библиотечный синус.
Третий же метод генерации синуса — табличный, и вот он мне больше всего понравился, особенно прозрачностью установки фаз и частот. Кроме того, он демонстрировал наибольшее быстродействие. Суть метода: берем таблицу с заранее рассчитанными значениями синуса и просто выводим записанные в ней данные через равные промежутки времени с заданным шагом. Меняя шаг, мы меняем частоту, а меняя стартовую точку, меняем фазу. То, что надо!
...
uint8_t msin[256]={
127, 130, 133, 136, 139, 142, 145, 148, 151, 154, 157, 160, 163, 166,
169, 172, 175, 178, 181, 184, 186, 189, 192, 194, 197, 200, 202, 205,
207, 209, 212, 214, 216, 218, 221, 223, 225, 227, 229, 230, 232, 234,
235, 237, 239, 240, 241, 243, 244, 245, 246, 247, 248, 249, 250, 250,
251, 252, 252, 253, 253, 253, 253, 253, 254, 253, 253, 253, 253, 253,
252, 252, 251, 250, 250, 249, 248, 247, 246, 245, 244, 243, 241, 240,
239, 237, 235, 234, 232, 230, 229, 227, 225, 223, 221, 218, 216, 214,
212, 209, 207, 205, 202, 200, 197, 194, 192, 189, 186, 184, 181, 178,
175, 172, 169, 166, 163, 160, 157, 154, 151, 148, 145, 142, 139, 136,
133, 130, 127, 123, 120, 117, 114, 111, 108, 105, 102, 99, 96, 93, 90,
87, 84, 81, 78, 75, 72, 69, 67, 64, 61, 59, 56, 53, 51, 48, 46, 44,
41, 39, 37, 35, 32, 30, 28, 26, 24, 23, 21, 19, 18, 16, 14, 13, 12,
10, 9, 8, 7, 6, 5, 4, 3, 3, 2, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 1, 1, 2, 3, 3, 4, 5, 6, 7, 8, 9, 10, 12, 13, 14, 16, 18, 19, 21,
23, 24, 26, 28, 30, 32, 35, 37, 39, 41, 44, 46, 48, 51, 53, 56, 59,
61, 64, 67, 69, 72, 75, 78, 81, 84, 87, 90, 93, 96, 99, 102, 105,
108, 111, 114, 117, 120, 123};
static void spi1_init(void){
// Включаем порт и интерфейс
rcc_periph_clock_enable(RCC_SPI1);
rcc_periph_clock_enable(RCC_GPIOA);
/* Configure GPIOs:
* SCK=PA5
* MOSI=PA7
*/
gpio_set_mode(GPIOA, GPIO_MODE_OUTPUT_50_MHZ,
GPIO_CNF_OUTPUT_ALTFN_PUSHPULL, GPIO5|GPIO7);
spi_reset(SPI1);
spi_init_master(SPI1, SPI_CR1_BAUDRATE_FPCLK_DIV_2,
SPI_CR1_CPOL_CLK_TO_0_WHEN_IDLE,
SPI_CR1_CPHA_CLK_TRANSITION_1,
SPI_CR1_DFF_8BIT, SPI_CR1_LSBFIRST);
//spi_set_full_duplex_mode(SPI1);
spi_enable_software_slave_management(SPI1);
spi_set_nss_high(SPI1);
spi_enable(SPI1);
}
void gpio_init(){
rcc_periph_clock_enable(RCC_GPIOB);
gpio_set_mode(GPIOB, GPIO_MODE_OUTPUT_50_MHZ,
GPIO_CNF_OUTPUT_PUSHPULL, GPIO6|GPIO12);
/*
* GPIO4 - ST_CP
* GPIO12 - LED
*/
gpio_set(GPIOB,GPIO12);
gpio_clear(GPIOB,GPIO6);
}
void send_xy(uint8_t x, uint8_t y){
spi_xfer(SPI1,x);
spi_xfer(SPI1,y);
// Дергаем RCLK для записи в регистр
gpio_set(GPIOB,GPIO6);
gpio_clear(GPIOB,GPIO6);
}
void main(void){
rcc_clock_setup_in_hse_8mhz_out_72mhz();
spi1_init();
gpio_init();
uint32_t n=1;
uint8_t a=128, b=128;
while(1){
n++;
if(!(n%2))b++;
for(uint16_t i=0;i<256;i++){
send_xy(msin[a],msin[b]);
a+=1;
b+=3;
}
}
}
...
Вот такой несложный код генерирует достаточно интересную картинку, которая еще и двигаться будет.

На этом мы, пожалуй, оставим Bluepill и перейдем к x86_64.
USB/SPI И ФИГУРЫ ЛИССАЖУ
Для работы с FT232H на просторах AUR я отыскал библиотеку libmpsse, но с ней все оказалось не то чтобы гладко. Пакет давно не обновлялся и наотрез отказывался собираться, хорошо, что в первом же комментарии были советы, как это исправить. Вторую трудность я себе создал сам. Трудность эта заключалась в совмещении библиотек из C и С++, но и это вопрос решаемый. Зачем так делать, спросишь ты? На то у меня были причины. Для работы с FT232H наш ЦАП уже прокачан до почти настоящего SPI, осталось поправить код под пакетную передачу. Теперь вместо генерации координат на лету мы их запишем в массив и уже его скормим ЦАПу.
#include <stdio.h>
#include <math.h>
#include <string.h>
#include <cstdint>
#define FTDI 1
#ifdef FTDI
#include <stdlib.h>
extern "C"{
#include <mpsse.h>
}
#define SPEED 1000000
#endif
uint8_t buffer[10000];
uint16_t buf_shift=0;
uint8_t msin[256]={
127, 130, 133, 136, 139, 142, 145, 148, 151, 154, 157, 160, 163, 166, 169, 172, 175, 178, 181, 184, 186, 189, 192, 194, 197, 200, 202, 205, 207, 209, 212, 214, 216, 218, 221, 223, 225, 227, 229, 230, 232, 234, 235, 237, 239, 240, 241, 243, 244, 245, 246, 247, 248, 249, 250, 250, 251, 252, 252, 253, 253, 253, 253, 253, 254, 253, 253, 253, 253, 253, 252, 252, 251, 250, 250, 249, 248, 247, 246, 245, 244, 243, 241, 240, 239, 237, 235, 234, 232, 230, 229, 227, 225, 223, 221, 218, 216, 214, 212, 209, 207, 205, 202, 200, 197, 194, 192, 189, 186, 184, 181, 178, 175, 172, 169, 166, 163, 160, 157, 154, 151, 148, 145, 142, 139, 136, 133, 130, 127, 123, 120, 117, 114, 111, 108, 105, 102, 99, 96, 93, 90, 87, 84, 81, 78, 75, 72, 69, 67, 64, 61, 59, 56, 53, 51, 48, 46, 44, 41, 39, 37, 35, 32, 30, 28, 26, 24, 23, 21, 19, 18, 16, 14, 13, 12, 10, 9, 8, 7, 6, 5, 4, 3, 3, 2, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 2, 3, 3, 4, 5, 6, 7, 8, 9, 10, 12, 13, 14, 16, 18, 19, 21, 23, 24, 26, 28, 30, 32, 35, 37, 39, 41, 44, 46, 48, 51, 53, 56, 59, 61, 64, 67, 69, 72, 75, 78, 81, 84, 87, 90, 93, 96, 99, 102, 105, 108, 111, 114, 117, 120, 123};
uint8_t buffer2[512];
void singen(uint8_t sa,uint8_t sb,uint8_t fs){
static uint8_t a=0;
static uint8_t b=0;
b+=fs;
uint16_t n=0;
while(n<512){
buffer2[n++]=msin[a];
buffer2[n++]=msin[b];
a+=sa;
b+=sb;
}
}
uint16_t send_xy(uint8_t x, uint8_t y){
buffer[buf_shift++]=x;
buffer[buf_shift++]=y;
return buf_shift;
}
int main( int argc, const char** argv )
{
#ifdef FTDI
/*****************************************************************
* А теперь выведем с ходу на экран через FT232H
*****************************************************************/
struct mpsse_context *spi = NULL;
spi = MPSSE(SPI0, SPEED, LSB);
while(1){
singen(4,5,1);
Start(spi);
for(uint16_t i=0;i<10;i++)FastWrite(spi, (char*)buffer2, 512);
Stop(spi);
}
Close(spi);
#endif
}
Компилируем полученную программу:
g++ -O3 single-sin.c -o single-sin -lmpsse
Запускаем и убеждаемся, что все работает точно так же, как на контроллере. Посмотрели — работает, но аппетит, как известно, приходит во время еды. Хочется вывести на экран графику и текст, этим и займемся.
ГРАФИКА
Практически во всех статьях, где описывался вывод изображения на осциллографическую трубку, предлагают взять алгоритм Брезенхэма и с его помощью рисовать на экране линии, а из них составлять картинку. И этот подход, конечно, работает, но уж очень это муторно. На мой вкус, все это не то, поэтому будем изобретать свой велосипед. Итак, начнем с простого: пусть у нас есть монохромное изображение размером 256 × 256, например такое.

Достанем оттуда координаты всех черных точек. В этом нам поможет замечательная библиотека OpenCV. Вообще, в ней очень много функций для работы с изображениями, но здесь мы используем ее лишь как декодер. А поскольку она всеядная, мы автоматически избавляемся от привязки к определенному формату картинки и можем использовать любой на наш вкус, например .png. Значит, декодируем изображение, пробегаемся по полученному массиву и находим координаты всех черных точек.
#include "opencv2/imgproc.hpp"
#include "opencv2/imgcodecs.hpp"
#include "opencv2/highgui.hpp"
#include <stdio.h>
#include <math.h>
#include <string.h>
#define FTDI 1
#ifdef FTDI
#include <stdlib.h>
extern "C"{
#include <mpsse.h>
}
#define SPEED 10000000
#endif
using namespace cv;
using namespace std;
Mat img;
int main( int argc, const char** argv )
{
char filename[256]={0};
strcpy(filename,argv[1]);
img = imread(filename, IMREAD_GRAYSCALE);
// Создаем массив с координатами точек контура
// Изображение предварительно подготовлено
printf("//Mat.rows %u\r\n", img.rows);
printf("//Mat.cols %u\r\n", img.cols);
uint32_t imgsize=img.rows*img.cols;
uint8_t *mix_img=(uint8_t*) calloc(imgsize+1,sizeof(uint8_t));
uint32_t n=0;
uint8_t *pic=(uint8_t*) calloc(2*imgsize+1,sizeof(uint8_t));
n=0;
for( uint64_t i=0;i<imgsize;i++){
if(img.data[i]<100){
pic[n++]=i%img.cols;
pic[n++]=i/img.cols;
}
}
printf("n=%u\r\n",n/2);
#ifdef FTDI
/*****************************************************************
* А теперь выведем с ходу на экран через FT232H
*****************************************************************/
struct mpsse_context *spi = NULL;
spi = MPSSE(SPI0, SPEED, LSB);
// for(uint8_t i=0;i<20;i++)printf("%u\r\n",out_mix[i]);
while(1){
Start(spi);
FastWrite(spi, (char*)pic, 2*n);
Stop(spi);
}
Close(spi);
#endif
free(pic);
}
Казалось бы, все готово, изображение на экране. Но, как в том анекдоте, есть один нюанс — мы видим большое количество артефактов.

Артефакты появляются в местах, где лучу приходится перескакивать на относительно большое расстояние. В простейшем случае с ними можно бороться, снижая битрейт. Однако это плохой способ: артефакты, конечно, исчезают, но взамен появляется мерцание. Для сравнения вот то же изображение с битрейтом 1 Мбит/с.

Артефактов почти нет, но уже хорошо заметно мерцание. Так что же делать? Попробуем оптимизировать траекторию луча, сделав его путь по экрану минимальным (по возможности). Это приводит нас к задачe коммивояжера.
Надо сказать, это сама по себе весьма непростая задача, но нам нет необходимости искать оптимальный путь, так как в нашем случае любое решение лучше чем то, что у нас есть. Поэтому мы воспользуемся самым быстрым методом — методом ближайшего соседа. Суть его заключается в том, что на каждом шаге луч будет двигаться в ближайшую из оставшихся точек.
Сначала я написал скрипт на питоне, и подход оказался рабочим, однако быстро стало ясно, что сложность O^2 — это неприятно. И если для прорисовки наших 700 точек это требовало десятков секунд, то за время для 10 000 точек уже можно неспешно попить чайку. Поэтому, почесав в затылке, я переписал код на С, получив ускорение где‑то в 60 раз.
Результат неплох, смущало только то, что у меня в ноуте восемь логических ядер, из которых во время расчета одно пыхтело, а семь прохлаждались. И тут я вспомнил про Open MPI, с помощью которого вычисления можно распараллелить, практически не меняя кода. Программист из меня такой себе, и все стадии расчетов распараллелить у меня не вышло, но даже с тем, что удалось, получилось ускорить это дело еще в четыре раза.
В таком исполнении программулина обрабатывает 700 точек почти мгновенно, 10 000 — за пару секунд и в целом может переварить около 25 000 точек. Это успех. Также я добавил две полезные фишки. Отрисовка изображения в N заходов (три дают лучший результат) позволяет подавить мерцание при большом количестве точек и несколько снижает влияние наводок на видеоусилитель. Еще одна фишка — сохранение финального массива в файл .h, что позволяет в дальнейшем экономить время на обработку изображения.
#include "opencv2/imgproc.hpp"
#include "opencv2/imgcodecs.hpp"
#include <stdio.h>
#include <math.h>
#include <string.h>
#include <omp.h>
#define N 3 // Число заходов в финальном массиве
//#define SAVE 1
#ifndef _OPENMP
static_assert(false, "openmp support required");
#endif
#define FTDI 1
#ifdef FTDI
#include <stdlib.h>
extern "C"{
#include <mpsse.h>
}
#define SPEED 10000000
#endif
using namespace cv;
using namespace std;
Mat img;
uint32_t min(uint32_t *, uint32_t);
uint32_t min(uint32_t *data, uint32_t datasize){
uint32_t min_ind=0;
for(uint32_t i=0;i<datasize;i++) if(data[i]<data[min_ind]) min_ind=i;
return min_ind;
};
int main( int argc, const char** argv )
{
char filename[256]={0};
strcpy(filename,argv[1]);
img = imread(filename, IMREAD_GRAYSCALE);
// Создаем массив с координатами точек контура
// Изображение предварительно подготовлено
printf("//Mat.rows %u\r\n", img.rows);
printf("//Mat.cols %u\r\n", img.cols);
uint8_t *vect_x = (uint8_t*) calloc(img.rows*img.cols+1,sizeof(uint8_t));
uint8_t *vect_y = (uint8_t*) calloc(img.rows*img.cols+1,sizeof(uint8_t));
uint32_t n=0;
for( uint64_t i=0;i<img.rows*img.cols;i++){
if(img.data[i]<100){
vect_y[n]=i/img.cols;
vect_x[n]=i%img.cols;
n++;
}
}
printf("//n=%u\r\n",n);
// Оптимизируем последовательность точек жадным алгоритмом
uint32_t * M = (uint32_t*) calloc(n*n+1,sizeof(uint32_t));
#pragma omp parallel shared(M,vect_x,vect_y) num_threads(8)
{
# pragma omp for
for(uint32_t i=0;i<n;i++){
for(uint32_t j=i;j<n;j++){
// Настоящее расстояние нам не нужно, поэтому корень можно не извлекать
if(i!=j){
M[i*n+j]=(uint32_t)(pow((vect_x[i]-vect_x[j]),2)+pow((vect_y[i]-vect_y[j]),2));
M[j*n+i]=M[i*n+j];
}
else M[i*n+j]=0xffffffff;
}
}
}//# pragma omp for
//print_matrix(M,n);
uint32_t *way = (uint32_t*) calloc(img.rows*img.cols+1,sizeof(uint32_t));
uint32_t *S = (uint32_t*) calloc(n+1,sizeof(uint32_t));
way[0]=0;
for(uint32_t i=1;i<n;i++){
#pragma omp parallel shared(M) num_threads(8)
{
# pragma omp for
for(uint32_t j=0;j<n;j++) S[j]=M[way[i-1]*n+j];
}
way[i]=min(S,n);
//printf("min: %u \r\n",way[i]);
#pragma omp parallel shared(M,way) num_threads(8)
{
# pragma omp for
for(uint32_t j=0;j<i;j++){
M[(way[i]*n)+way[j]]=0xffffffff;
M[(way[j]*n)+way[i]]=0xffffffff;}
}
//printf("i=%u way[i]=%u\r\n",i,way[i]);
//print_matrix(M,n);
}
//print_mas(way,n);
uint8_t *out = (uint8_t*) calloc(2*n+1,sizeof(uint8_t));
uint32_t j=0;
for(uint32_t i=0;i<n;i++){
out[j++]=vect_x[way[i]];
out[j++]=vect_y[way[i]];
};
/**********************************************************
* Расставляли, расставляли, а теперь замиксуем в N заходов
**********************************************************/
uint8_t *out_mix = (uint8_t*) calloc(2*n+1,sizeof(uint8_t));
j=0;
uint32_t k=0;
for(uint32_t i=0;i<N;i++){
j=2*i;
do{
out_mix[k++]=out[j];
out_mix[k++]=out[j+1];
j+=2*N;
}while(j<2*n);
}
// Освобождаем память
free(M);
free(vect_x);
free(vect_y);
free(way);
free(S);
free(out);
#ifdef SAVE
char arr_name[256]={0};
char out_file_name[256]={0};
if(argc>2){
strcpy(out_file_name,argv[2]);
strncpy(arr_name, out_file_name,strlen(out_file_name)-2);
}
else {
strncpy(arr_name, filename,strlen(filename)-4);
strcat(out_file_name,arr_name);
strcat(out_file_name,".h");
};
printf("create file %s\r\n",out_file_name);
FILE *fout;
fout=fopen(out_file_name,"w");
fprintf(fout,
"//************************************************\r\n\
//final result\r\n\
//generated by c prog black\r\n\
//************************************************\r\n");
fprintf(fout,"static const uint8_t %s[]={\r\n",arr_name);
fprintf(fout,"%u, ",out_mix[0]);
for(uint16_t i=1;i<2*n;i++){
fprintf(fout,"%u, ",out_mix[i]);
if(!(i%10))fprintf(fout,"\r\n");
}
fprintf(fout,"};\r\n");
fclose(fout);
#endif
#ifdef FTDI
/*****************************************************************
* А теперь выведем с ходу на экран через FT232H
*****************************************************************/
struct mpsse_context *spi = NULL;
spi = MPSSE(SPI0, SPEED, LSB);
//for(uint8_t i=0;i<20;i++)printf("%u\r\n",out_mix[i]);
//while(1){
for(uint64_t i=0; i<600;i++){
Start(spi);
FastWrite(spi, (char*)out_mix, 2*n);
Stop(spi);
}
Close(spi);
#endif
}
Собираем программу.
g++ -O3 -fopenmp `pkg-config opencv4 --cflags --libs` black.c -o black -lmpsse
Теперь, если скормить этой программе нашу тестовую картинку, мы увидим, что артефактов стало заметно меньше без снижения битрейта. Вот так это выглядит при битрейте 10 Mбит/с.

Тем не менее артефакты отчетливо видны в местах перескока между буквами. Ну да я специально выбрал такое изображение, чтобы их было видно. У читателя здесь возникнет резонный вопрос: а стоит ли затрачивать такие усилия ради того, чтобы неидеально отрисовать столь простую картинку на высокой скорости? Может, лучше просто снизить скорость? Отвечу: затевалось все это, чтобы отрисовывать изображения гораздо более сложные, в 5000–20 000 точек, там снижать скорость — не вариант. Рассмотрим, например, картинку повеселее.

Легко видеть, что картинка гораздо более фактурная.
Она цветная, с полутонами, и разрешение далеко не 256 × 256. Как быть? Тут нам поможет imagemagick — продвинутый консольный редактор графики.
INFO
Все описанное дальше наверняка можно реализовать и средствами opencv, да и библиотеки imagemagick можно встроить прямо в код C. И это лучше бы удовлетворяло принципу KISS, но, как я уже говорил, программист я посредственный и ленивый, поэтому наваять пару скриптов на Shell мне проще.
Итак, есть два концептуально разных способа преобразовать цветное изображение в монохромное: выделить контур и отобразить цвет плотностью пикселов. Для выделения контура мой скрипт сначала сжимает картинку так, чтобы большая сторона составляла 256 пикселов, конвертирует изображение в градации серого и выделяет контур. Полученное изображение скармливается рассмотренной выше программе. Вот сам скрипт.
#!/bin/zsh
##kontur_gen.sh
IMG=$1
echo "$1"
STR=$(identify "$IMG"|awk -F' ' '{print $3}')
echo "$STR"
X=$(echo $STR|awk -F'x' '{print $1}')
Y=$(echo $STR|awk -F'x' '{print $2}')
echo "$X $Y"
if [ $((X)) -lt $((Y)) ];
then
echo "X<Y"
convert -normalize -resize x256 -colorspace gray -edge $2 -negate $IMG temp.png
else
echo "X>Y"
convert -normalize -resize 256x -colorspace gray -edge $2 -negate $IMG temp.png
fi
./black "temp.png" "${1:0:(-3)}h"
Параметр $2 влияет на глубину поиска контуров и в общем случае подбирается под изображение. Обычно лучше всего работает значение 2. Картинка после обработки выглядит так.

Изображение содержит около 12 000 точек и отображается на трубке вот так.

Второй скрипт правит яркость и контраст, снижает количество цветов до 16, меняет размер, переводит в оттенки серого и заменяет оттенки группами по 4 × 4 пиксела.
#!/bin/zsh
#kontur_gen2.sh
IMG=$1
STR=$(identify "$IMG"|awk -F' ' '{print $3}')
X=$(echo $STR|awk -F'x' '{print $1}')
Y=$(echo $STR|awk -F'x' '{print $2}')
if [ $((X)) -lt $((Y)) ]
then
convert -normalize -brightness-contrast -30x30 -colors 16 -colorspace gray -resize x256 -ordered-dither o4x4 -negate $IMG temp.png
else
convert -normalize -brightness-contrast -30x30 -colors 16 -colorspace gray -resize 256x -ordered-dither o4x4 -negate $IMG temp.png
fi
./black "temp.png" "${1:0:(-3)}h"
Картинка после обработки выглядит следующим образом.

Рисунок содержит около 8000 точек и вот так выглядит на экране.

Оба представленных скрипта переваривают большинство картинок. Какой из них справляется лучше, зависит от изображения, но в целом под каждый конкретный случай их можно оптимизировать, подстроив яркость/контраст. А с помощью несложного однострочного скрипта можно устроить даже слайд‑шоу.
for i in images/*;do ./kontur_gen2.sh $i;done
С изображениями разобрались, теперь перейдем к тексту.
ВЫВОДИМ ТЕКСТ
Со шрифтом, не мудрствуя лукаво, я решил поступить, как с изображениями. Взял библиотеку со шрифтом из проекта всеволнового приемника и сконвертировал формат. В итоге каждая буква описывается последовательностью координат пикселов. Неприятным в этом деле получается то, что в буквах разное количество пикселов и общий массив со шрифтом состоит из элементов разной длины. Поэтому формат хранения шрифта пришлось усложнить, в результате первый байт в описании символа указывает количество пикселов в нем, а смещение каждого символа хранится в отдельном массиве. Подробно разбирать сам конвертер формата шрифта я не вижу особого смысла, но код конвертера выложен на гитхабе, кому интересно — можно посмотреть.
Теперь дело за малым: пишем функцию отрисовки символа на экране (точнее, в буфере) и функцию отрисовки строки.
uint8_t buffer[10000];
uint16_t buf_shift=0;
uint16_t send_xy(uint8_t x, uint8_t y){
buffer[buf_shift++]=x;
buffer[buf_shift++]=y;
return buf_shift;
}
void send_char(uint8_t chr, uint8_t x, uint8_t y){
uint8_t c=(chr<0xe0) ? chr - 0x20 : chr - 0x50;
uint16_t sh=0;
uint8_t c_len=0;
sh=shift[c];
c_len=ASCII[sh];
sh++;
for(uint8_t j=0;j<3;j++)for(uint8_t i=0;i<c_len;i++)send_xy(ASCII[sh+2*i]+x,ASCII[sh+2*i+1]+y);
}
void send_str(char *str,uint8_t x, uint8_t y){
while(*str){
if((uint8_t)*str==0xd0||(uint8_t)*str==0xd1) str++;
if(*str=='\r'){x=0;str++;}
if(*str=='\n'){y+=8*2;str++;}
send_char((uint8_t)*str++,x,y);
x+=6*2;
}
}
Осталось только передать функции send_str() желаемую строку и положение ее начала на экране, после чего отправить буфер в ЦАП. Обрати внимание, что изображение каждого символа записывается в буфер трижды, что позволяет уменьшить количество артефактов и поднять яркость шрифта, если тот отображается совместно с другими изображениями на экране. Теперь, когда мы умеем выводить на экран графику и текст, мы можем сделать это одновременно (ну, почти). Шрифт и изображение включены в виде заголовочных файлов.
#include "vector_font.h"
#include <stdio.h>
#include <math.h>
#include <string.h>
#include <cstdint>
#include "xaker-logo.h"
#define FTDI 1
#ifdef FTDI
#include <stdlib.h>
extern "C"{
#include <mpsse.h>
}
#define SPEED 10000000
#endif
uint8_t buffer[10000];
uint16_t buf_shift=0;
uint16_t send_xy(uint8_t x, uint8_t y){
buffer[buf_shift++]=x;
buffer[buf_shift++]=y;
return buf_shift;
}
void send_char(uint8_t chr, uint8_t x, uint8_t y){
uint8_t c=(chr<0xe0) ? chr - 0x20 : chr - 0x50;
uint16_t sh=0;
uint8_t c_len=0;
sh=shift[c];
c_len=ASCII[sh];
sh++;
for(uint8_t j=0;j<3;j++)for(uint8_t i=0;i<c_len;i++)send_xy(ASCII[sh+2*i]+x,ASCII[sh+2*i+1]+y);
}
void send_str(char *str,uint8_t x, uint8_t y){
while(*str){
if((uint8_t)*str==0xd0||(uint8_t)*str==0xd1) str++;
if(*str=='\r'){x=0;str++;}
if(*str=='\n'){y+=8*2;str++;}
send_char((uint8_t)*str++,x,y);
x+=6*2;
}
}
int main( int argc, const char** argv )
{
send_str("Candidum 2022",1,80);
send_str("Eritis sicut Deus,\r\n scientes bonum\r\n et malum.",1,100);
#ifdef FTDI
/*****************************************************************
* А теперь выведем с ходу на экран через FT232H
*****************************************************************/
struct mpsse_context *spi = NULL;
spi = MPSSE(SPI0, SPEED, LSB);
while(1){
Start(spi);
FastWrite(spi, (char*)xaker_logo,sizeof(xaker_logo));
FastWrite(spi, (char*)buffer, buf_shift);
Stop(spi);
}
Close(spi);
#endif
}
Компилируем, запускаем и видим на экране следующее.

Выглядит довольно симпатично.
ЗАКЛЮЧЕНИЕ
Из всего описанного можно сделать вывод, что управлять осциллографической трубкой в целом довольно просто и выводить на нее изображение — тоже. Проблемы появляются с ростом сложности изображения, точнее с ростом количества пикселов. Именно поэтому большинство проектов в сети выводят очень простую картинку типа циферблата, стрелок и строчки текста. Ведь в конечном счете все упирается в видеоусилитель, и для того, чтобы в векторном формате вывести хорошую картинку, нужно, по сути, собрать осциллограф с полосой пропускания мегагерц десять, что уже большая задача. Поэтому для тех, кто захочет поэкспериментировать с изображением на CRT, но не дружит со схемотехникой, мой совет: купи простенький аналоговый осциллограф б/у, это будет и лучше, и дешевле. Но в целом ковыряться с осциллографической трубкой довольно интересно.
Читайте ещё больше платных статей бесплатно: https://t.me/hacker_frei