REELS OS - strumieniowania ekranu windows -> arduino

Typ_projektu
Arduino
Krótki opis projektu

REELS OS to projekt miniaturowego, zewnętrznego monitora dedykowanego do przeglądania mediów społecznościowych w formacie pionowym. System pozwala na bezprzewodowe (poprzez port szeregowy) strumieniowanie obrazu z komputera bezpośrednio na ekran OLED, wykorzystując zaawansowane algorytmy ditheringu do uzyskania czytelnego obrazu 1-bitowego. Dzięki integracji fizycznego panelu sterowania, użytkownik może zarządzać odtwarzaniem filmów i nawigacją w przeglądarce za pomocą przycisków na urządzeniu, co tworzy unikalny, "gadżetowy" ekosystem do interakcji z ulubionymi treściami

Niezbędne elementy

Do poprawnego działania projektu wymagane jest środowisko Python 3 oraz zestaw bibliotek obsługujących komunikację i przetwarzanie danych.

Biblioteki po stronie komputera (Python):

  • mss: Wykorzystywana do błyskawicznego przechwytywania klatek z ekranu monitora.

  • Pillow (PIL): Odpowiada za zaawansowane przetwarzanie obrazu, zmianę rozmiarów oraz proces ditheringu (rozproszenia błędów).

  • numpy: Służy do szybkich operacji macierzowych i pakowania bitów do formatu zrozumiałego dla kontrolera OLED.

  • pyserial: Zapewnia stabilną komunikację szeregową z prędkością 500 000 bodów.

  • keyboard: Umożliwia emulację naciśnięć klawiszy systemowych na podstawie sygnałów z przycisków Arduino.

  • webbrowser: Służy do automatycznego otwierania dedykowanych adresów URL.

  • threading & time: Zarządzają współbieżnością procesów (niezależne wysyłanie obrazu i odbieranie komend).

Biblioteki po stronie Arduino:

  • U8g2lib: Najwydajniejsza biblioteka graficzna obsługująca pełne buforowanie obrazu na sterowniku SH1106.

  • Wire.h: Standardowa biblioteka do komunikacji I2C z wyświetlaczem.

Sprzęt
  1. Mikrokontroler: Arduino (np. Uno lub Nano) wyposażone w układ ATmega328P.
  2. Wyświetlacz: Moduł OLED 1.3" o rozdzielczości 128x64 pikseli, oparty na sterowniku SH1106 z interfejsem I2C.
  3. Panel sterowania: 6 przycisków typu tact-switch podłączonych do wejść cyfrowych z wykorzystaniem wewnętrznych rezystorów podciągających.
  4. Komputer: 1 post USB, System operacyjny Windows11
    (na systemie linux DE:gnome-wayland zostały napotkanie problemy z udostępnieniem ekranu, MacOS nie był testowany)
  5. Akcesoria: Płytka stykowa (breadboard), przewody połączeniowe typu "jumper" oraz kabel USB do transmisji danych i zasilania
Opis projektu

 Usunięto obraz.

Usunięto obraz.


Usunięto obraz.
Usunięto obraz.
Projekt REELS OS to innowacyjne rozwiązanie programowe, które przekształca standardowy mikrokontroler ATmega328P oraz wyświetlacz OLED SH1106 w interaktywny, zewnętrzny monitor dedykowany do konsumpcji treści wertykalnych. Główna zasada działania opiera się na ścisłej współpracy skryptu napisanego w języku Python z Arduino, co pozwala na strumieniowanie wideo w czasie rzeczywistym +/-10FPS przy zachowaniu wysokiej płynności. Proces rozpoczyna się od inteligentnego przechwytywania fragmentu ekranu komputera, gdzie algorytm automatycznie lokalizuje środkową strefę monitora, aby idealnie wykadrować materiały o proporcjach 9:16, takie jak popularne rolki czy krótkie filmy wideo. Przechwycony obraz poddawany jest zaawansowanej obróbce cyfrowej, w ramach której system analizuje średnią jasność klatki i dynamicznie dostosowuje kontrast oraz ostrość, przygotowując grafikę do wyświetlenia na czarno-białej matrycy. Aby zachować głębię obrazu przy ograniczeniu do zaledwie dwóch kolorów, zastosowano algorytm ditheringu metodą Floyda-Steinberga, który poprzez odpowiednie rozmieszczenie czarnych i białych punktów tworzy iluzję odcieni szarości.

