Change interface to clickable (38c3)

This commit is contained in:
woju 2024-11-28 18:06:22 +01:00
parent 0b7aa02f44
commit bc9a7c56e9
Signed by: woju
GPG key ID: 81E859ECD7FB4F51
16 changed files with 1099 additions and 522 deletions

View file

@ -13,7 +13,7 @@ from loguru import logger
from . import (
detector as _detector,
game_bt,
game_common,
bluetooth,
)
@ -33,10 +33,10 @@ def game(mac):
logger.debug('pygame.init()')
pygame.init()
pygame.display.set_caption('tuxgo')
pygame.mouse.set_visible(False)
screen = pygame.display.set_mode(flags=pygame.FULLSCREEN)
bot = bluetooth.BluetoothBot(mac)
game = game_bt.Game(screen, bot)
game.run()
game_common.Game(screen, bot).run()
@cli.command('debug-image')

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

92
tuxgo/camera.py Normal file
View file

@ -0,0 +1,92 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# SPDX-FileCopyrightText: 2023 Wojtek Porczyk <woju@hackerspace.pl>
import numpy as np
import picamera2 # pylint: disable=import-error
from loguru import logger
def get_char_for_pixel(pixel):
s = sum(pixel)
if s > 614:
return '\u2588'
if s > 460:
return '\u2593'
if s > 307:
return '\u2592'
if s > 153:
return '\u2591'
return ' '
def print_frame_to_console(frame):
for row in frame:
print(''.join(get_char_for_pixel(cell) for cell in row))
print()
class Camera:
def __init__(self, size):
self._exposure = 1.0
size = tuple(map(int, size))
self.camera = picamera2.Picamera2()
self.camera.preview_configuration.update(
{
'main': {
'format': 'BGR888',
'size': size,
},
'lores': None,
'raw': None,
'display': None,
'encode': None,
'controls': {
'ExposureValue': self._exposure,
},
}
)
self.camera.configure('preview')
# ExposureValue vanishes from controls for some reason
@property
def exposure(self):
return self._exposure
@exposure.setter
def exposure(self, value):
self._exposure = max(-8, min(8, value))
self.camera.controls.ExposureValue = self._exposure
def __enter__(self):
self.camera.start()
def __exit__(self, exc_type, exc_value, exc_tb):
try:
self.camera.stop()
except SystemError: # raised on double stop
pass
def resize(self, size):
logger.debug(f'resize({size=})')
# this is in picamera2.py:
# if size[0] % 2 or size[1] % 2:
# raise RuntimeError("width and height should be even")
x, y = map(int, size)
x -= x & 1
y -= y & 1
self.camera.preview_configuration.update({'size': (x, y)})
self.camera.switch_mode('preview')
def capture_frame(self, rotate=0):
'''returns (array, metadata)
array has shape (height, width, 3)
'''
request = self.camera.capture_request()
frame = request.make_array('main')
metadata = picamera2.Metadata(request.get_metadata())
request.release()
return np.rot90(frame, rotate), metadata

View file

