Знаете, есть две вещи, в которых я не могу себе отказать — набрать полную корзину на 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-S3Driver 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/

А пока у меня всё. Всем кота! (づ˶•༝•˶)づ♡