Przetworzone dane są następnie rotowane programowo i pakowane bitowo w taki sposób, aby ich struktura w pamięci komputera odpowiadała fizycznemu układowi stron w sterowniku wyświetlacza, co drastycznie przyspiesza proces renderowania po stronie sprzętowej. Transmisja danych odbywa się przez szybki port szeregowy z prędkością 500 000 bodów, przy czym każda ramka obrazu jest zabezpieczona specjalnym protokołem z bajtami ucieczki, co eliminuje ryzyko desynchronizacji sygnału przy wysokim klatkażu. Po stronie odbiorczej mikrokontroler błyskawicznie przepisuje dane bezpośrednio do bufora ekranu, wykorzystując zoptymalizowaną magistralę I2C pracującą z częstotliwością 800 kHz, co pozwala na niemal natychmiastowe odświeżenie matrycy. System nie ogranicza się jedynie do odbioru obrazu, ponieważ zintegrowany panel sześciu przycisków pozwala na wysyłanie komend zwrotnych do komputera, umożliwiając tym samym pełną kontrolę nad przeglądarką, w tym przewijanie treści czy odświeżanie stron bez użycia myszki. Całość dopełnia inteligentny system zarządzania stanem połączenia, który w momencie wykrycia braku sygnału z hosta uruchamia estetyczną animację oczekiwania, zachowując przy tym na ekranie logi systemowe informujące o gotowości sprzętu. Dzięki takiemu podejściu REELS OS nie jest tylko prostym wyświetlaczem, ale kompletnym ekosystemem multimedialnym, który maksymalnie wykorzystuje ograniczone zasoby mikrokontrolera do płynnego odtwarzania dynamicznego wideo.

 

kod programu

Kod Arduino:
 

#include <Wire.h>
#include <U8g2lib.h>
 
// OLED 128x64, rotated (portrait mode - 64px wide, 128px tall)
// Full framebuffer mode for fast screen updates
U8G2_SH1106_128X64_NONAME_F_HW_I2C u8g2(U8G2_R3, U8X8_PIN_NONE);
 
// Button pin definitions
#define BTN_UP    7
#define BTN_DOWN  9
#define BTN_RIGHT 8
#define BTN_LEFT  10
#define BTN_A     5
#define BTN_B     4
 
// Timestamp of last received serial frame
// Used to detect loss of video signal
unsigned long lastSerialTime = 0;
const unsigned long serialTimeout = 1000; // 1 second timeout
 
// Previous button states (for edge detection)
int pU=0, pD=0, pL=0, pR=0, pA=0, pB=0;
 
// Fake system boot logs displayed during boot / no-signal state
const char* sys_logs[] = {
  "MCU: 328P",
  "I2C: 800K",
  "SRL: 500K",
  "BUF: 1024B",
  "REELS..."
};
 
// Draw static system log lines (used in boot & idle screens)
void drawStaticSystemLogs(int limit) {
  u8g2.setFont(u8g2_font_4x6_tf);
  for(int i = 0; i < limit; i++) {
    // Logs start lower to leave room for header
    u8g2.setCursor(2, 40 + (i * 9));
    u8g2.print("[OK] ");
    u8g2.print(sys_logs[i]);
  }
}
 
