Знаете, есть две вещи, в которых я не могу себе отказать — набрать полную корзину на AliExpress и порадовать котанчика. Даже если это будет котанчик в фоторамке:
Как вы могли догадаться, этот пост будет о фоторамке с котаном на каждый день. Помогать в этом нам будут генераторы случайных токенов, пока хайп-трейн не сошёл с рельс.
Планчик
Как и любое другое дело, сначала стоит прикинуть планчик. В этот раз он совсем бесхитростный:
Я бы даже сказал, довольно стандартный для такого рода проектов:
- мутим шедулер, который нарандомит постер «кот дня»
- заливаем его в S3
- оттуда его скачает ESP32
- и уже он выведет его на e-Paper дисплей
Можно было бы подумать, что самое время начать с генератора изображений, но это не совсем так. С e-Paper, имхо, удобнее идти от обратного — сначала выбрать железку по вкусу/кошельку, а уже затем подгонять под неё генератор. Так и поступим.
О железах
Признаюсь честно, железки я долго не выбирал. Зашёл на сайт Waveshare и нашёл чудненький e-Paper дисплей:
- 7.5", разрешение 800x480px
- четыре цвета: красный, жёлтый, чёрный и белый
- обновляется около 20 секунд. Скорее всего, можно ускорить до 12, но я не пробовал — мне не принципиально
- управляется по SPI, можно взять комплект сразу с драйвером (я так и сделал)
- выглядит вот так:
В пару к нему в пару взял BPI-Leaf-S3:
Не то чтобы он как-то сильно выделялся на фоне других ESP32-бордов, но:
- он у меня уже был ^_^
- имеет 2MB PSRAM на борту, что сильно добавляет удобства
Единственное, у него было два бесячих светодиода, которые совсем не вписывались в мою концепцию, а потому были удалены:
Теперь, когда ограничения стали понятны, можно приступить к котгену.
О котгене
Уж не знаю, сколько нужно обезьян, чтобы одна из них напечатала произведение Шекспира. Но я был выжат досуха после круговорота тупизны из переписывания промптов и их отправки в разные модельки. В итоге, решив, что лучшее — враг хорошего, смирился с непредсказуемостью результата и пришёл к флоу ниже.
Сначала отправляю в ChatGPT промпт с заданием придумать кото-цитату дня и намутить для неё композицию:
You must generate **one new English quote** in the style of the examples (humor, irony, slightly philosophical, minimalistic). The quote must contain a surprising twist or reversal of expectation.
Then, adapt the **poster prompt** so that the composition the mood of the new quote. The cat must remain black and white.
Replace `<QUOTE OF THE DAY>` in the poster prompt with the generated quote.
**Output format:**
Return strictly in JSON:
{"quote": "<quote>", "prompt": "<poster prompt with the quote inserted>"}
---
### Quote rules:
- English only.
- 1–2 sentences maximum.
- Humor, irony, minimalism.
- Must contain a small unexpected twist or contrast.
- No emojis, smilies, or extra symbols.
---
### Poster-prompt rules:
Base style:
Minimalist wireframe brush-pen style poster of a lone cat.
Clean, bold silkscreen aesthetic with EXACTLY four solid flat colors:
- pure yellow (background)
- pure red (small accents)
- pure black (silhouettes)
- pure white
Strict rules:
- Background must be entirely pure yellow.
- Absolutely NO frames, borders, rectangles, textures, or extra layers.
- Red is ONLY for small accent scratches, details, or marks.
- No gradients, shading, halftone patterns, outlines, or extra colors.
- Must look like a flat silkscreen print or bold brush-pen artwork.
- Black and White cat.
Composition rules:
- <COMPOSITION>
Scale and framing:
- The cat must be large, dominating the composition and filling most of the frame.
- The cat must be black and white.
- Composition must use full bleed, edge-to-edge design, with minimal unused background space.
Typography requirement:
- At the very bottom of the composition, include EXACTLY the following quote as bold monospaced lettering:
“<QUOTE OF THE DAY>”
- The text must appear as part of the artwork (not metadata), spanning the full width as a clean strip, horizontally aligned, and clearly visible.
Atmosphere: sharp minimalism, bold irony, humor
---
Your task:
- First, come up with a new quote (in English).
- Then adapt the composition to match it.
- Return JSON with two fields: "quote" and "prompt".
На выходе получаю:
- цитату:
I looked into the abyss; it blinked slowly
- и промпт к ней:
Minimalist wireframe brush-pen style poster of a lone cat.
Clean, bold silkscreen aesthetic with EXACTLY four solid flat colors:
- pure yellow (background)
- pure red (small accents)
- pure black (silhouettes)
- pure white
Strict rules:
- Background must be entirely pure yellow.
- Absolutely NO frames, borders, rectangles, textures, or extra layers.
- Red is ONLY for small accent scratches, details, or marks.
- No gradients, shading, halftone patterns, outlines, or extra colors.
- Must look like a flat silkscreen print or bold brush-pen artwork.
- Black and White cat.
Composition rules:
- Extreme close-up of a black-and-white cat face filling 85–90% of the frame like a looming void.
- Massive black facial mass with crisp white muzzle patch; one full white almond eye, the other eye a narrow white slit mid-blink to suggest the "abyss" blinking.
- Thick, confident brush-pen edges carve the silhouette; white whiskers cut into the black.
- Ears crop off at the top edge; cheeks and whiskers push against the left and right edges for full-bleed presence.
- Two or three tiny pure red tick marks near the half-closed eyelid to imply a slow blink; optionally one micro red scratch under the eye. Keep red minimal.
- No props, no background objects—only the cat against pure yellow.
- Reserve a narrow full-width yellow baseline at the very bottom for the quote; do not place black shapes behind the text area.
Scale and framing:
- The cat must be large, dominating the composition and filling most of the frame.
- The cat must be black and white.
- Composition must use full bleed, edge-to-edge design, with minimal unused background space.
Typography requirement:
- At the very bottom of the composition, include EXACTLY the following quote as bold monospaced lettering:
“I looked into the abyss; it blinked slowly.”
- The text must appear as part of the artwork (not metadata), spanning the full width as a clean strip, horizontally aligned, and clearly visible.
- Text-only on yellow background; no boxes, rules, or additional shapes.
Отправляю получившийся промпт в Ideogram (по ощущениям, косячил он меньше остальных) с запросом на генерацию трёх изображений.
На выходе получаю первое:
Второе:
И третье:
Затем отправляю все три в ChatGPT с просьбой выбрать лучший:
Your task is to select the single best image from the list. The chosen image should be the clearest and best represent the quote: I looked into the abyss; it blinked slowly.
- Prefer images that include the quote text, but only if the text is clear and accurately matches the quote.
- If no image with the quote meets these conditions, choose the clearest image that visually represents the quote instead.
- Return only the number of the selected image, with no additional text or explanation."
Он выбирает один из них:
Осталось сделать постобработку изображения. Дело в том, что у нас всего 4 цвета, а на картинках выше куча полутонов, с которыми нужно что-то делать. Насколько я знаю, с e-Paper дисплеями чаще всего используют какой-нить алгоритм дизеринга (Atkinson, Floyd–Steinberg и пр.). Но мне результат не понравился (с B/W было бы норм, кста), поэтому я применил квантизацию цветовой палитры с разного рода эвристиками (писал в паре с ChatGPT, потому грязненько):
def quantize_to_palette(img: Image.Image) -> Image.Image:
# Convert to array [0,1]
arr = np.array(img.convert("RGB"), dtype=np.float32) / 255.0
h, w, _ = arr.shape
hsv = rgb_to_hsv_np(arr)
H, S, V = hsv[..., 0], hsv[..., 1], hsv[..., 2]
# Palette (RGB 0–1)
palette_rgb = np.array([
[0, 0, 0], # black
[1, 1, 1], # white
[1, 0, 0], # red
[1, 1, 0], # yellow
], dtype=np.float32)
# Start with -1 (unassigned)
nearest = np.full((h, w), -1, dtype=np.int32)
# --- Rule-based overrides ---
# White: low saturation, high brightness
mask_white = (S < 0.25) & (V > 0.75)
nearest[mask_white] = 1
# Yellow: hue in [35–75 deg] and reasonably saturated
mask_yellow = (H >= 35/360.0) & (H <= 75/360.0) & (S > 0.3) & (V > 0.25)
nearest[mask_yellow] = 3
# --- Fallback: nearest in RGB ---
unassigned = nearest == -1
pixels = arr[unassigned].reshape(-1, 3)
if pixels.size > 0:
dist = np.sum((pixels[:, None, :] - palette_rgb[None, :, :]) ** 2, axis=-1)
nearest_vals = np.argmin(dist, axis=1)
nearest[unassigned] = nearest_vals
# Map back to RGB
result = (palette_rgb[nearest] * 255).astype(np.uint8)
return Image.fromarray(result)
Получаю результат, пригодный для e-Paper дисплея (IRL будет симпатичнее):
Который уже можно загрузить в S3 для употребления :) Вот «кот на каждый день» и готов — самое время всё подсобрать.
О сборке
А для этого печатаю кейс (передник, серединник и задник):
Это модификация Raspberry Pi Zero 7.5 eInk Waveshare Frame/Case с доработками.
Прикидываю, как буду подключать дисплей к ESP32, через RPI гребенку драйвера (см. E-Paper_Driver_HAT_(B) Schematic):
ESP32-S3 | Driver Board (RPI GPIO) |
---|---|
3v3 | #2: RPI 5V |
GND | #6: GND |
GPIO15 | #11: EPD_RST |
GPIO4 | #12: PWR |
GPIO5 | #18: EPD_BUSY |
GPIO16 | #19: EPD_DIN |
GPIO41 | #20: EPD_DC |
GPIO17 | #23: EPD_SCK |
GPIO42 | #24: EPD_CS |
Немного МГФТ, изоленты и запаха припоя:
Собираю луковичку:
И…кажется…я что-то забыл…
О фирмваре
Ах да… котген есть, фоторамка собрана, осталось её оживить. Я бы мог написать фирмварю сам, но зачем, если у нас есть ESPHome? Вот и я подумал, что незачем, а потому:
- добавляю поддержку этого дисплея в ESPHome (пока не приняли, переписывают компонент целиком): https://github.com/esphome/esphome/pull/10709
- пишу конфиг для моего BPI-Leaf-S3 (будет обновлять постер каждое пробуждение от сна):
esphome:
name: catoftheday
friendly_name: CatOfTheDay
esp32:
board: esp32-s3-devkitc-1
framework:
type: esp-idf
psram:
mode: quad
speed: 80MHz
external_components:
- source: github://pr#10709
components: [waveshare_epaper]
refresh: 1h
# Enable logging
logger:
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
on_connect:
- component.update: poster_img
http_request:
id: fetch_image_request
timeout: 5s
online_image:
- id: poster_img
url: "https://storage.yandexcloud.net/cat-of-the-day/poster.bmp"
format: BMP
type: RGB
on_download_finished:
component.update: poster_display
on_error:
then:
- logger.log: "unable to download poster"
spi:
clk_pin: GPIO17
mosi_pin: GPIO16
display:
- id: poster_display
platform: waveshare_epaper
cs_pin: GPIO42
dc_pin: GPIO41
busy_pin: GPIO5
reset_pin: GPIO15
power_pin: GPIO4
model: 7.50in-h
update_interval: never
lambda: |-
it.image(0, 0, id(poster_img));
ESP_LOGD("display", "poster displayed successfully");
deep_sleep:
run_duration: 180s
sleep_duration: 28800s # 8h
- прошиваю и миау:
Заключение
Честно сказать, у меня осталось двоякое впечатление: возиться с железками и 3D-принтером было прикольно, возиться с генераторами случайных токенов — ну такое, занятие очень на любителя, коим я так и не стал. Если кому пригодится, то скрипт для котгена лежит тут (осторожно, грязненький питоний): https://github.com/buglloc/cat-of-the-day/
А пока у меня всё. Всем кота! (づ˶•༝•˶)づ♡