.. 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.