// Boot animation displayed on power-up
void playBootAnimation() {
 
  // --- SPLASH SCREEN ---
  u8g2.clearBuffer();
  u8g2.setFont(u8g2_font_6x12_tf);
  u8g2.drawStr(5, 20, "REELS OS");
  u8g2.drawHLine(2, 25, 60);
  u8g2.sendBuffer();
  delay(400);
 
  // --- FAKE SYSTEM LOGS ---
  for(int i = 1; i <= 5; i++) {
    u8g2.clearBuffer();
 
    // Persistent header
    u8g2.setFont(u8g2_font_6x12_tf);
    u8g2.drawStr(5, 20, "REELS OS");
    u8g2.drawHLine(2, 25, 60);
 
    drawStaticSystemLogs(i);
    u8g2.sendBuffer();
    delay(30);
  }
}
 
void setup() {
  // High-speed serial for video data
  Serial.begin(500000);
 
  // Increase I2C clock for fast OLED refresh
  Wire.setClock(800000);
 
  // Initialize OLED display
  u8g2.begin();
  u8g2.setFlipMode(1);
 
  // Show boot animation at startup
  playBootAnimation();
 
  lastSerialTime = millis();
 
  // Configure buttons with internal pullups
  pinMode(BTN_UP,    INPUT_PULLUP);
  pinMode(BTN_DOWN,  INPUT_PULLUP);
  pinMode(BTN_LEFT,  INPUT_PULLUP);
  pinMode(BTN_RIGHT, INPUT_PULLUP);
  pinMode(BTN_A,     INPUT_PULLUP);
  pinMode(BTN_B,     INPUT_PULLUP);
}
 
// Send button state only when it changes (reduces serial traffic)
void sendBtn(const char *name, int state, int &prev) {
  if (state != prev) {
    Serial.print(name);
    Serial.print(" ");
    Serial.println(state);
    prev = state;
  }
}
 
void loop() {
 
  // --- HANDLE BUTTON INPUTS ---
  sendBtn("UP",    !digitalRead(BTN_UP),    pU);
  sendBtn("DOWN",  !digitalRead(BTN_DOWN),  pD);
  sendBtn("LEFT",  !digitalRead(BTN_LEFT),  pL);
  sendBtn("RIGHT", !digitalRead(BTN_RIGHT), pR);
  sendBtn("A",     !digitalRead(BTN_A),     pA);
  sendBtn("B",     !digitalRead(BTN_B),     pB);
 
  // State machine for framed serial protocol
  static bool receiving = false;
  static bool escaping  = false;
  static int  idx = 0;
 
  // Direct pointer to OLED framebuffer (1024 bytes)
  uint8_t* screenBuffer = u8g2.getBufferPtr();
 
  // --- SERIAL VIDEO RECEPTION ---
  if (Serial.available()) {
    lastSerialTime = millis();
 
    while (Serial.available()) {
      uint8_t c = Serial.read();
 
      // Start frame marker
      if (!receiving) {
        if (c == 0xFF) {
          receiving = true;
          escaping = false;
          idx = 0;
        }
      }
      // End frame marker
      else if (c == 0xFE) {
        // Only draw when full frame received
        if (idx == 1024) {
          u8g2.sendBuffer();
        }
        receiving = false;
      }
      // Restart frame if unexpected start marker
      else if (c == 0xFF) {
        escaping = false;
        idx = 0;
      }
      // Handle escaped bytes
      else if (escaping) {
        escaping = false;
        uint8_t decoded =
          (c == 0x00) ? 0xFD :
          (c == 0x01) ? 0xFF :
          (c == 0x02) ? 0xFE : c;
 
        if (idx < 1024) screenBuffer[idx++] = decoded;
      }
      // Escape byte detected
      else if (c == 0xFD) {
        escaping = true;
      }
      // Normal framebuffer data
      else {
        if (idx < 1024) screenBuffer[idx++] = c;
      }
    }
  }
 
  // --- NO SIGNAL / IDLE MODE ---
  else if (millis() - lastSerialTime > serialTimeout) {
    u8g2.clearBuffer();
    // Header
    u8g2.setFont(u8g2_font_6x12_tf);
    u8g2.drawStr(5, 20, "REELS OS");
    u8g2.drawHLine(2, 25, 60);
    // Static logs
    drawStaticSystemLogs(5);
    u8g2.drawHLine(2, 90, 60);

    // Status text + spinner animation
    u8g2.setFont(u8g2_font_4x6_tf);
    u8g2.drawStr(2, 102, "LINK: OFFLINE");
    const char* spinner = "|/-\\";
    u8g2.setCursor(2, 115);
    u8g2.print("NO SIGNAL ");
    u8g2.print(spinner[(millis() / 150) % 4]);
 
    u8g2.sendBuffer();
  }
}

 

