Skip to content

Commit

Permalink
eye calibration process
Browse files Browse the repository at this point in the history
  • Loading branch information
Matt White committed Jul 17, 2022
1 parent 7ac33fd commit b547c47
Show file tree
Hide file tree
Showing 8 changed files with 468 additions and 234 deletions.
1 change: 1 addition & 0 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pip = "*"
adafruit-pca9685 = "*"
adafruit-circuitpython-servokit = "*"
pyttsx3 = "*"
pydantic = "*"

[dev-packages]
ipython = "*"
Expand Down
463 changes: 274 additions & 189 deletions Pipfile.lock

Large diffs are not rendered by default.

22 changes: 18 additions & 4 deletions main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from typing import Optional

import typer
import asyncio
from loguru import logger

from yuri.config import Config, ConfigFactory
Expand All @@ -13,7 +14,7 @@

app = typer.Typer()

DEFAULT_CONFIG_LOCATION = "yuri.yaml"
DEFAULT_CONFIG_LOCATION = "yuri.json"


def get_config(config_path: Optional[str]) -> Config:
Expand Down Expand Up @@ -54,12 +55,16 @@ def colors(seconds: int = 3, config_path: Optional[str] = None):
lights.cycle_colors(seconds)


async def asay(message: str, config: Config):
servos = Servos(config)
speaker = SpeakerFactory.create(config)
await asyncio.gather(servos.eyes.blink_loop(), speaker.say(message))

@app.command()
def say(message: str, config_path: Optional[str] = None):
config = get_config(config_path)
speaker = SpeakerFactory.create(config)
speaker.say(message)

asyncio.run(asay(message, config))


@app.command()
def transcribe(config_path: Optional[str] = None):
Expand All @@ -69,6 +74,15 @@ def transcribe(config_path: Optional[str] = None):
transcription = listener.transcribe(audio)
logger.info(transcription)

@app.command()
def calibrate(config_path: Optional[str] = None):
config = get_config(config_path)
servos = Servos(config)
speaker = SpeakerFactory.create(config)
asyncio.run(speaker.say("Let's calibrate."))
inputs = Input(config)
servos.eyes.calibrate(inputs, config)
config.save(config_path or DEFAULT_CONFIG_LOCATION)

@app.command()
def repeat(config_path: Optional[str] = None):
Expand Down
12 changes: 12 additions & 0 deletions yuri.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"listener_type": "sphinx",
"speaker_type": "google",
"left_eye": {
"neutral_x": 129.9125660837739,
"neutral_y": 100.04676697844651
},
"right_eye": {
"neutral_x": 80.42903619357463,
"neutral_y": 129.9125660837739
}
}
1 change: 0 additions & 1 deletion yuri.yaml

This file was deleted.

65 changes: 45 additions & 20 deletions yuri/config.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,56 @@
from dataclasses import dataclass, field
from dataclasses import field
from pydantic.dataclasses import dataclass
from pydantic import BaseModel

import board
import yaml


@dataclass
class Pins:
dotstar_clock: int = board.D6
dotstar_data: int = board.D5
button: int = board.D17
joydown: int = board.D27
joyleft: int = board.D22
joyup: int = board.D23
joyright: int = board.D24
joyselect: int = board.D16

@dataclass
class Config:
import json
from typing import Optional
from loguru import logger

Pin = board.pin.Pin

class Pins(BaseModel):
dotstar_clock: Pin = board.D6
dotstar_data: Pin = board.D5
button: Pin = board.D17
joydown: Pin = board.D27
joyleft: Pin = board.D22
joyup: Pin = board.D23
joyright: Pin = board.D24
joyselect: Pin = board.D16

class Config:
arbitrary_types_allowed = True


class Eye(BaseModel):
neutral_x: Optional[float] = None
neutral_y: Optional[float] = None

class Config(BaseModel):
listener_type: str = "sphinx"
speaker_type: str = "google"
pins: Pins = field(default_factory=Pins)
pins: Pins = Pins()
left_eye: Eye = Eye()
right_eye: Eye = Eye()

def save(self, location: str):
with open(location, "w") as config_file:
config_file.write(json.dumps(self.dict(exclude={"pins"}), indent=2))



class ConfigFactory:
@classmethod
def create(cls, location: str) -> Config:
with open(location) as config_file:
file_data = yaml.load(config_file, Loader=yaml.FullLoader)
obj = {}
try:
obj = json.loads(config_file.read())
except json.JSONDecodeError:
logger.warning("error parsing config")

config = Config.parse_obj(obj)


return Config(**(file_data or {}))
return config
132 changes: 115 additions & 17 deletions yuri/servos.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import time
import board
import busio
import asyncio
import math
import random

from typing import List
from dataclasses import dataclass
Expand All @@ -11,8 +14,17 @@
from adafruit_pca9685 import PCA9685

from yuri.config import Config
from yuri.input import Input

async def move(servo: Servo, target_angle: float, smoothing_factor: float = 0.80):
# Keep iterating until the target angle's reached
while not math.isclose(servo.angle, target_angle, rel_tol=0.02):
current_angle = servo.angle
smoothed_angle = (target_angle * smoothing_factor) + (current_angle * (1.0 - smoothing_factor))

