diff --git a/assets/face_wire.png b/assets/face_wire.png new file mode 100644 index 0000000..d9b6aab Binary files /dev/null and b/assets/face_wire.png differ diff --git a/display.py b/display.py new file mode 100644 index 0000000..3a97fd3 --- /dev/null +++ b/display.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +""" +JARVIS Display +────────────── +Renders the JARVIS UI overlay. Works in two modes: + + X11 (development laptop): + python display.py + + Linux framebuffer (device – no windowing system): + python display.py --framebuffer + # or + JARVIS_FB=1 python display.py + +Framebuffer prerequisites on Debian (device): + sudo apt install python3-pygame + sudo usermod -aG video $USER # then log out/in + # If fbdev fails (common on newer kernels) try KMS/DRM: + SDL_VIDEODRIVER=kmsdrm python display.py --framebuffer +""" + +import os +import sys +import math +import argparse +from datetime import datetime + +# ── CLI / env-var flags ───────────────────────────────────────────────────── +# These must be parsed BEFORE pygame is imported so we can set SDL env vars +# in time for pygame.init() to pick them up. + +_parser = argparse.ArgumentParser(description='JARVIS display', add_help=True) +_parser.add_argument( + '--framebuffer', '-fb', action='store_true', + help='Render to Linux framebuffer instead of an X11/Wayland window') +_parser.add_argument('--width', type=int, default=1280, + help='Screen / framebuffer width (default 1280)') +_parser.add_argument('--height', type=int, default=800, + help='Screen / framebuffer height (default 800)') +_args, _ = _parser.parse_known_args() + +USE_FB: bool = _args.framebuffer or bool(os.environ.get('JARVIS_FB')) + +if USE_FB: + # SDL2 env vars must be set before pygame.display.init() + os.environ.setdefault('SDL_VIDEODRIVER', 'fbdev') + os.environ.setdefault('SDL_FBDEV', os.environ.get('JARVIS_FBDEV', '/dev/fb0')) + # Prevent SDL trying to open a mouse device on the framebuffer + os.environ.setdefault('SDL_NOMOUSE', '1') + +import pygame # noqa: E402 — import after env setup + +# ── Colour palette ─────────────────────────────────────────────────────────── +BLACK = ( 0, 0, 0) +WHITE = (255, 255, 255) +GRAY = (160, 160, 160) +DIM_GRAY = ( 80, 80, 80) +WIRE_COL = ( 58, 58, 58) # subdued wireframe line colour + +# ── Wireframe face ──────────────────────────────────────────────────────────── +# Pure 2-D face drawing. Face shape is defined by an explicit width profile; +# eye sockets, nose bridge and chin are drawn as distinct features so the +# brain immediately reads "face" rather than a geometric solid. + +WIRE_EYE = ( 95, 95, 95) # slightly brighter for eye/feature lines + +def _hw(t: float, rx: float) -> float: + """ + Face half-width in pixels at normalised height t. + t = -1 → crown (top) + t = 0 → cheek level (widest, ~= rx) + t = +1 → chin tip + Upper half: rounded ellipse. + Lower half: faster taper toward chin. + """ + if t <= 0.0: + return rx * math.sqrt(max(0.0, 1.0 - t * t)) + else: + return rx * math.sqrt(max(0.0, 1.0 - t * t)) * (1.0 - 0.30 * t) + + +def draw_wireframe_face(surface: pygame.Surface, + cx: int, cy: int, + rx: float, ry: float) -> None: + """ + Draw a 2-D face wireframe centred at (cx, cy). + Coordinate system: t ∈ [−1, +1] (−1=crown, 0=cheeks, +1=chin) + x_fraction ∈ [−1, +1] (fraction of half-width at t) + """ + N_H = 13 # horizontal grid lines + N_V = 11 # vertical grid lines (including edges) + PTS = 40 # interpolation steps per curve + + # Helpers ───────────────────────────────────────────────────────────────── + def sc(xf: float, t: float) -> tuple[int, int]: + """xf (fraction of half-width) + height t → screen pixel.""" + w = _hw(t, rx) + # Slight forward bow: face curves toward viewer at cheek level + bow = int(0.06 * w * (1.0 - t * t) * (1.0 - xf * xf)) + return (int(cx + xf * w), int(cy + t * ry) - bow) + + def draw(pts, close=False, col=WIRE_COL): + if len(pts) >= 2: + pygame.draw.lines(surface, col, close, pts, 1) + + # Face outline (left and right silhouette) ──────────────────────────────── + t_vals = [(-1.0 + 2.0 * i / (PTS - 1)) for i in range(PTS)] + draw([sc(-1.0, t) for t in t_vals]) # left edge + draw([sc( 1.0, t) for t in t_vals]) # right edge + # Crown arc (straight line across the very top) + draw([sc(-1.0, -1.0), sc(1.0, -1.0)]) + # Chin arc + draw([sc(-0.6, 1.0), sc(0.0, 1.02), sc(0.6, 1.0)]) + + # Horizontal grid lines ─────────────────────────────────────────────────── + for i in range(1, N_H): + t = -1.0 + 2.0 * i / N_H + draw([sc(-1.0 + 2.0 * j / (PTS - 1), t) for j in range(PTS)]) + + # Vertical grid lines ───────────────────────────────────────────────────── + for i in range(1, N_V): + xf = -1.0 + 2.0 * i / N_V + draw([sc(xf, t) for t in t_vals]) + + # ── Eye sockets ─────────────────────────────────────────────────────────── + # Each eye is a small ellipse drawn in a slightly brighter colour so it + # immediately reads as a facial feature. + eye_t = -0.38 # height: upper face (~35% from crown) + eye_xf = 0.35 # lateral offset (fraction of half-width at eye_t) + eye_w = _hw(eye_t, rx) # face half-width at eye level + e_rx = int(eye_w * 0.24) + e_ry = int(ry * 0.09) + + for sign in (-1, +1): + ecx = int(cx + sign * eye_xf * eye_w) + ecy = int(cy + eye_t * ry) + eye_pts = [ + (int(ecx + e_rx * math.cos(2 * math.pi * k / 28)), + int(ecy + e_ry * math.sin(2 * math.pi * k / 28))) + for k in range(28) + ] + draw(eye_pts + [eye_pts[0]], col=WIRE_EYE) + + # ── Nose bridge ─────────────────────────────────────────────────────────── + # Two short lines running from inner eye corners down to nose tip + inner = int(eye_w * 0.12) + e_btm = int(cy + eye_t * ry) + e_ry + nose_y = int(cy + 0.08 * ry) + nose_w = int(eye_w * 0.07) + draw([(cx - inner, e_btm), (cx - nose_w, nose_y)], col=WIRE_COL) + draw([(cx + inner, e_btm), (cx + nose_w, nose_y)], col=WIRE_COL) + + +# ── Clock / date ───────────────────────────────────────────────────────────── + +def draw_clock(surface: pygame.Surface, fonts: dict, + x: int, y: int) -> None: + now = datetime.now() + date_str = now.strftime('%A, %B %-d, %Y') + time_str = now.strftime('%-H:%M') + secs_str = now.strftime('%S') + + # Date row + surface.blit(fonts['small'].render(date_str, True, GRAY), (x, y)) + + # Large time + superscript seconds + t_surf = fonts['large'].render(time_str, True, WHITE) + s_surf = fonts['small'].render(secs_str, True, DIM_GRAY) + ty = y + fonts['small'].get_height() + 4 + surface.blit(t_surf, (x, ty)) + surface.blit(s_surf, (x + t_surf.get_width() + 2, + ty + t_surf.get_height() - s_surf.get_height() - 8)) + + +# ── Status line ─────────────────────────────────────────────────────────────── + +def draw_status(surface: pygame.Surface, fonts: dict, + text: str, cx: int, y: int) -> None: + surf = fonts['small'].render(text, True, GRAY) + surface.blit(surf, (cx - surf.get_width() // 2, y)) + + +def draw_dot(surface: pygame.Surface, cx: int, y: int, + radius: int = 9) -> None: + pygame.draw.circle(surface, WHITE, (cx, y), radius) + + +# ── Font helper ─────────────────────────────────────────────────────────────── + +def _load_font(size: int, bold: bool = False) -> pygame.font.Font: + """Try a list of clean system fonts, fall back to pygame built-in.""" + for name in ('DejaVuSans', 'FreeSans', 'LiberationSans', + 'Helvetica', 'Arial', None): + try: + f = pygame.font.SysFont(name, size, bold=bold) + if f: + return f + except Exception: + pass + return pygame.font.Font(None, size) + + +# ── Main loop ───────────────────────────────────────────────────────────────── + +def main() -> None: + pygame.init() + pygame.mouse.set_visible(False) + + W, H = _args.width, _args.height + + if USE_FB: + screen = pygame.display.set_mode((W, H), pygame.FULLSCREEN | pygame.NOFRAME) + else: + screen = pygame.display.set_mode((W, H)) + pygame.display.set_caption('JARVIS') + + fonts = { + 'large': _load_font(76), + 'small': _load_font(24), + } + + # Layout anchors — all relative to screen size so they adapt to resolution + face_cx = W // 2 + face_cy = int(H * 0.31) + face_rx = int(min(W, H) * 0.150) # half-width at cheek level + face_ry = int(min(W, H) * 0.175) # half-height crown→chin + status_y = int(H * 0.82) + dot_y = int(H * 0.895) + + # ── Load face wireframe PNG if present ──────────────────────────────────── + # Place any face wireframe PNG at assets/face_wire.png and it will be + # used automatically. Black background is composited away via BLEND_ADD + # so only the bright lines show. Falls back to parametric drawing. + _here = os.path.dirname(os.path.abspath(__file__)) + _png_path = os.path.join(_here, 'assets', 'face_wire.png') + face_img = None + if os.path.exists(_png_path): + raw = pygame.image.load(_png_path).convert() + img_src_w, img_src_h = raw.get_size() + # Scale to fit face slot while preserving aspect ratio + scale = min((face_rx * 2.2) / img_src_w, + (face_ry * 2.4) / img_src_h) + img_w = max(1, int(img_src_w * scale)) + img_h = max(1, int(img_src_h * scale)) + face_img = pygame.transform.smoothscale(raw, (img_w, img_h)) + + status_text = 'Initializing...' + + clock = pygame.time.Clock() + + while True: + for event in pygame.event.get(): + if event.type == pygame.QUIT: + pygame.quit() + return + if event.type == pygame.KEYDOWN: + if event.key in (pygame.K_ESCAPE, pygame.K_q): + pygame.quit() + return + # Touch / mouse tap anywhere also quits in kiosk mode + if USE_FB and event.type == pygame.MOUSEBUTTONDOWN: + pygame.quit() + return + + screen.fill(BLACK) + if face_img: + screen.blit(face_img, + face_img.get_rect(center=(face_cx, face_cy)), + special_flags=pygame.BLEND_ADD) + else: + draw_wireframe_face(screen, face_cx, face_cy, face_rx, face_ry) + draw_clock(screen, fonts, 20, 14) + draw_status(screen, fonts, status_text, W // 2, status_y) + draw_dot(screen, W // 2, dot_y) + + pygame.display.flip() + clock.tick(10) # 10 fps is plenty for a status screen + + +if __name__ == '__main__': + main() diff --git a/gen_face.py b/gen_face.py new file mode 100644 index 0000000..0aba2cc --- /dev/null +++ b/gen_face.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +""" +gen_face.py – Run ONCE to generate assets/face_wire.png +───────────────────────────────────────────────────────── +Uses matplotlib's 3-D surface renderer to produce a wireframe face image +that display.py loads. Run it once; the PNG is then part of your project. + + python gen_face.py + +Dependencies (dev machine only – NOT needed on the target device): + sudo apt install python3-matplotlib # or: pip install matplotlib +""" + +import os +import math +import argparse +import numpy as np +import matplotlib +matplotlib.use('Agg') # off-screen — no display needed +import matplotlib.pyplot as plt +from mpl_toolkits.mplot3d import Axes3D # noqa: F401 + +# ── Face width profile ──────────────────────────────────────────────────────── +# (normalised_height, width_fraction) 0 = crown, 1 = chin +# Widest point is at the cheekbones (~t=0.37), not the crown. +# Crown and forehead are slightly narrower; jaw/chin taper below cheeks. +_PROFILE = [ + (0.00, 0.82), # crown + (0.10, 0.88), # upper skull + (0.22, 0.94), # temples / forehead + (0.37, 1.00), # cheekbones — widest + (0.50, 0.96), # nose level + (0.62, 0.84), # mouth level + (0.73, 0.68), # jaw angle + (0.83, 0.50), # lower jaw + (0.92, 0.30), # chin + (1.00, 0.08), # chin tip +] + + +def _face_r(v: float) -> float: + """Radial width at normalised height v ∈ [0, 1].""" + for i in range(len(_PROFILE) - 1): + t0, w0 = _PROFILE[i] + t1, w1 = _PROFILE[i + 1] + if t0 <= v <= t1: + return w0 + (v - t0) / (t1 - t0) * (w1 - w0) + return 0.0 + + +def face_surface(nu: int = 22, nv: int = 28): + """ + Build the face mesh arrays. + Axis convention chosen to match matplotlib's default camera behaviour: + X = left / right + Y = depth (positive = toward viewer / front of face) + Z = up / down (+1 = crown, −1 = chin) + To view from the front: view_init(elev, azim=90) ← camera is at +Y + """ + u_vals = np.linspace(-math.pi / 2, math.pi / 2, nu) # front hemisphere + v_vals = np.linspace(0.0, 1.0, nv) # crown → chin + + X = np.zeros((nv, nu)) + Y = np.zeros((nv, nu)) + Z = np.zeros((nv, nu)) + + for j, v in enumerate(v_vals): + r = _face_r(v) + z = math.cos(math.pi * v) # +1 at crown, −1 at chin + for i, u in enumerate(u_vals): + X[j, i] = r * math.sin(u) # left/right + Y[j, i] = r * math.cos(u) # depth (front = max) + Z[j, i] = z # height + + return X, Y, Z + + +def generate(out_path: str = 'assets/face_wire.png', + img_w: int = 400, img_h: int = 500, + line_col: str = '#606060') -> None: + dpi = 100 + fig = plt.figure(figsize=(img_w / dpi, img_h / dpi), facecolor='black') + ax = fig.add_subplot(111, projection='3d', facecolor='black') + + X, Y, Z = face_surface(nu=18, nv=22) + ax.plot_wireframe(X, Y, Z, + color=line_col, + linewidth=0.85, + rstride=1, cstride=1) + + # azim=90 → camera at +Y, looking toward −Y (directly at face) + # elev=-8 → camera slightly below horizontal = view from below, + # shows chin/jaw, hides flat top of mesh — matches reference + ax.view_init(elev=-8, azim=90) + + ax.set_axis_off() + # [x_width, y_depth, z_height] + # Key: y_depth very small → face is flat/mask-like, not a globe + ax.set_box_aspect([1.0, 0.28, 1.22]) + + plt.subplots_adjust(left=0, right=1, top=1, bottom=0) + + os.makedirs(os.path.dirname(out_path) if os.path.dirname(out_path) else '.', + exist_ok=True) + fig.savefig(out_path, dpi=dpi, facecolor='black', + bbox_inches='tight', pad_inches=0.02) + plt.close(fig) + print(f'Saved {out_path} ({img_w}×{img_h} px)') + + +if __name__ == '__main__': + ap = argparse.ArgumentParser() + ap.add_argument('--out', default='assets/face_wire.png') + ap.add_argument('--width', type=int, default=400) + ap.add_argument('--height', type=int, default=500) + ap.add_argument('--color', default='#606060') + args = ap.parse_args() + generate(args.out, args.width, args.height, args.color) diff --git a/gen_face_ai.py b/gen_face_ai.py new file mode 100644 index 0000000..000a4fb --- /dev/null +++ b/gen_face_ai.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +""" +gen_face_ai.py – Generate assets/face_wire.png using Gemini image generation +──────────────────────────────────────────────────────────────────────────────── +Uses the Gemini / Google Gen AI SDK with a native image-generation model +(gemini-2.5-flash-image or similar) to produce a wireframe face PNG ready for +display.py to load. + + pip install google-genai pillow + python3 gen_face_ai.py + +Or set the key in the environment: + export GEMINI_API_KEY=YOUR_KEY + python3 gen_face_ai.py +""" + +import os +import sys +import io +import argparse + +# ── Paste your Gemini API key here ──────────────────────────────────────────── +API_KEY = 'AQ.Ab8RN6LuGwkGiKPa61jsLAEYEpJp1Yl2EkZuBWTbN9AMKxgTSw' +# ───────────────────────────────────────────────────────────────────────────── + +# ── CLI ─────────────────────────────────────────────────────────────────────── +ap = argparse.ArgumentParser() +ap.add_argument('--key', default='', help='Override the hardcoded API key') +ap.add_argument('--out', default='assets/face_wire.png') +ap.add_argument('--model', default='gemini-2.5-flash-image', + help='Gemini image model to use (default: gemini-2.5-flash-image)') +ap.add_argument('--list-models', action='store_true', + help='Print models that support generateContent then exit') +args = ap.parse_args() + +api_key = args.key or API_KEY or os.environ.get('GEMINI_API_KEY', '') +if not api_key: + sys.exit('ERROR: paste your key into API_KEY at the top of this file') + +# ── Install check ───────────────────────────────────────────────────────────── +try: + from google import genai + from google.genai import types +except ImportError: + sys.exit('Run: pip install google-genai then try again.') + +try: + from PIL import Image +except ImportError: + sys.exit('Run: pip install pillow then try again.') + +# ── Connect ─────────────────────────────────────────────────────────────────── +print('Connecting to Google GenAI …') +client = genai.Client(api_key=api_key) + +if args.list_models: + print('\nModels with generateContent support:') + for m in client.models.list(): + methods = getattr(m, 'supported_actions', None) or getattr(m, 'supported_methods', None) or [] + if 'generateContent' in methods: + print(f' {m.name}') + sys.exit(0) + +# ── Prompt ──────────────────────────────────────────────────────────────────── +PROMPT = ( + "3D wireframe polygon mesh of a human head and face, viewed from slightly " + "below, front-facing, neutral expression, pure black background, thin " + "light gray lines only forming a grid over the head and face, no colour " + "fill, no skin texture, no neck, symmetrical, sci-fi holographic display " + "style, high contrast monochrome" +) + +# ── Generate ────────────────────────────────────────────────────────────────── +print(f'Generating with {args.model} …') +response = client.models.generate_content( + model = args.model, + contents= PROMPT, + config = types.GenerateContentConfig( + response_modalities = ['IMAGE', 'TEXT'], + ), +) + +# ── Extract image bytes ─────────────────────────────────────────────────────── +img_bytes = None +for part in response.candidates[0].content.parts: + if part.inline_data and part.inline_data.mime_type.startswith('image/'): + img_bytes = part.inline_data.data + break + +if img_bytes is None: + # Print any text the model returned to help debug + for part in response.candidates[0].content.parts: + if hasattr(part, 'text') and part.text: + print('Model text response:', part.text[:400]) + sys.exit('No image in response – try a different --model') + +# ── Save as PNG ─────────────────────────────────────────────────────────────── +os.makedirs(os.path.dirname(args.out) if os.path.dirname(args.out) else '.', + exist_ok=True) + +# Convert whatever format came back to a proper PNG +img = Image.open(io.BytesIO(img_bytes)) +img.save(args.out, 'PNG') + +print(f'Saved {args.out} ({img.width}×{img.height} px)') +print('Run python3 display.py to see it.') diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1cf18fe --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +# JARVIS dependencies +# Install on both dev laptop and device: +# sudo apt install python3-pygame python3-pvporcupine python3-pvrecorder +# or: +# pip install pygame pvporcupine pvrecorder (add --break-system-packages on Debian 13) + +pygame>=2.1.0 +pvporcupine +pvrecorder