Kod na PC (Windows) - Wariant rolki

import serial
import time
import threading
import keyboard
import numpy as np
from PIL import Image, ImageEnhance, ImageOps
import mss
import webbrowser
 
# -------------------------------------------------
# CONFIG
# -------------------------------------------------
COM_PORT = "COM6"
BAUD = 500000
WIDTH = 128   # Physical OLED Width
HEIGHT = 64   # Physical OLED Height
 
# Global serial object
ser = None
 
def get_serial_connection():
    """Attempts to connect to the Arduino. Loops until successful."""
    global ser
    while True:
        try:
            print(f"🔍 Searching for USB on {COM_PORT}...")
            ser = serial.Serial(COM_PORT, BAUD, timeout=0.05)
            print(f"✅ Connected to {COM_PORT}")
            return # Exit loop once connected
        except (serial.SerialException, FileNotFoundError):
            print(f"❌ USB not found. Retrying in 2 seconds...")
            time.sleep(2)
 
# Initial connection attempt
get_serial_connection()
 
# -------------------------------------------------
# CAPTURE & PROCESS
# -------------------------------------------------
def auto_contrast(img):
    """
    Optimizes the image for a 1-bit OLED.
    Uses a center-mask to ignore dark/bright UI edges when calculating contrast.
    """
    gray = img.convert("L") # Convert to Grayscale
    w, h = gray.size
   
    # Define the 'Interest Zone' (Center 50% of the screen)
    left, top = w // 4, h // 4
    right, bottom = int(w * 0.75), int(h * 0.75)
   
    # Calculate average brightness of the center crop
    center_crop = gray.crop((left, top, right, bottom))
    mean = np.array(center_crop).mean()
 
    # If the center is dark, boost contrast and brightness more aggressively
    contrast_factor = 1.2 if mean > 120 else 1.6
    brightness_factor = 1.0 if mean > 120 else 1.2
 
    # Mask for Autocontrast logic
    mask = Image.new("L", (w, h), 0)
    white_box = Image.new("L", (right - left, bottom - top), 255)
    mask.paste(white_box, (left, top))
 
    # Apply processing chain
    gray = ImageOps.autocontrast(gray, cutoff=2, mask=mask)
    gray = ImageEnhance.Contrast(gray).enhance(contrast_factor)
    gray = ImageEnhance.Brightness(gray).enhance(brightness_factor)
    gray = ImageEnhance.Sharpness(gray).enhance(1.3)
 
    return gray
 
def capture_and_dither(sct, capture_region):
    """
    Captures screen, resizes to vertical dimensions, dithers to 1-bit,
    and packs bits into a format the SH1106 driver understands.
    """
    img = sct.grab(capture_region)
    pil = Image.frombytes("RGB", img.size, img.rgb)
   
    # Resize to 64x128 (matches the logical R1 orientation of the OLED)
    pil = pil.resize((HEIGHT, WIDTH), Image.BILINEAR)
    pil = auto_contrast(pil)
   
    # Convert to 1-bit black and white using Floyd-Steinberg dithering
    pil = pil.convert("1", dither=Image.FLOYDSTEINBERG)
   
    # Rotate 90deg to match physical orientation
    pil = pil.rotate(90, expand=True)
   
    # Reorganize the numpy array to match the OLED's page-based memory layout
    arr = np.array(pil).reshape((8, 8, 128))[:, ::-1, :]
    return np.packbits(arr, axis=1).tobytes()
 