@ -1,499 +0,0 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# SPDX-FileCopyrightText: 2023 Wojtek Porczyk <woju@hackerspace.pl>
import datetime
import itertools
import sys
import funcparserlib.parser
import numpy as np
import picamera2
import pygame
import pygame.camera
import pygame.display
import pygame.event
import pygame.surfarray
import pygame.time
from . import (
assets,
badges,
camera,
detector as _detector,
game_base,
parser,
utils,
)
from .theme import Colour
PYGAME_EVENT_UPDATE_CPU_LOAD = pygame.event.custom_type()
class RobotComponent(game_base.Component):
def add_widgets(self):
if self.game.bot.connected:
self.game.widgets.label('CONNECTED',
bottom=2, left=0, bg=Colour.GREEN2)
self.game.widgets.key('WSAD', 'Move',
bottom=1, grid_left=0)
self.game.widgets.key('E', 'Sonar',
bottom=1, grid_left=1)
if self.game.bot.last_range is not None:
self.game.widgets.label(f'RANGE: {self.game.bot.last_range:3d} cm',
bottom=2, left=280)
elif self.game.bot.loop is not None:
self.game.widgets.label('CONNECTING',
bottom=2, left=0)
else:
self.game.widgets.label('DISCONNECTED',
bottom=2, left=0, bg=Colour.RED)
self.game.widgets.key('R', 'Reconnect',
bottom=1, grid_left=0)
def handle_event(self, event):
if event.type != pygame.KEYDOWN:
return False
if self.game.bot.connected:
if event.key == pygame.K_w:
self.game.bot.forward_nowait()
return True
if event.key == pygame.K_s:
self.game.bot.backward_nowait()
return True
if event.key == pygame.K_a:
self.game.bot.turn_left_nowait()
return True
if event.key == pygame.K_d:
self.game.bot.turn_right_nowait()
return True
if event.key == pygame.K_e:
self.game.bot.sonar_nowait()
return True
elif self.game.bot.loop is None:
if event.key == pygame.K_r:
self.game.bot.connect()
return True
class BadgesComponent(Component):
def __init__(self, *args, **kwds):
super().__init__(*args, **kwds)
self.enabled = not all(badge is None for badge in self.game.badges)
def add_widgets(self):
if self.enabled:
self.game.widgets.key('B', 'Badges', bottom=1, grid_left=3)
def handle_event(self, event):
if (self.enabled
and event.type == pygame.KEYDOWN
and event.key == pygame.K_b):
self.game.mode = self.game.mode_badges
return True
class ClockComponent(Component):
def add_widgets(self):
self.game.widgets.label(f'{datetime.datetime.now():%H:%M}',
top=0, right=0)
class CPUComponent(Component):
def __init__(self, *args, **kwds):
super().__init__(*args, **kwds)
self.cpuload = utils.CPULoad()
self.cpuload.update()
pygame.time.set_timer(PYGAME_EVENT_UPDATE_CPU_LOAD, 1000)
def add_widgets(self):
self.game.widgets.label(self.cpuload.format_all_short(), top=1, left=0)
def handle_event(self, event):
if event.type == PYGAME_EVENT_UPDATE_CPU_LOAD:
self.cpuload.update()
return True
class PWMComponent(Component):
def __init__(self, *args, **kwds):
super().__init__(*args, **kwds)
self.pwm = utils.PWM()
self.pwm.period = 1000000 # ns -> 1 kHz
self.pwm.duty_cycle = 0
self.pwm.enable = 1
def add_widgets(self):
self.game.widgets.key('[]', 'Lamp', bottom=0, grid_left=2)
def handle_event(self, event):
if event.type != pygame.KEYDOWN:
return
if event.key == pygame.K_LEFTBRACKET:
self.pwm.duty_cycle = max(0, self.pwm.duty_cycle - 100000)
return True
if event.key == pygame.K_RIGHTBRACKET:
self.pwm.duty_cycle = min(1000000, self.pwm.duty_cycle + 100000)
return True
class RFKillComponent(Component):
def add_widgets(self):
if not utils.get_rfkill_state('phy0'):
self.game.widgets.label('WIFI NOT RFKILL\'D', bottom=3, left=0, bg=Colour.RED)
class Game:
def __init__(self, screen, bot):
self.screen = screen
self.screen_width, self.screen_height = self.screen.get_size()
self.font_vcr_60 = pygame.font.Font(assets / 'fonts/VCROCDFaux.ttf', 60)
self.widgets = game_base.Widgets(self.screen,
grid_left=(80, 280, 450, 580),
grid_right=(),
)
self.clock = pygame.time.Clock()
self.mode = self.mode_normal
self.bot = bot
self.camera = camera.Camera(
(self.screen_width * 1.5, self.screen_height * 1.5))
self.detector = _detector.Detector()
self.frame = None
self.metadata = None
self.badges = list(badges.Badge.load_from_assets())
self.current_badge = None
self.comp_clock = ClockComponent(self)
self.comp_robot = RobotComponent(self)
self.comp_badges = BadgesComponent(self)
self.comp_cpu = CPUComponent(self)
self.comp_pwm = PWMComponent(self)
self.comp_rfkill = RFKillComponent(self)
def run(self):
with self.camera:
try:
while True:
self.mode()
pygame.display.flip()
self.clock.tick(50)
finally:
self.comp_pwm.pwm.enable = 0
def capture_frame(self):
self.frame, self.metadata = self.camera.capture_frame()
#
# COMPONENTS AND EVENT HANDLING
#
def handle_exit(self, event):
if (event.type == pygame.QUIT
or (event.type == pygame.KEYDOWN and event.key == pygame.K_q)):
if self.bot.loop is not None:
self.bot.disconnect()
sys.exit()
def handle_event(self, components, event):
self.handle_exit(event)
# CPULoad is a bit special, we need to always catch the timer event,
# no matter which mode we're in
if self.comp_cpu.handle_event(event):
return
for component in components:
if component.handle_event(event):
break
def add_widgets(self, components):
for component in components:
component.add_widgets()
#
# SCREEN MODES
#
def mode_normal(self):
components = (
self.comp_clock,
self.comp_robot,
self.comp_badges,
self.comp_cpu,
self.comp_pwm,
self.comp_rfkill,
)
self.capture_frame()
display_frame = self.frame.copy()
detections = self.detector.detect_markers(self.frame)
analyser = _detector.Analyser(detections)
for detection in detections:
detection.debug_draw(display_frame)
try:
_programme = list(analyser.get_programme(debug_frame=display_frame))
except _detector.BoardError:
error = True
else:
error = False
# surface = pygame.surfarray.make_surface(frame.T)
# XXX WTF this math, TODO get understanding
surface = pygame.surfarray.make_surface(np.flipud(np.rot90(display_frame)))
surface = pygame.transform.scale(surface, self.screen.get_size())
frame_width, frame_height = surface.get_size()
# logger.debug(f'{surface.get_size()=}')
self.screen.blit(surface, (
(self.screen_width - frame_width) / 2,
(self.screen_height - frame_height) / 2))
self.widgets.label(f'EXP: {self.metadata.ExposureTime:5} us (1/{1000000/self.metadata.ExposureTime:3.0f} s)',
top=2, left=0)
self.widgets.label(f'GAIN:'
f' A{self.metadata.AnalogueGain:.1f}'
f' D{self.metadata.DigitalGain:.1f}'
f' R{self.metadata.ColourGains[0]:.1f}'
f' B{self.metadata.ColourGains[1]:.1f}',
top=3, left=0)
self.widgets.label(f'BLUR: {self.detector.blur} px',
top=1, right=0)
self.widgets.label(f'FPS: {self.clock.get_fps():4.1f} Hz',
top=0, left=0)
# self.widgets.label(f'SHUTTER: ',
# top=2, left=0)
self.widgets.label(f'ILLUM: {self.metadata.Lux:4.0f} lux',
top=3, right=0)
self.widgets.label(f'PWM: {self.comp_pwm.pwm.percent:3.0f} %',
top=2, right=0)
self.widgets.label(f'TEMP: {self.metadata.SensorTemperature:2.0f} °C',
top=0, right=100)
if error:
self.widgets.label('ERROR', bottom=2, right=0, bg=Colour.RED)
else:
self.widgets.label('OK', bottom=2, right=0)
self.widgets.key('SPC', 'Snapshot', bottom=0, grid_left=0)
# self.widgets.key('C', 'Cam. sett.', bottom=1, left=600)
# self.widgets.key('I', 'Load file', bottom=0, left=600)
self.widgets.key('+-', 'Blur', bottom=1, grid_left=2)
self.widgets.key('Q', 'Quit', bottom=0, grid_left=3)
self.add_widgets(components)
# for i, item in enumerate(self.metadata.__dict__.items()):
# k, v = item
# self.widgets.label(f'{k}: {v}', left=0, top=i + 3)
for event in pygame.event.get():
self.handle_event(components, event)
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_SPACE:
self.mode = self.mode_snapshot
if event.key == pygame.K_c:
self.mode = self.mode_calibrate
if event.key == pygame.K_MINUS:
self.detector.blur = max(1, self.detector.blur - 1)
if event.key in (pygame.K_PLUS, pygame.K_EQUALS):
self.detector.blur += 1
def mode_calibrate(self):
components = (
self.comp_clock,
)
display_frame = self.frame.copy()
detections = camera.detect_calib_board(self.frame)
# XXX WTF this math, TODO get understanding
surface = pygame.surfarray.make_surface(np.flipud(np.rot90(display_frame)))
surface = pygame.transform.scale(surface, self.screen.get_size())
frame_width, frame_height = surface.get_size()
self.screen.blit(surface, (
(self.screen_width - frame_width) / 2,
(self.screen_height - frame_height) / 2))
self.add_widgets(components)
self.widgets.key('C', 'Calibrate', bottom=0, grid_left=0)
self.widgets.key('Q', 'Quit', bottom=0, grid_left=3)
for event in pygame.event.get():
self.handle_event(components, event)
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
self.mode = self.mode_normal
if event.key == pygame.K_c:
self.capture_frame()
def mode_snapshot(self):
components = (
self.comp_clock,
self.comp_robot,
self.comp_badges,
self.comp_pwm,
self.comp_rfkill,
)
display_frame = self.frame.copy()
detections = self.detector.detect_markers(self.frame)
analyser = _detector.Analyser(detections)
for detection in detections:
detection.debug_draw(display_frame)
try:
programme = list(analyser.get_programme(debug_frame=display_frame))
parsed = parser.parse(programme)
except (_detector.BoardError, funcparserlib.parser.NoParseError):
error = True
else:
error = False
# surface = pygame.surfarray.make_surface(frame.T)
# XXX WTF this math, TODO get understanding
surface = pygame.surfarray.make_surface(np.flipud(np.rot90(display_frame)))
surface = pygame.transform.scale(surface, self.screen.get_size())
frame_width, frame_height = surface.get_size()
# logger.debug(f'{surface.get_size()=}')
self.screen.blit(surface, (
(self.screen_width - frame_width) / 2,
(self.screen_height - frame_height) / 2))
self.add_widgets(components)
self.widgets.key('SPC', 'Snapshot', bottom=0, grid_left=0)
self.widgets.key('Q', 'Quit', bottom=0, grid_left=3)
if error:
self.widgets.label('ERROR', bottom=2, right=0, bg=Colour.RED)
else:
for i, line in enumerate(programme, 1):
line = ' '.join(token.value.text for token in line)
self.widgets.label(f'{i:2d} {line}', top=i, left=0)
self.widgets.label('OK', bottom=2, right=0)
if self.bot.connected:
if self.bot.current_task is not None:
self.widgets.key('X', 'Stop', bottom=0, grid_left=1)
elif not error:
self.widgets.key('R', 'Run', bottom=0, grid_left=1)
for event in pygame.event.get():
self.handle_event(components, event)
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
self.mode = self.mode_normal
if event.key == pygame.K_SPACE:
self.capture_frame()
if self.bot.connected:
if self.bot.current_task is not None:
if event.key == pygame.K_x:
self.bot.stop_programme()
elif not error:
if event.key == pygame.K_r:
self.bot.execute_programme(parsed)
def mode_badges(self):
self.screen.fill((0, 0, 0))
image_size = 256
assert self.screen_height == 1280
assert self.screen_width == 720
padding = (self.screen_width - 2 * image_size) / 3
for i, badge in enumerate(self.badges):
if badge is None:
continue
x = padding + (image_size + padding) * (i // 5)
y = image_size * (i % 5)
image = pygame.transform.scale(
badge.image_menu or badge.image, (image_size, image_size))
label = self.font_vcr_60.render(
str((i + 1) % 10), True, (0, 0, 0), (255, 255, 255))
self.screen.blit(image, (x, y))
self.screen.blit(label, (x - label.get_width() - 16, y))
for event in pygame.event.get():
self.handle_event((), event)
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
self.mode = self.mode_normal
continue
for i in range(min(len(self.badges), 10)):
if event.key == getattr(pygame, f'K_{(i + 1) % 10}'):
if self.badges[i] is not None:
self.mode = self.mode_badge
self.current_badge = self.badges[i]
def mode_badge(self):
assert self.current_badge
padding = 48
self.screen.fill((0, 0, 0))
qrcode_size = (self.screen_height
- 3 * padding - self.current_badge.image.get_height())
qrcode = pygame.transform.scale(
self.current_badge.qrcode, (qrcode_size, qrcode_size))
self.screen.blit(qrcode,
((self.screen_width - qrcode_size) / 2, padding))
self.screen.blit(self.current_badge.image, (
(self.screen_width - self.current_badge.image.get_width()) / 2,
(self.screen_height - self.current_badge.image.get_height() - padding)
))
for event in pygame.event.get():
self.handle_event((), event)
if event.type == pygame.KEYDOWN:
if event.key in (pygame.K_ESCAPE, pygame.K_b):
self.mode = self.mode_badges
self.current_badge = None
continue
for i in range(min(len(self.badges), 10)):
if event.key == getattr(pygame, f'K_{(i + 1) % 10}'):
if self.badges[i] is not None:
self.current_badge = self.badges[i]
def mode_camera_settings(self):
raise NotImplementedError()
def mode_load_file(self):
raise NotImplementedError()
# vim: tw=80 ts=4 sts=4 sw=4 et

956
tuxgo/game_common.py Normal file
View file

@ -0,0 +1,956 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# SPDX-FileCopyrightText: 2023-2024 Wojtek Porczyk <woju@hackerspace.pl>
import datetime
import itertools
import sys
import traceback
import cv2
import funcparserlib.parser
import pygame
from . import (
assets,
badges,
camera,
detector as _detector,
parser,
utils,
)
from .theme import Colour
PYGAME_EVENT_UPDATE_CPU_LOAD = pygame.event.custom_type()
class Layout:
layout_width = 6
layout_height = 2
def __init__(self, screen):
self.screen = screen
self.screen_width, self.screen_height = self.screen.get_size()
assert self.screen_width >= 720
self.button_size = self.screen_width // self.layout_width
self.font = pygame.font.Font(
assets / 'fonts/SpaceGrotesk-Regular.otf', 36
)
self.font_mono = pygame.font.Font(
assets / 'fonts/Iosevka-Regular.ttf', 24
)
self.font_key = pygame.font.Font(
assets / 'fonts/Pilowlava-Regular.otf', 56
)
def place_button(
self, *, left=None, right=None, top=None, bottom=None, width=1, height=1
):
assert (left is not None) ^ (right is not None)
assert (top is not None) ^ (bottom is not None)
if left is not None:
x = left * self.button_size
if right is not None:
x = self.screen_width - (left + width) * self.button_size
if top is not None:
y = top * self.button_size
if bottom is not None:
y = self.screen_height - (bottom + height) * self.button_size
return {
'left': x,
'top': y,
'width': width * self.button_size,
'height': height * self.button_size,
}
class Control:
def __init__(self, game):
self.game = game
def handle_event(self, event):
pass
def get_blits(self):
raise NotImplementedError()
class Status(Control):
def get_blits(self):
surface = self.game.layout.font_mono.render(
f' FPS: {self.game.clock.get_fps():4.1f} Hz '
f' {datetime.datetime.now():%H:%M} ',
True,
Colour.ACCENT3,
Colour.BACKGROUND,
)
yield surface, (self.game.layout.screen_width - surface.get_width(), 0)
class Sonar(Control):
def get_blits(self):
if self.game.bot.last_range is None:
return
surface = self.game.layout.font_mono.render(
(
' \N{INFINITY} '
if self.game.bot.last_range == float('inf')
else f' {self.game.bot.last_range:3} cm '
),
True,
Colour.ACCENT2,
Colour.BACKGROUND,
)
yield surface, (
int(self.game.layout.button_size * 3.5 - surface.get_width() / 2),
self.game.layout.screen_height
- self.game.layout.button_size * 2
- surface.get_height()
- 5,
)
class Debug(Control):
def label(
self,
text,
*,
left,
top,
color=Colour.ACCENT3,
background=Colour.BACKGROUND,
):
surface = self.game.layout.font_mono.render(
text, True, color, background
)
return surface, (left, top * surface.get_height())
def get_blits(self):
yield self.label(
f'EXP:'
f' {self.game.metadata.ExposureTime:5} us'
f' (1/{1000000/self.game.metadata.ExposureTime:3.0f} s)',
top=2,
left=0,
)
yield self.label(
f'GAIN:'
f' A{self.game.metadata.AnalogueGain:.1f}'
f' D{self.game.metadata.DigitalGain:.1f}'
f' R{self.game.metadata.ColourGains[0]:.1f}'
f' B{self.game.metadata.ColourGains[1]:.1f}',
top=3,
left=0,
)
yield self.label(
f'ILLUM: {self.game.metadata.Lux:4.0f} lux', top=3, left=500
)
yield self.label(
f'TEMP: {self.game.metadata.SensorTemperature:2.0f} °C',
top=0,
left=100,
)
height, width, _ = self.game.frame.shape
yield self.label(f'W: {width} × H: {height}', top=1, left=0)
class RectControl(Control):
def __init__(self, *args, left, top, width, height, **kwds):
super().__init__(*args, **kwds)
self.rect = pygame.Rect(left, top, width, height)
self.uv = self.rect.move(-self.rect.x, -self.rect.y)
def get_blits(self):
surface = pygame.Surface(self.rect.size, pygame.SRCALPHA)
self.draw(surface)
yield surface, self.rect.topleft
def draw(self, surface):
raise NotImplementedError()
class BaseButton(RectControl):
key = None
def __init__(self, *args, key=None, on_press=None, **kwds):
super().__init__(*args, **kwds)
if key is not None:
self.key = key
if on_press is not None:
self.on_press = on_press
def on_press(self, pos): # pylint: disable=method-hidden
raise NotImplementedError()
def handle_event(self, event):
if (
event.type == pygame.MOUSEBUTTONDOWN
and event.button == 1
and self.rect.collidepoint(event.pos)
) or (event.type == pygame.KEYDOWN and event.key == self.key):
return self.on_press(event.pos)
return None
class Button(BaseButton):
# pylint: disable=abstract-method
label = None
def __init__(self, *args, on_press=None, label=None, **kwds):
super().__init__(*args, **kwds)
if label is not None:
self.label = label
def get_blits(self):
# for the interest of borders, we need to draw a bit bigger rectangle
rect = pygame.Rect(
self.rect.x - 1,
self.rect.y - 1,
self.rect.width + 2,
self.rect.height + 2,
)
if self.rect.left == 0:
rect.x = 0
rect.width -= 1
if self.rect.right == self.game.screen_width:
rect.width -= 1
if self.rect.bottom == self.game.screen_height:
rect.height -= 1
surface = pygame.Surface(rect.size)
self.draw(surface)
yield surface, rect.topleft
def draw_label(self, surface):
fontsurf = self.game.layout.font.render(
self.label, True, Colour.PRIMARY
)
fontrect = fontsurf.get_rect()
surface.blit(
fontsurf,
(
(self.rect.width - fontrect.width) // 2,
(self.rect.height - fontrect.height) // 2,
),
)
def draw_key(self, surface):
fontsurf = self.game.layout.font_key.render(
self.key, True, Colour.ACCENT5, Colour.BACKGROUND
)
fontrect = fontsurf.get_rect()
surface.blit(
fontsurf,
(
(self.rect.width - fontrect.width) // 2,
(self.rect.height - fontrect.height) // 2,
),
)
def draw(self, surface):
surface.fill(Colour.BACKGROUND)
pygame.draw.rect(surface, Colour.ACCENT3, surface.get_rect(), width=2)
if self.key is not None:
self.draw_key(surface)
self.draw_label(surface)
class UpButton(BaseButton):
# pylint: disable=abstract-method
def draw(self, surface):
surface.fill(Colour.BACKGROUND)
pygame.draw.lines(
surface,
Colour.ACCENT3,
False,
(
(self.rect.width * 0.2, self.rect.height - 1),
(0, self.rect.height - 1),
(0, 0),
(self.rect.width - 2, 0),
(self.rect.width - 2, self.rect.height - 1),
(self.rect.width * 0.8, self.rect.height - 1),
),
width=2,
)
fontsurf = self.game.layout.font.render(
'+', True, Colour.ACCENT1, Colour.BACKGROUND
)
fontrect = fontsurf.get_rect()
surface.blit(fontsurf, ((self.rect.width - fontrect.width) // 2, 2))
class DownButton(BaseButton):
# pylint: disable=abstract-method
def draw(self, surface):
surface.fill(Colour.BACKGROUND)
pygame.draw.lines(
surface,
Colour.ACCENT3,
False,
(
(self.rect.width * 0.2, 0),
(0, 0),
(0, self.rect.height - 2),
(self.rect.width - 2, self.rect.height - 2),
(self.rect.width - 2, 0),
(self.rect.width * 0.8, 0),
),
width=2,
)
fontsurf = self.game.layout.font.render(
'-', True, Colour.ACCENT1, Colour.BACKGROUND
)
fontrect = fontsurf.get_rect()
surface.blit(
fontsurf,
(
(self.rect.width - fontrect.width) // 2,
self.rect.height - fontrect.height - 2,
),
)
class Rocker(RectControl):
label = None
def __init__(self, *args, key_up=None, key_down=None, **kwds):
super().__init__(*args, **kwds)
self.up = UpButton(
self.game,
left=self.rect.left,
top=self.rect.top,
width=self.rect.width,
height=self.rect.centery - self.rect.top,
key=key_up,
on_press=self.on_press_up,
)
self.down = DownButton(
self.game,
left=self.rect.left,
top=self.rect.centery,
width=self.rect.width,
height=self.rect.height - self.up.rect.height,
key=key_down,
on_press=self.on_press_down,
)
def draw(self, surface):
fontsurf = self.game.layout.font.render(
self.label, True, Colour.PRIMARY, Colour.BACKGROUND
)
fontrect = fontsurf.get_rect()
surface.blit(
fontsurf,
(
self.uv.centerx - fontrect.width // 2,
self.uv.centery - fontrect.height - 2,
),
)
fontsurf = self.game.layout.font_mono.render(
self.get_value(), True, Colour.ACCENT2, Colour.BACKGROUND
)
fontrect = fontsurf.get_rect()
surface.blit(
fontsurf,
(self.uv.centerx - fontrect.width // 2, self.uv.centery + 2),
)
def get_value(self):
raise NotImplementedError()
def get_blits(self):
yield from self.up.get_blits()
yield from self.down.get_blits()
yield from super().get_blits()
def handle_event(self, event):
return self.up.handle_event(event) or self.down.handle_event(event)
def on_press_up(self, pos):
raise NotImplementedError()
def on_press_down(self, pos):
raise NotImplementedError()
class PWMRocker(Rocker):
label = 'PWM'
def get_value(self):
return f'{self.game.pwm.percent:3.0f} %'
def on_press_up(self, _pos):
self.game.pwm.duty_cycle = min(
1000000, self.game.pwm.duty_cycle + 100000
)
return True
def on_press_down(self, _pos):
self.game.pwm.duty_cycle = max(0, self.game.pwm.duty_cycle - 100000)
return True
class BlurRocker(Rocker):
label = 'BLUR'
def get_value(self):
return f'{self.game.detector.blur} px'
def on_press_up(self, _pos):
self.game.detector.blur += 1
return True
def on_press_down(self, _pos):
self.game.detector.blur = max(1, self.game.detector.blur - 1)
return True
class ExposureRocker(Rocker):
label = 'EXPO'
def get_value(self):
return (
'±0.0 EV'
if abs(self.game.camera.exposure) < 1e-5
else (
f'{self.game.camera.exposure:+3.1f}'
' E\N{LATIN SUBSCRIPT SMALL LETTER V}'
)
)
def on_press_up(self, _pos):
self.game.camera.exposure += 1 / 3
return True
def on_press_down(self, _pos):
self.game.camera.exposure -= 1 / 3
return True
class ResolutionRocker(Rocker):
label = 'RES'
def get_value(self):
return f'× {self.game.frame.shape[0] / self.game.screen_height:3.1f}'
def on_press_up(self, _pos):
self.game.resolution_index += 1
self.game.camera.resize(
(
self.game.screen_width * renard(self.game.resolution_index),
self.game.screen_height * renard(self.game.resolution_index),
)
)
def on_press_down(self, _pos):
self.game.resolution_index -= 1
self.game.camera.resize(
(
self.game.screen_width * renard(self.game.resolution_index),
self.game.screen_height * renard(self.game.resolution_index),
)
)
# def on_press_up(self, _pos):
# ratio = self.game.frame.shape[0] / self.game.screen_height
# ratio += 0.25
# self.game.camera.resize(
# (self.game.screen_width * ratio, self.game.screen_height * ratio))
# return True
# def on_press_down(self, _pos):
# ratio = self.game.frame.shape[0] / self.game.screen_height
# ratio -= 0.25
# self.game.camera.resize(
# (self.game.screen_width * ratio, self.game.screen_height * ratio))
# return True
class ButtonBack(Button):
label = 'BACK'
def on_press(self, _pos):
self.game.mode = self.game.mode_normal
return True
class ButtonCallibrate(Button):
label = 'ADJ'
def on_press(self, _pos):
self.game.mode = self.game.mode_callibrate
return True
class ButtonQuit(Button):
label = 'QUIT'
key = 'Q'
def on_press(self, _pos):
sys.exit()
class ButtonConnect(Button):
key = 'R'
def draw_label(self, surface):
if self.game.bot.connected:
self.label = 'CONNECTED'
elif self.game.bot.loop is not None:
self.label = 'CONNECTING'
else:
self.label = 'CONNECT'
return super().draw_label(surface)
def on_press(self, _pos):
if self.game.bot.loop is None:
self.game.bot.connect()
return True
class ButtonSonar(Button):
label = 'SONAR'
key = 'E'
def on_press(self, _pos):
if self.game.bot.connected:
self.game.bot.sonar_nowait()
return True
class ButtonForward(Button):
label = ''
key = 'W'
def on_press(self, _pos):
if self.game.bot.connected:
self.game.bot.forward_nowait()
return True
class ButtonBackward(Button):
label = ''
key = 'S'
def on_press(self, _pos):
if self.game.bot.connected:
self.game.bot.backward_nowait()
return True
class ButtonTurnLeft(Button):
label = ''
key = 'A'
def on_press(self, _pos):
if self.game.bot.connected:
self.game.bot.turn_left_nowait()
return True
class ButtonTurnRight(Button):
label = ''
key = 'D'
def on_press(self, _pos):
if self.game.bot.connected:
self.game.bot.turn_right_nowait()
return True
class ButtonCapture(Button):
label = 'CAPTURE'
key = 'SPACE'
def on_press(self, _pos):
if self.game.mode == self.game.mode_snapshot:
self.game.capture_frame()
else:
self.game.mode = self.game.mode_snapshot
return True
class ButtonRun(Button):
label = 'RUN'
key = 'R'
def on_press(self, _pos):
self.game.bot.execute_programme(self.game.current_ast)
return True
class ButtonStop(Button):
label = 'STOP'
key = 'X'
def on_press(self, _pos):
self.game.bot.stop_programme()
return True
class ButtonBadges(Button):
label = 'B'
key = 'B'
def on_press(self, _pos):
# TODO check how many badges are there, and then B switches to either
# mode_badges or just mode_badge with first one picked
self.game.current_badge = self.game.badges[0]
self.game.mode = self.game.mode_badge
# fmt: off
R20 = (
1.00, 1.12, 1.25, 1.40,
1.60, 1.80, 2.00, 2.24,
2.50, 2.80, 3.15, 3.55,
4.00, 4.50, 5.00, 5.60,
6.30, 7.10, 8.00, 9.00,
)
R10 = R20[::2]
R5 = R10[::2]
# fmt: on
def renard(index, series=R10):
magnitude, number = divmod(index, len(series))
return series[number] * 10**magnitude
class Game:
def __init__(self, screen, bot):
self.screen = screen
self.screen_width, self.screen_height = self.screen.get_size()
self.layout = Layout(self.screen)
self.clock = pygame.time.Clock()
self.mode = self.mode_normal
self.bot = bot
self.pwm = utils.PWM()
self.pwm.period = 1000000 # ns -> 1 kHz
self.pwm.duty_cycle = 0
self.pwm.enable = 1
self.cpuload = utils.CPULoad()
self.cpuload.update()
pygame.time.set_timer(PYGAME_EVENT_UPDATE_CPU_LOAD, 1000)
self.resolution_index = 2
self.camera = camera.Camera(
(
self.screen_width * renard(self.resolution_index),
self.screen_height * renard(self.resolution_index),
)
)
self.detector = _detector.Detector()
self.frame = None
self.metadata = None
self.current_programme = None
self.current_ast = None
self.badges = list(badges.Badge.load_from_assets())
self.current_badge = None
self.error = False
self.ctrl_pwm = PWMRocker(
self, **self.layout.place_button(left=1, bottom=0, height=2)
)
self.ctrl_blur = BlurRocker(
self, **self.layout.place_button(left=2, bottom=0, height=2)
)
self.ctrl_expo = ExposureRocker(
self, **self.layout.place_button(left=3, bottom=0, height=2)
)
self.ctrl_res = ResolutionRocker(
self, **self.layout.place_button(left=4, bottom=0, height=2)
)
self.ctrl_button_back = ButtonBack(
self, **self.layout.place_button(left=0, bottom=0)
)
self.ctrl_button_callibrate = ButtonCallibrate(
self, **self.layout.place_button(left=0, bottom=1)
)
self.ctrl_button_quit = ButtonQuit(
self, **self.layout.place_button(left=0, bottom=0)
)
self.ctrl_button_capture = ButtonCapture(
self, **self.layout.place_button(left=4, bottom=0, width=2)
)
self.ctrl_button_badges = ButtonBadges(
self, **self.layout.place_button(left=1, bottom=1)
)
self.ctrl_button_connect = ButtonConnect(
self, **self.layout.place_button(left=1, bottom=0, width=3)
)
self.ctrl_button_turn_left = ButtonTurnLeft(
self, **self.layout.place_button(left=1, bottom=0)
)
self.ctrl_button_forward = ButtonForward(
self, **self.layout.place_button(left=2, bottom=1)
)
self.ctrl_button_backward = ButtonBackward(
self, **self.layout.place_button(left=2, bottom=0)
)
self.ctrl_button_turn_right = ButtonTurnRight(
self, **self.layout.place_button(left=3, bottom=0)
)
self.ctrl_button_sonar = ButtonSonar(
self, **self.layout.place_button(left=3, bottom=1)
)
self.ctrl_button_run = ButtonRun(
self, **self.layout.place_button(left=4, bottom=1, width=2)
)
self.ctrl_button_stop = ButtonStop(
self,
**self.layout.place_button(left=0, bottom=0, width=6, height=2),
)
self.ctrl_status = Status(self)
self.ctrl_debug = Debug(self)
self.ctrl_sonar = Sonar(self)
def run(self):
with self.camera:
try:
while True:
self.mode()
pygame.display.flip()
self.clock.tick(50)
except Exception as exc:
# TODO make this configurable
if self.frame is not None:
now = f'{datetime.datetime.now():%Y%m%d-%H%M%S}'
cv2.imwrite(f'badframe-{now}.png', self.frame)
with open(
f'badframe-{now}.txt', 'w', encoding='utf-8'
) as file:
traceback.print_exception(exc, file=file)
raise
finally:
if self.bot.loop is not None:
self.bot.disconnect()
self.pwm.enable = 0
def capture_frame(self):
# NOTE about frames, arrays and shapes:
# picamera's make_array returns array of shape (height, width, 3)
# array[0][0] is top left, but the camera is mounted "upside down" in
# the box I'm using, therefore rotate 180°
self.frame, self.metadata = self.camera.capture_frame(rotate=2)
def handle_event(self, ui, event):
if event.type == pygame.QUIT or (
event.type == pygame.KEYDOWN and event.key == pygame.K_q
):
sys.exit()
if event.type == PYGAME_EVENT_UPDATE_CPU_LOAD:
self.cpuload.update()
return True
return any(ctrl.handle_event(event) for ctrl in ui)
def draw_ui(self, ui):
self.screen.blits(
list(itertools.chain.from_iterable(ctrl.get_blits() for ctrl in ui))
)
def compile_display(self):
display_frame = self.frame.copy()
detections = self.detector.detect_markers(self.frame)
analyser = _detector.Analyser(detections)
for detection in detections:
detection.debug_draw(display_frame)
try:
self.current_programme = list(
analyser.get_programme(debug_frame=display_frame)
)
self.current_ast = parser.parse(self.current_programme)
except (_detector.BoardError, funcparserlib.parser.NoParseError):
self.current_programme = None
self.current_ast = None
self.error = True
else:
self.error = False
# pygame's surface needs shape (width, height, 3), hence transpose
surface = pygame.pixelcopy.make_surface(
display_frame.transpose(1, 0, 2)
)
surface = pygame.transform.scale(surface, self.screen.get_size())
frame_width, frame_height = surface.get_size()
self.screen.blit(
surface,
(
(self.screen_width - frame_width) / 2,
(self.screen_height - frame_height) / 2,
),
)
def mode_normal(self):
ui = (
self.ctrl_status,
self.ctrl_button_callibrate,
self.ctrl_button_quit,
self.ctrl_button_badges,
self.ctrl_button_capture,
) + (
(
self.ctrl_button_forward,
self.ctrl_button_backward,
self.ctrl_button_turn_left,
self.ctrl_button_turn_right,
self.ctrl_button_sonar,
self.ctrl_sonar,
)
if self.bot.connected
else (self.ctrl_button_connect,)
)
self.capture_frame()
self.compile_display()
self.draw_ui(ui)
for event in pygame.event.get():
self.handle_event(ui, event)
def mode_snapshot(self):
ui = [
self.ctrl_status,
]
if self.bot.connected:
ui.append(self.ctrl_sonar)
if self.bot.current_task is not None:
ui.append(self.ctrl_button_stop)
else:
ui.extend(
(
self.ctrl_button_forward,
self.ctrl_button_backward,
self.ctrl_button_turn_left,
self.ctrl_button_turn_right,
self.ctrl_button_sonar,
self.ctrl_button_capture,
self.ctrl_button_badges,
self.ctrl_button_back,
)
)
if self.current_programme:
ui.append(self.ctrl_button_run)
else:
ui.extend(
(
self.ctrl_button_connect,
self.ctrl_button_capture,
self.ctrl_button_badges,
self.ctrl_button_back,
)
)
self.compile_display()
if self.current_programme is not None:
for i, line in enumerate(self.current_programme, 1):
line = ' '.join(token.value.text for token in line)
surface = self.layout.font_mono.render(
f'{i:2d} {line}', True, Colour.HIGHLIGHT, Colour.BACKGROUND
)
self.screen.blit(surface, (10, surface.get_height() * i))
self.draw_ui(ui)
for event in pygame.event.get():
self.handle_event(ui, event)
def mode_callibrate(self):
ui = (
self.ctrl_status,
self.ctrl_debug,
self.ctrl_button_back,
self.ctrl_pwm,
self.ctrl_blur,
self.ctrl_expo,
self.ctrl_res,
)
self.capture_frame()
self.compile_display()
self.draw_ui(ui)
for event in pygame.event.get():
self.handle_event(ui, event)
def mode_badge(self):
ui = (
self.ctrl_button_back,
) # fmt: skip
assert self.current_badge is not None
padding = 48
self.screen.fill((0, 0, 0))
qrcode_size = (
self.screen_height
- 3 * padding
- self.current_badge.image.get_height()
)
qrcode = pygame.transform.scale(
self.current_badge.qrcode, (qrcode_size, qrcode_size)
)
self.screen.blit(
qrcode, ((self.screen_width - qrcode_size) / 2, padding)
)
self.screen.blit(
self.current_badge.image,
(
(self.screen_width - self.current_badge.image.get_width()) / 2,
(
self.screen_height
- self.current_badge.image.get_height()
- padding
),
),
)
self.draw_ui(ui)
for event in pygame.event.get():
self.handle_event(ui, event)
# vim: tw=80 ts=4 sts=4 sw=4 et

View file

@ -4,25 +4,53 @@
import enum
# 37c3 colours
class Colour(tuple, enum.Enum):
class Colour38c3(tuple, enum.Enum):
# fmt: off
BLACK = ( 0, 0, 0) #000000
WHITE = (255, 255, 255) #ffffff
GREY1 = (217, 217, 217) #d9d9d9
GREY2 = (170, 170, 170) #aaaaaa
GREY3 = (122, 122, 122) #7a7a7a
GREY4 = ( 32, 32, 32) #202020
BLUE = ( 45, 66, 255) #2d42ff
BLUE2 = ( 11, 21, 117) #0b1575
RED = (222, 64, 64) #de4040
RED2 = ( 86, 16, 16) #561010
GREEN = (121, 255, 94) #79ff5e
GREEN2 = ( 43, 141, 24) #2b8d18
CYAN = ( 41, 255, 255) #29ffff
CYAN2 = ( 0, 107, 107) #006b6b
MAGENTA = (222, 55, 255) #de37ff
MAGENTA2 = (102, 0, 122) #66007a
YELLOW = (246, 246, 117) #f6f675
YELLOW2 = (117, 177, 1) #757501
CORAL_RED = (255, 80, 83) #FF5053
PEARL_WHITE = (254, 242, 255) #FEF2FF
AMETHYST = (106, 95, 219) #6A5FDB
PERIWINKLE = (178, 170, 255) #B2AAFF
DARK_PURPLE = ( 38, 26, 102) #261A66
AUBERGINE = ( 41, 17, 76) #29114C
SUPER_DARK_PINK = ( 15, 0, 10) #0F000A
DARK_AUBERGINE = ( 31, 11, 47) #190B2F
PRIMARY = CORAL_RED
HIGHLIGHT = PEARL_WHITE
ACCENT1 = AMETHYST
ACCENT2 = PERIWINKLE
ACCENT3 = DARK_PURPLE
ACCENT4 = AUBERGINE
ACCENT5 = DARK_AUBERGINE
BACKGROUND = SUPER_DARK_PINK
BLACK = ( 0, 0, 0) #000000
WHITE = (255, 255, 255) #ffffff
# fmt: on
# 37c3 colours
class Colour37c3(tuple, enum.Enum):
# fmt: off
BLACK = ( 0, 0, 0) #000000
WHITE = (255, 255, 255) #ffffff
GREY1 = (217, 217, 217) #d9d9d9
GREY2 = (170, 170, 170) #aaaaaa
GREY3 = (122, 122, 122) #7a7a7a
GREY4 = ( 32, 32, 32) #202020
BLUE = ( 45, 66, 255) #2d42ff
BLUE2 = ( 11, 21, 117) #0b1575
RED = (222, 64, 64) #de4040
RED2 = ( 86, 16, 16) #561010
GREEN = (121, 255, 94) #79ff5e
GREEN2 = ( 43, 141, 24) #2b8d18
CYAN = ( 41, 255, 255) #29ffff
CYAN2 = ( 0, 107, 107) #006b6b
MAGENTA = (222, 55, 255) #de37ff
MAGENTA2 = (102, 0, 122) #66007a
YELLOW = (246, 246, 117) #f6f675
YELLOW2 = (117, 177, 1) #757501
# fmt: on
Colour = Colour38c3