.. include:: /index.rst :start-after: start_hello_message :end-before: end_hello_message .. _py_joystick_eye: 4.13 Joystick-gesteuertes Auge ================================= **Einführung** In dieser Lektion bauen Sie ein **joystick-gesteuertes Auge**, das auf einem 128×64-SSD1306-OLED-Bildschirm dargestellt wird. Ein zweiachsiger analoger Joystick liefert X/Y-Bewegungen, die über die ADC-Kanäle des Fusion HAT+ ausgelesen werden. Das OLED zeigt ein stilisiertes „Auge“, dessen Pupille der Richtung des Joysticks flüssig folgt. Dieses Projekt demonstriert: - das Auslesen mehrachsiger analoger Eingaben - sanfte Wertnormalisierung und Dead-Zone-Behandlung - geometrische Begrenzungen mithilfe einer Ellipse - Echtzeit-Grafikdarstellung auf einem monochromen OLED Wenn Sie den Joystick bewegen, gleiten Iris und Pupille innerhalb des Auges, ohne die äußere Begrenzung der Sklera zu überschreiten. ---------------------------------------------- **Was Sie benötigen** Für dieses Projekt werden die folgenden Komponenten benötigt: .. list-table:: :widths: 30 20 :header-rows: 1 * - KOMPONENTENBESCHREIBUNG - KAUFLINK * - :ref:`cpn_wires` - |link_wires_buy| * - :ref:`cpn_joystick` - \- * - :ref:`cpn_oled` - \- * - :ref:`cpn_fusion_hat` - \- * - Raspberry Pi - \- ---------------------------------------------- **Verdrahtungsdiagramm** Verwenden Sie das folgende Verdrahtungsdiagramm, um die Komponenten korrekt zu verbinden: .. image:: img/fzz/4.13_joystick_eye_bb.png :width: 100% :align: center ---------------------------------------------- **Einrichtungsschritte** #. Installieren Sie die erforderlichen Bibliotheken: .. raw:: html .. code-block:: shell sudo pip3 install adafruit-circuitpython-ssd1306 --break #. Führen Sie das Beispiel aus dem Verzeichnis ``ai-lab-kit`` aus: .. raw:: html .. code-block:: shell cd ~/ai-lab-kit/python/ sudo python3 4.13_JoystickEye.py #. Wenn das Skript ausgeführt wird: * Durch Bewegen des Joysticks nach links/rechts/oben/unten bewegt sich die Pupille entsprechend. * Die Pupillenbewegung wird geglättet und innerhalb eines elliptischen Auges begrenzt. * Das Display wird mit etwa **50 FPS** aktualisiert. * Drücken Sie **Ctrl+C**, um das Programm zu beenden. ---------------------------------------------- **Code** Hier ist das Python-Skript für das joystick-gesteuerte Auge: .. raw:: html .. code-block:: python #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Joystick-controlled eye on a 128x64 SSD1306 OLED. - Joystick X/Y on ADC A0/A1 (0..4095) - Pupil moves inside a drawn eye based on joystick deflection """ import time from math import sqrt from fusion_hat.adc import ADC from PIL import Image, ImageDraw, ImageFont import adafruit_ssd1306 import board # ===== OLED setup ===== WIDTH, HEIGHT = 128, 64 i2c = board.I2C() oled = adafruit_ssd1306.SSD1306_I2C(WIDTH, HEIGHT, i2c, addr=0x3C) oled.fill(0) oled.show() # Framebuffer for drawing image = Image.new("1", (WIDTH, HEIGHT)) draw = ImageDraw.Draw(image) font = ImageFont.load_default() # ===== Joystick (two ADC channels) ===== # Adjust channel names to your hardware if needed (e.g., 'A1','A2') joy_x = ADC('A0') # X axis joy_y = ADC('A1') # Y axis # ===== Mapping helpers ===== def linear_map(x, in_min, in_max, out_min, out_max): """Map x from one range to another (float).""" return (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min def clamp(v, vmin, vmax): """Clamp v to [vmin, vmax].""" return vmax if v > vmax else vmin if v < vmin else v # ===== Eye layout (tweak to your taste) ===== # Eye center and sizes EYE_CX, EYE_CY = WIDTH // 2, HEIGHT // 2 EYE_W, EYE_H = 90, 48 # outer ellipse width/height (sclera) IRIS_R = 10 # iris radius (white ring, optional) PUPIL_R = 7 # pupil radius (black) BORDER = 2 # outer eye border thickness in pixels # Pupil movement limits (keep pupil inside sclera with some margin) # We'll approximate the inside of the eye as an ellipse and limit the pupil center PUPIL_MARGIN = PUPIL_R + 3 MAX_X = (EYE_W // 2) - PUPIL_MARGIN MAX_Y = (EYE_H // 2) - PUPIL_MARGIN # ===== Joystick normalization settings ===== # Joystick nominal center near 2048; define a dead zone and smoothing ADC_MIN, ADC_MAX = 0.0, 4095.0 ADC_CENTER_X = 2048.0 ADC_CENTER_Y = 2048.0 DEADZONE = 120.0 # +/- counts considered centered (no move) SMOOTH_ALPHA = 0.35 # EMA smoothing factor for normalized values # State for smoothing nx_smooth, ny_smooth = 0.0, 0.0 def read_joystick_norm(): """ Read joystick and produce normalized values in [-1, 1] for X and Y, with dead zone and smoothing. Y is inverted so up is positive. """ global nx_smooth, ny_smooth rx = float(joy_x.read()) ry = float(joy_y.read()) # Raw offset from center dx = rx - ADC_CENTER_X dy = ry - ADC_CENTER_Y # Deadzone if abs(dx) < DEADZONE: dx = 0.0 if abs(dy) < DEADZONE: dy = 0.0 # Normalize to [-1, 1] # Use the larger of (center-min) / (max-center) to reduce asymmetry span_x_pos = ADC_MAX - ADC_CENTER_X span_x_neg = ADC_CENTER_X - ADC_MIN span_y_pos = ADC_MAX - ADC_CENTER_Y span_y_neg = ADC_CENTER_Y - ADC_MIN nx = dx / (span_x_pos if dx >= 0 else span_x_neg) if dx != 0 else 0.0 ny = dy / (span_y_pos if dy >= 0 else span_y_neg) if dy != 0 else 0.0 # Invert Y so pushing stick up moves pupil up ny = -ny # Clamp to [-1, 1] nx = clamp(nx, -1.0, 1.0) ny = clamp(ny, -1.0, 1.0) # Exponential smoothing nx_smooth = SMOOTH_ALPHA * nx + (1.0 - SMOOTH_ALPHA) * nx_smooth ny_smooth = SMOOTH_ALPHA * ny + (1.0 - SMOOTH_ALPHA) * ny_smooth # Re-clamp after smoothing nx_smooth_clamped = clamp(nx_smooth, -1.0, 1.0) ny_smooth_clamped = clamp(ny_smooth, -1.0, 1.0) return nx_smooth_clamped, ny_smooth_clamped def pupil_target_from_norm(nx, ny): """ Map normalized joystick values [-1..1] to a legal pupil center inside the eye. We limit by ellipse equation (x/MAX_X)^2 + (y/MAX_Y)^2 <= 1. If outside, project back to the ellipse boundary. """ # Proposed offsets px = nx * MAX_X py = ny * MAX_Y # Check ellipse boundary; if outside, project back if (px*px) / (MAX_X*MAX_X) + (py*py) / (MAX_Y*MAX_Y) > 1.0: # Normalize direction to ellipse edge # Scale so that (px/MAX_X, py/MAX_Y) lies on unit circle k = 1.0 / sqrt((px*px) / (MAX_X*MAX_X) + (py*py) / (MAX_Y*MAX_Y)) px *= k py *= k # Final pupil center cx = int(EYE_CX + px) cy = int(EYE_CY + py) return cx, cy def draw_eye(cx, cy): """ Draw the eye (sclera outline + filled sclera) and the pupil at (cx, cy). Monochrome: 1 = white (ON), 0 = black (OFF). """ draw.rectangle((0, 0, WIDTH, HEIGHT), outline=0, fill=0) # Outer eye (white sclera with border) # Outer ellipse x0 = EYE_CX - EYE_W // 2 y0 = EYE_CY - EYE_H // 2 x1 = EYE_CX + EYE_W // 2 y1 = EYE_CY + EYE_H // 2 # Border: draw multiple outlines to simulate thickness for t in range(BORDER): draw.ellipse((x0 - t, y0 - t, x1 + t, y1 + t), outline=1, fill=0) # Filled sclera (white) draw.ellipse((x0 + 1, y0 + 1, x1 - 1, y1 - 1), outline=0, fill=1) # Optional iris ring (white ring around pupil); comment out if undesired if IRIS_R > PUPIL_R: draw.ellipse((cx - IRIS_R, cy - IRIS_R, cx + IRIS_R, cy + IRIS_R), outline=0, fill=0) draw.ellipse((cx - (IRIS_R - 2), cy - (IRIS_R - 2), cx + (IRIS_R - 2), cy + (IRIS_R - 2)), outline=0, fill=1) # Pupil (black) draw.ellipse((cx - PUPIL_R, cy - PUPIL_R, cx + PUPIL_R, cy + PUPIL_R), outline=0, fill=0) # Optional text (for debugging) # txt = "X/Y" # tw, th = font.getsize(txt) # draw.text((2, 2), txt, font=font, fill=1) def main(): try: while True: # Read joystick → normalized [-1..1] nx, ny = read_joystick_norm() # Compute pupil center within eye bounds cx, cy = pupil_target_from_norm(nx, ny) # Draw and show draw_eye(cx, cy) oled.image(image) oled.show() time.sleep(0.02) # ~50 FPS except KeyboardInterrupt: oled.fill(0) oled.show() print("\nExited.") if __name__ == "__main__": main() ---------------------------------------------- **Code verstehen** 1. **Analoges Auslesen des Joysticks** Zwei ADC-Kanäle lesen die X- und Y-Positionen ``(0..4095)``. Das Skript wandelt diese Werte in normalisierte Bewegungen ``[-1..1]`` um mit: - Dead-Zone-Behandlung - Richtungsinvertierung (oben = positives Y) - Glättung mithilfe eines exponentiellen gleitenden Durchschnitts (EMA) .. code-block:: python def read_joystick_norm(): """ Read joystick and produce normalized values in [-1, 1] for X and Y, with dead zone and smoothing. Y is inverted so up is positive. """ global nx_smooth, ny_smooth rx = float(joy_x.read()) ry = float(joy_y.read()) # Raw offset from center dx = rx - ADC_CENTER_X dy = ry - ADC_CENTER_Y ... 2. **Geometrie des Auges** Das Auge wird gezeichnet als: - eine Ellipse für die Sklera (weiß) - optionaler äußerer Rand - optionaler Iris-Ring - eine schwarze Pupille .. code-block:: python def draw_eye(cx, cy): """ Draw the eye (sclera outline + filled sclera) and the pupil at (cx, cy). Monochrome: 1 = white (ON), 0 = black (OFF). """ draw.rectangle((0, 0, WIDTH, HEIGHT), outline=0, fill=0) # Outer eye (white sclera with border) # Outer ellipse x0 = EYE_CX - EYE_W // 2 y0 = EYE_CY - EYE_H // 2 x1 = EYE_CX + EYE_W // 2 y1 = EYE_CY + EYE_H // 2 ... 3. **Bewegungsbegrenzung der Pupille** Der Mittelpunkt der Pupille wird durch die Ellipsengleichung begrenzt: ``(px/MAX_X)^2 + (py/MAX_Y)^2 <= 1`` Wenn eine Eingabe die Pupille außerhalb des Auges bewegen würde, projiziert das Skript sie zurück auf die Ellipsengrenze. .. code-block:: python def pupil_target_from_norm(nx, ny): """ Map normalized joystick values [-1..1] to a legal pupil center inside the eye. We limit by ellipse equation (x/MAX_X)^2 + (y/MAX_Y)^2 <= 1. If outside, project back to the ellipse boundary. """ # Proposed offsets px = nx * MAX_X py = ny * MAX_Y # Check ellipse boundary; if outside, project back if (px*px) / (MAX_X*MAX_X) + (py*py) / (MAX_Y*MAX_Y) > 1.0: ... 4. **Darstellung (Rendering)** In jedem Frame: - wird der Hintergrund des Auges neu gezeichnet - wird die Position der Pupille berechnet - wird der Bildpuffer an das OLED gesendet - wird das Display mit etwa **50 FPS** aktualisiert 5. **Hauptschleife** Liest kontinuierlich den Joystick → normalisiert die Werte → berechnet die Pupillenposition → zeichnet das Auge neu → aktualisiert das Display. ---------------------------------------------- **Fehlerbehebung** - **Joystickbewegung wirkt ruckelig** - Erhöhen Sie ``SMOOTH_ALPHA`` - Erhöhen Sie ``DEADZONE`` - **Auge wirkt gestreckt oder zu klein** Passen Sie Folgendes an: .. code-block:: python EYE_W, EYE_H = 90, 48 - **Pupille ragt aus dem Auge heraus** Erhöhen Sie: .. code-block:: python PUPIL_MARGIN = PUPIL_R + 3 - **Joystickachsen sind vertauscht** Tauschen Sie die ADC-Kanäle oder invertieren Sie die Vorzeichen. ---------------------------------------------- **Probieren Sie es selbst aus** 1. **Blinkanimation hinzufügen** Fügen Sie ein zeitgesteuertes Öffnen und Schließen der Augenlider hinzu. 2. **Wütende / fröhliche Augenbrauen** Zeichnen Sie stilisierte Augenbrauen, die auf die Joystickbewegung reagieren. 3. **Follow-the-Dot-Spiel** Fügen Sie einen beweglichen Zielpunkt hinzu, den das Auge „verfolgen“ soll. 4. **Taste zum Umschalten von Modi** Wechseln Sie zwischen verschiedenen Augenstilen: Roboterauge, Cartoon-Auge, Katzenauge. 5. **Mehrere Augen anzeigen** Zeichnen Sie zwei Augen, die sich gemeinsam oder unabhängig voneinander bewegen. Diese Erweiterungen verwandeln das einfache Joystick-Auge in eine leistungsfähige OLED-Animations- und Interaktionsdemo.