# -------------------------------------------------
# SERIAL PROTOCOL
# -------------------------------------------------
START = bytes([0xFF]) # Start of frame marker
END   = bytes([0xFE]) # End of frame marker
 
def escape_frame(data: bytes) -> bytes:
    """
    Prevents data bytes from being confused with START/END markers.
    If 0xFF appears in the image, it's swapped for an escape sequence.
    """
    return data.replace(b'\xfd', b'\xfd\x00') \
               .replace(b'\xff', b'\xfd\x01') \
               .replace(b'\xfe', b'\xfd\x02')
 
def send_frame(frame: bytes):
    """Sends the escaped byte frame over serial."""
    global ser
    try:
        if ser and ser.is_open:
            ser.write(START + escape_frame(frame) + END)
    except (serial.SerialException, AttributeError):
        # If writing fails, the USB was likely pulled
        ser = None
 
# -------------------------------------------------
# THREADS
# -------------------------------------------------
def stream_thread():
    """Captures screen and sends to Arduino as fast as possible."""
    global ser
    with mss.mss() as sct:
        monitor = sct.monitors[1]
       
        # Calculate a 1:2 aspect ratio box centered on the screen
        capture_height = monitor["height"]
        capture_width = capture_height // 2
        left_offset = (monitor["width"] - capture_width) // 2
 
        capture_region = {
            "top": monitor["top"],
            "left": monitor["left"] + left_offset,
            "width": capture_width,
            "height": capture_height
        }
       
        while True:
            if ser:
                frame = capture_and_dither(sct, capture_region)
                send_frame(frame)
                time.sleep(0.001) # High-speed refresh
            else:
                # If serial is lost, wait and let the main loop reconnect
                time.sleep(1)
 
def handle_input(line):
    """Maps Arduino serial messages to PC keyboard actions."""
    try:
        btn, val = line.split()
        val = int(val)
    except:
        return
       
    if btn == "A":
        if val == 1:
            print("🚀 Reels launched")
            webbrowser.open("https://www.instagram.com/reels/")
           
    elif btn == "B":
        if val == 1:
            print("🔄 Refreshing")
            keyboard.send("f5")
           
    else:
        # Physical to Logical mapping for 90deg rotation
        mapping = {
            "UP":    "right",
            "RIGHT": "down",
            "DOWN":  "left",
            "LEFT":  "up"
        }
        if btn in mapping:
            key = mapping[btn]
            keyboard.press(key) if val else keyboard.release(key)
 
def input_thread():
    """Listens for button presses coming FROM the Arduino."""
    global ser
    while True:
        if ser:
            try:
                line = ser.readline().decode(errors="ignore").strip()
                if line:
                    handle_input(line)
            except:
                ser = None # Trigger reconnection
        else:
            time.sleep(1)
 
# -------------------------------------------------
# MAIN RUNNER
# -------------------------------------------------
print("✅ REELS OS HOST STARTED")
 
# Start threads as Daemons so they close when the script exits
threading.Thread(target=stream_thread, daemon=True).start()
threading.Thread(target=input_thread, daemon=True).start()
 
try:
    while True:
        # If the serial object becomes None, try to reconnect
        if ser is None:
            get_serial_connection()
        time.sleep(1)
except KeyboardInterrupt:
    print("👋 Shutting down...")

 

Kod na PC (Windows) - Wariant Fullscreen

import serial
import time
import threading
import keyboard
import numpy as np
from PIL import Image, ImageEnhance, ImageOps
import mss
 
# -------------------------------------------------
# CONFIG
# -------------------------------------------------
COM_PORT = "COM6"
BAUD = 500000
WIDTH = 128
HEIGHT = 64
 
ser = serial.Serial(COM_PORT, BAUD, timeout=0.01)
 