# Don't move onto the next smooth target until the current angle is achieved.
servo.angle = smoothed_angle
await asyncio.sleep(0.02)

@dataclass
class Eyes:
Expand All @@ -25,15 +37,106 @@ class Eyes:
right_y: Servo
right_x: Servo

def open(self, wide: bool = False):
self.lower_lids.angle = 16 if wide else 13
self.upper_lids.angle = 13 if wide else 10
@property
def servos(self) -> List[Servo]:
return [
self.upper_lids,
self.lower_lids,
self.left_y,
self.left_x,
self.right_y,
self.right_x,
]

def init(self, config: Config):
# Set everything to neutral
for servo in self.servos:
servo.angle = 0.5 * servo.actuation_range

if config.left_eye.neutral_x is not None:
self.left_x.angle = config.left_eye.neutral_x
if config.left_eye.neutral_y is not None:
self.left_y.angle = config.left_eye.neutral_y
if config.right_eye.neutral_x is not None:
self.right_x.angle = config.right_eye.neutral_x
if config.right_eye.neutral_y is not None:
self.right_y.angle = config.right_eye.neutral_y

def calibrate(self, inputs: Input, config: Config):
asyncio.run(self.open(True))
for eye in ("left", "right"):
logger.info(f"calibrate {eye} eye")
y = getattr(self, f"{eye}_y")
x = getattr(self, f"{eye}_x")
incr = 3

while inputs.button.value:
logger.info(f"x:{x.angle:.2f} | y:{y.angle:.2f}")
if not inputs.joyup.value:
y.angle = min(y.angle + incr, y.actuation_range)
if not inputs.joydown.value:
y.angle = max(y.angle - incr, 0)
if not inputs.joyleft.value:
x.angle = max(x.angle - incr, 0)
if not inputs.joyright.value:
x.angle = min(x.angle + incr, x.actuation_range)

time.sleep(0.3)
config_eye = getattr(config, f"{eye}_eye")
config_eye.neutral_x = x.angle
config_eye.neutral_y = y.angle
logger.info(f"Done calibrating {eye} eye")

time.sleep(1.5)


async def open(self, wide: bool = False):
await asyncio.gather(
move(self.lower_lids, 15 if wide else 13),
move(self.upper_lids, 12 if wide else 10),
)

async def close(self):
await asyncio.gather(
move(self.lower_lids, 10.0),
move(self.upper_lids, 7.0),
)


async def blink_loop(self):

while True:
await self.close()
await self.open(wide=True)
await self.open()
await asyncio.sleep(random.random() * 3.0)

def close(self):
self.lower_lids.angle = 10
self.upper_lids.angle = 7
async def look(self):
"""
UP
self.right_y = 0
self.left_y = 180
DOWN
self.right_y = 180
self.left_y = 0
RIGHT
self.right_x = 0
self.left_x = 0
LEFT
self.right_x = 180
self.left_x = 180
"""


await asyncio.gather(
move(self.lower_lids, 10.0),
move(self.upper_lids, 7.0),
)

FAST = 0.3


class Servos:
Expand All @@ -45,13 +148,14 @@ def __init__(self, config: Config):

self.eyes = Eyes(
lower_lids=Servo(self.pca.channels[0], actuation_range=30),
right_y=Servo(self.pca.channels[1], actuation_range=30),
right_x=Servo(self.pca.channels[2], actuation_range=30),
right_y=Servo(self.pca.channels[1], actuation_range=180),
right_x=Servo(self.pca.channels[2], actuation_range=180),

upper_lids=Servo(self.pca.channels[4], actuation_range=30),
left_y=Servo(self.pca.channels[5], actuation_range=30),
left_x=Servo(self.pca.channels[6], actuation_range=30),
left_y=Servo(self.pca.channels[5], actuation_range=180),
left_x=Servo(self.pca.channels[6], actuation_range=180),
)
self.eyes.init(self.config)

def rotate(self):
logger.info("triggering servos")
Expand All @@ -64,9 +168,3 @@ def rotate(self):
logger.info("done")


def smile(self):
self.eye_l.throttle = FAST
self.eye_r.throttle = FAST
time.sleep(.001)
self.eye_l.throttle = 0
self.eye_r.throttle = 0
6 changes: 3 additions & 3 deletions yuri/speaker.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ def __init__(self, config: Config):
self.config = config

@abstractmethod
def say(self, message: str):
async def say(self, message: str):
raise NotImplementedError()


class GoogleSpeaker(Speaker):
def say(self, message: str):
async def say(self, message: str):
logger.info("say.start", message=message)

mp3_fp = BytesIO()
Expand All @@ -42,7 +42,7 @@ def __init__(self, config: Config):
self.engine.setProperty("voice", self.engine.getProperty("voices")[15].id)
self.engine.setProperty("rate", 160)

def say(self, message: str):
async def say(self, message: str):
logger.info("say.start", message=message)
self.engine.say(message)
self.engine.runAndWait()
Expand Down

0 comments on commit b547c47

Please sign in to comment.