# -------------------------------------------------
# CAPTURE & PROCESS
# -------------------------------------------------
def auto_contrast(img):
    gray = img.convert("L")
    w, h = gray.size
   
    # Calculate coordinates for the center 50% of the image
    left = w // 4
    top = h // 4
    right = int(w * 0.75)
    bottom = int(h * 0.75)
   
    # 1. Get the mean brightness of ONLY the center crop
    center_crop = gray.crop((left, top, right, bottom))
    mean = np.array(center_crop).mean()
 
    # Dynamic tuning based on the center brightness
    contrast_factor = 1.2 if mean > 120 else 1.6
    brightness_factor = 1.0 if mean > 120 else 1.2
 
    # 2. Create a mask so autocontrast only looks at the center
    mask = Image.new("L", (w, h), 0)
    white_box = Image.new("L", (right - left, bottom - top), 255)
    mask.paste(white_box, (left, top))
 
    # Apply autocontrast. It calculates the stretch using ONLY the white masked
    # area (the center), but applies the visual change to the ENTIRE image!
    gray = ImageOps.autocontrast(gray, cutoff=2, mask=mask)
    gray = ImageEnhance.Contrast(gray).enhance(contrast_factor)
    gray = ImageEnhance.Brightness(gray).enhance(brightness_factor)
    gray = ImageEnhance.Sharpness(gray).enhance(1.3)
 
    return gray
 
def capture_and_dither(sct, capture_region):
    img = sct.grab(capture_region)
    pil = Image.frombytes("RGB", img.size, img.rgb)
   
    # Resize to standard horizontal OLED dimensions (128x64)
    pil = pil.resize((WIDTH, HEIGHT), Image.BILINEAR)
   
    pil = auto_contrast(pil)
    pil = pil.convert("1", dither=Image.FLOYDSTEINBERG)
   
    # Numpy bit-rotation to match OLED hardware memory structure
    arr = np.array(pil).reshape((8, 8, 128))[:, ::-1, :]
    return np.packbits(arr, axis=1).tobytes()
 
# -------------------------------------------------
# SEND FRAME
# -------------------------------------------------
START = bytes([0xFF])
END   = bytes([0xFE])
 
def escape_frame(data: bytes) -> bytes:
    return data.replace(b'\xfd', b'\xfd\x00') \
               .replace(b'\xff', b'\xfd\x01') \
               .replace(b'\xfe', b'\xfd\x02')
 
def send_frame(frame: bytes):
    ser.write(START + escape_frame(frame) + END)
 
# -------------------------------------------------
# STREAMING THREAD
# -------------------------------------------------
def stream_thread():
    with mss.mss() as sct:
        # Get dimensions of the ENTIRE primary monitor
        monitor = sct.monitors[1]
       
        print(f"📷 Capturing Full Screen. Resolution: {monitor['width']}x{monitor['height']}")
 
        while True:
            # Pass the full monitor directly to the capture function
            frame = capture_and_dither(sct, monitor)
            send_frame(frame)
            time.sleep(0.005) # Tiny delay to keep the Serial connection stable
 
# -------------------------------------------------
# BUTTON HANDLING
# -------------------------------------------------
def handle_input(line):
    try:
        btn, val = line.split()
        val = int(val)
    except:
        return
       
    # Normal horizontal mappings
    mapping = {
        "UP":    "up",   "DOWN":  "down",
        "LEFT":  "left", "RIGHT": "right",
        "A":     "z",    "B":     "x",
    }
   
    if btn in mapping:
        key = mapping[btn]
        keyboard.press(key) if val else keyboard.release(key)
 
def input_thread():
    while True:
        try:
            line = ser.readline().decode(errors="ignore").strip()
            if line:
                handle_input(line)
        except:
            pass
 
# -------------------------------------------------
# START
# -------------------------------------------------
print("✅ Streaming Full Screen - Horizontal (Max FPS Numpy Mode)")
threading.Thread(target=stream_thread, daemon=True).start()
threading.Thread(target=input_thread, daemon=True).start()
 
while True:
    time.sleep(0.1)


 

Youtube
Tagi
Arduino Screen I2C gra Ekran