dropbear-locomotion / scripts /dropbear_live_window.py
cudabenchmarktest's picture
Release v0.2.1 live-viewer fix and directional curriculum
19881ee verified
Raw
History Blame Contribute Delete
22.9 kB
#!/usr/bin/env python3
"""Interactive desktop window for the Dropbear raw-RGB live stream.
Frames arrive on stdin. Camera drags and robot-velocity joystick motion are
emitted as one-line commands on stdout for the simulator process.
"""
from __future__ import annotations
import argparse
import queue
import sys
import threading
import time
import tkinter as tk
import cv2
import numpy as np
from PIL import Image, ImageTk
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--width", type=int, required=True)
parser.add_argument("--height", type=int, required=True)
parser.add_argument("--display-width", type=int, default=1200)
parser.add_argument("--joystick-max-speed", type=float, default=1.0)
parser.add_argument("--joystick-max-yaw-rate", type=float, default=1.0)
parser.add_argument("--source-fps", type=float, default=5.0)
parser.add_argument("--display-fps", type=float, default=50.0)
parser.add_argument("--training-live-enabled", action="store_true")
parser.add_argument("--title", required=True)
args = parser.parse_args()
if not 0.25 <= args.source_fps <= 60.0:
parser.error("--source-fps must be between 0.25 and 60")
if not 10.0 <= args.display_fps <= 60.0:
parser.error("--display-fps must be between 10 and 60")
display_height = round(args.height * args.display_width / args.width)
expected_bytes = args.width * args.height * 3
frames: queue.Queue[bytes] = queue.Queue(maxsize=2)
stopped = threading.Event()
root = tk.Tk()
root.title(args.title)
root.geometry(f"{args.display_width}x{display_height}")
root.minsize(640, 360)
root.resizable(True, True)
canvas = tk.Canvas(
root,
width=args.display_width,
height=display_height,
borderwidth=0,
highlightthickness=0,
background="#050910",
)
canvas.pack(fill=tk.BOTH, expand=True)
image_item = canvas.create_image(0, 0, anchor=tk.NW)
pad_radius = 46
knob_radius = 8
pad_center = {"x": 64, "y": display_height - 62}
panel_item = canvas.create_rectangle(
0,
0,
1,
1,
fill="#050910",
outline="#26384f",
width=1,
)
label_item = canvas.create_text(
0,
0,
anchor=tk.W,
text=(
f"PLANAR ±{args.joystick_max_speed:.2f} m/s "
f"YAW ±{args.joystick_max_yaw_rate:.2f}"
),
fill="#dce6f5",
font=("Sans", 8),
)
value_item = canvas.create_text(
0,
0,
anchor=tk.W,
text="vx +0.00 vy +0.00 wz +0.00",
fill="#9fe8ff",
font=("Sans", 7),
)
pad_item = canvas.create_oval(
0,
0,
1,
1,
fill="#17273a",
outline="#bed2eb",
width=1,
)
horizontal_axis = canvas.create_line(0, 0, 1, 1, fill="#52708f", width=1)
vertical_axis = canvas.create_line(0, 0, 1, 1, fill="#52708f", width=1)
direction_items = {
direction: canvas.create_text(
0,
0,
text=label,
fill="#dcecff",
font=("Sans", 7, "bold"),
)
for direction, label in (
("forward", "F"),
("left", "L"),
("right", "R"),
("back", "B"),
)
}
knob_item = canvas.create_oval(
0,
0,
1,
1,
fill="#65d9ff",
outline="#effaff",
width=1,
)
mode_bounds = {"left": 126, "top": 0, "right": 254, "bottom": 0}
mode_state = {"training": False}
mode_button_item = canvas.create_rectangle(
0,
0,
1,
1,
fill="#162b42",
outline="#8fcff3",
width=1,
)
mode_button_text = canvas.create_text(
0,
0,
text="MANUAL CONTROL\nclick: TRAIN LIVE",
fill="#e7f6ff",
font=("Sans", 8, "bold"),
justify=tk.CENTER,
)
def refresh_mode_controls() -> None:
if not args.training_live_enabled:
canvas.itemconfigure(
mode_button_item,
fill="#20252d",
outline="#66707c",
)
canvas.itemconfigure(
mode_button_text,
text="TRAIN LIVE\nbridge offline",
fill="#8f99a5",
)
elif mode_state["training"]:
canvas.itemconfigure(
mode_button_item,
fill="#124b38",
outline="#77f0b5",
)
canvas.itemconfigure(
mode_button_text,
text="TRAIN LIVE • DR\nclick: MANUAL",
fill="#dffff0",
)
else:
canvas.itemconfigure(
mode_button_item,
fill="#162b42",
outline="#8fcff3",
)
canvas.itemconfigure(
mode_button_text,
text="MANUAL CONTROL\nclick: TRAIN LIVE",
fill="#e7f6ff",
)
joystick_enabled = not mode_state["training"]
canvas.itemconfigure(
pad_item,
fill="#17273a" if joystick_enabled else "#141a20",
outline="#bed2eb" if joystick_enabled else "#58626d",
)
canvas.itemconfigure(
knob_item,
fill="#65d9ff" if joystick_enabled else "#56616c",
outline="#effaff" if joystick_enabled else "#737d87",
)
for item in direction_items.values():
canvas.itemconfigure(
item,
fill="#dcecff" if joystick_enabled else "#64707c",
)
def layout_controls(canvas_height: int) -> None:
center_x = 64
center_y = max(76, canvas_height - 62)
pad_center["x"] = center_x
pad_center["y"] = center_y
panel_top = center_y - pad_radius - 37
canvas.coords(
panel_item,
8,
panel_top,
265,
center_y + pad_radius + 8,
)
canvas.coords(label_item, 14, panel_top + 11)
canvas.coords(value_item, 14, panel_top + 25)
canvas.coords(
pad_item,
center_x - pad_radius,
center_y - pad_radius,
center_x + pad_radius,
center_y + pad_radius,
)
canvas.coords(
horizontal_axis,
center_x - pad_radius + 8,
center_y,
center_x + pad_radius - 8,
center_y,
)
canvas.coords(
vertical_axis,
center_x,
center_y - pad_radius + 8,
center_x,
center_y + pad_radius - 8,
)
canvas.coords(direction_items["forward"], center_x, center_y - 34)
canvas.coords(direction_items["left"], center_x - 34, center_y)
canvas.coords(direction_items["right"], center_x + 34, center_y)
canvas.coords(direction_items["back"], center_x, center_y + 34)
canvas.coords(
knob_item,
center_x - knob_radius,
center_y - knob_radius,
center_x + knob_radius,
center_y + knob_radius,
)
mode_bounds["top"] = center_y - 32
mode_bounds["bottom"] = center_y + 32
canvas.coords(
mode_button_item,
mode_bounds["left"],
mode_bounds["top"],
mode_bounds["right"],
mode_bounds["bottom"],
)
canvas.coords(
mode_button_text,
(mode_bounds["left"] + mode_bounds["right"]) / 2,
center_y,
)
layout_controls(display_height)
refresh_mode_controls()
drag = {
"mode": None,
"last_x": 0,
"last_y": 0,
}
def in_joystick(x: int, y: int) -> bool:
if mode_state["training"]:
return False
return (
(x - pad_center["x"]) ** 2 + (y - pad_center["y"]) ** 2
<= (pad_radius + 6) ** 2
)
def in_mode_button(x: int, y: int) -> bool:
return (
mode_bounds["left"] <= x <= mode_bounds["right"]
and mode_bounds["top"] <= y <= mode_bounds["bottom"]
)
def send_command(command: str) -> None:
sys.stdout.write(f"{command}\n")
sys.stdout.flush()
def update_joystick(x: int, y: int) -> None:
delta_x = float(x - pad_center["x"])
delta_y = float(y - pad_center["y"])
magnitude = (delta_x**2 + delta_y**2) ** 0.5
if magnitude > pad_radius:
scale = pad_radius / magnitude
delta_x *= scale
delta_y *= scale
forward = -delta_y / pad_radius
left = -delta_x / pad_radius
radius_fraction = min(1.0, magnitude / pad_radius)
side_quadrant = abs(delta_x) > abs(delta_y)
rim_turn = max(0.0, min(1.0, (radius_fraction - 0.65) / 0.35))
yaw_left = (
max(-1.0, min(1.0, left)) * rim_turn
if side_quadrant
else 0.0
)
canvas.coords(
knob_item,
pad_center["x"] + delta_x - knob_radius,
pad_center["y"] + delta_y - knob_radius,
pad_center["x"] + delta_x + knob_radius,
pad_center["y"] + delta_y + knob_radius,
)
canvas.itemconfigure(
value_item,
text=(
f"vx {forward * args.joystick_max_speed:+.2f} "
f"vy {left * args.joystick_max_speed:+.2f} "
f"wz {yaw_left * args.joystick_max_yaw_rate:+.2f}"
),
)
send_command(
f"robot_velocity {forward:.4f} {left:.4f} {yaw_left:.4f}"
)
def on_motion(event: tk.Event) -> None:
if drag["mode"] == "joystick":
update_joystick(int(event.x), int(event.y))
canvas.configure(cursor="hand2")
return
if drag["mode"] == "camera":
delta_x = int(event.x) - int(drag["last_x"])
delta_y = int(event.y) - int(drag["last_y"])
drag["last_x"] = int(event.x)
drag["last_y"] = int(event.y)
if delta_x != 0 or delta_y != 0:
# Horizontal orbit follows the user's preferred drag direction.
send_command(
"camera_orbit_delta "
f"{-0.28 * delta_x:.3f} {0.22 * delta_y:.3f}"
)
canvas.configure(cursor="fleur")
return
canvas.configure(
cursor=(
"hand2"
if in_joystick(event.x, event.y)
or in_mode_button(event.x, event.y)
else ""
)
)
def on_press(event: tk.Event) -> None:
if in_mode_button(event.x, event.y):
drag["mode"] = "mode_button"
if args.training_live_enabled:
mode_state["training"] = not mode_state["training"]
send_command(
"view_mode "
f"{'training' if mode_state['training'] else 'manual'}"
)
refresh_mode_controls()
canvas.configure(cursor="hand2")
return
if in_joystick(event.x, event.y):
drag["mode"] = "joystick"
update_joystick(int(event.x), int(event.y))
canvas.configure(cursor="hand2")
return
drag["mode"] = "camera"
drag["last_x"] = int(event.x)
drag["last_y"] = int(event.y)
canvas.configure(cursor="fleur")
def on_release(_event: tk.Event) -> None:
if drag["mode"] == "joystick":
send_command("robot_velocity 0.0 0.0 0.0")
canvas.coords(
knob_item,
pad_center["x"] - knob_radius,
pad_center["y"] - knob_radius,
pad_center["x"] + knob_radius,
pad_center["y"] + knob_radius,
)
canvas.itemconfigure(
value_item,
text="vx +0.00 vy +0.00 wz +0.00",
)
drag["mode"] = None
canvas.configure(cursor="")
def close_window(_event: tk.Event | None = None) -> None:
stopped.set()
root.destroy()
canvas.bind("<Motion>", on_motion)
canvas.bind("<Leave>", lambda _event: canvas.configure(cursor=""))
canvas.bind("<ButtonPress-1>", on_press)
canvas.bind("<B1-Motion>", on_motion)
canvas.bind("<ButtonRelease-1>", on_release)
root.bind("<Escape>", close_window)
root.bind("q", close_window)
root.protocol("WM_DELETE_WINDOW", close_window)
def read_exact(stream, buffer: bytearray) -> bool:
buffer_view = memoryview(buffer)
offset = 0
while offset < len(buffer):
chunk = stream.readinto(buffer_view[offset:])
if not chunk:
return False
offset += chunk
return True
def enqueue_frame(frame_bytes: bytes) -> None:
try:
frames.put_nowait(frame_bytes)
except queue.Full:
try:
frames.get_nowait()
except queue.Empty:
pass
try:
frames.put_nowait(frame_bytes)
except queue.Full:
pass
def read_source_frames() -> None:
frame_buffer = bytearray(expected_bytes)
while not stopped.is_set():
if not read_exact(sys.stdin.buffer, frame_buffer):
stopped.set()
return
enqueue_frame(bytes(frame_buffer))
threading.Thread(target=read_source_frames, daemon=True).start()
photo_reference: dict[str, ImageTk.PhotoImage | None] = {"image": None}
transition: dict[str, np.ndarray | float | bool | None] = {
"previous": None,
"current": None,
"displayed": None,
"forward_flow": None,
"backward_flow": None,
"started_at": 0.0,
"scene_cut": False,
}
transition_duration_s = 1.0 / args.source_fps
grid_x, grid_y = np.meshgrid(
np.arange(args.width, dtype=np.float32),
np.arange(args.height, dtype=np.float32),
)
redraw = {"needed": True}
def estimate_bidirectional_flow(
previous: np.ndarray,
current: np.ndarray,
) -> tuple[np.ndarray | None, np.ndarray | None, bool]:
"""Estimate scene motion at half scale and detect discontinuous resets."""
small_width = max(160, args.width // 2)
small_height = max(90, args.height // 2)
previous_gray = cv2.resize(
cv2.cvtColor(previous, cv2.COLOR_RGB2GRAY),
(small_width, small_height),
interpolation=cv2.INTER_AREA,
)
current_gray = cv2.resize(
cv2.cvtColor(current, cv2.COLOR_RGB2GRAY),
(small_width, small_height),
interpolation=cv2.INTER_AREA,
)
mean_difference = float(
cv2.absdiff(previous_gray, current_gray).mean()
)
try:
estimator = cv2.DISOpticalFlow_create(
cv2.DISOPTICAL_FLOW_PRESET_FAST
)
forward_small = estimator.calc(
previous_gray,
current_gray,
None,
)
backward_small = estimator.calc(
current_gray,
previous_gray,
None,
)
except (AttributeError, cv2.error):
return None, None, mean_difference > 42.0
scale_x = args.width / small_width
scale_y = args.height / small_height
forward = cv2.resize(
forward_small,
(args.width, args.height),
interpolation=cv2.INTER_LINEAR,
)
backward = cv2.resize(
backward_small,
(args.width, args.height),
interpolation=cv2.INTER_LINEAR,
)
forward[..., 0] *= scale_x
forward[..., 1] *= scale_y
backward[..., 0] *= scale_x
backward[..., 1] *= scale_y
magnitude_95 = float(
np.percentile(
np.linalg.norm(forward, axis=2),
95,
)
)
scene_cut = mean_difference > 42.0 or magnitude_95 > 120.0
return forward, backward, scene_cut
def begin_transition(frame_bytes: bytes) -> None:
current = np.frombuffer(frame_bytes, dtype=np.uint8).reshape(
args.height,
args.width,
3,
).copy()
# Always interpolate between two genuine RTX frames. Feeding a
# synthetic in-between image back into the next flow estimate causes
# recursive trails and eventually multiple translucent robots.
previous = transition["current"]
if not isinstance(previous, np.ndarray):
transition["previous"] = current
transition["current"] = current
transition["displayed"] = current
transition["forward_flow"] = None
transition["backward_flow"] = None
transition["scene_cut"] = True
transition["started_at"] = time.perf_counter()
return
forward, backward, scene_cut = estimate_bidirectional_flow(
previous,
current,
)
transition["previous"] = previous
transition["current"] = current
transition["forward_flow"] = forward
transition["backward_flow"] = backward
transition["scene_cut"] = scene_cut
transition["started_at"] = time.perf_counter()
def interpolated_frame(now: float) -> np.ndarray | None:
previous = transition["previous"]
current = transition["current"]
if not isinstance(current, np.ndarray):
return None
if not isinstance(previous, np.ndarray) or bool(
transition["scene_cut"]
):
return current
alpha = min(
1.0,
max(
0.0,
(now - float(transition["started_at"]))
/ transition_duration_s,
),
)
forward = transition["forward_flow"]
backward = transition["backward_flow"]
if (
alpha >= 1.0
or not isinstance(forward, np.ndarray)
or not isinstance(backward, np.ndarray)
):
return current
previous_map_x = grid_x - alpha * forward[..., 0]
previous_map_y = grid_y - alpha * forward[..., 1]
current_map_x = grid_x - (1.0 - alpha) * backward[..., 0]
current_map_y = grid_y - (1.0 - alpha) * backward[..., 1]
previous_warped = cv2.remap(
previous,
previous_map_x,
previous_map_y,
cv2.INTER_LINEAR,
borderMode=cv2.BORDER_REPLICATE,
)
current_warped = cv2.remap(
current,
current_map_x,
current_map_y,
cv2.INTER_LINEAR,
borderMode=cv2.BORDER_REPLICATE,
)
blended = cv2.addWeighted(
previous_warped,
1.0 - alpha,
current_warped,
alpha,
0.0,
)
# HUD text and the loss plot are semantic overlays, not scene
# geometry. Keep them crisp instead of optical-flow warping glyphs.
left_width = min(args.width, round(args.width * 0.38))
left_height = min(args.height, round(args.height * 0.72))
right_start = max(0, round(args.width * 0.60))
top_height = min(args.height, round(args.height * 0.22))
graph_start_y = max(0, round(args.height * 0.76))
blended[:left_height, :left_width] = current[
:left_height,
:left_width,
]
blended[:top_height, right_start:] = current[
:top_height,
right_start:,
]
blended[graph_start_y:, right_start:] = current[
graph_start_y:,
right_start:,
]
return blended
def on_resize(event: tk.Event) -> None:
if event.widget is not canvas:
return
layout_controls(max(int(event.height), 1))
redraw["needed"] = True
canvas.bind("<Configure>", on_resize)
def refresh() -> None:
if stopped.is_set():
close_window()
return
newest: bytes | None = None
while True:
try:
newest = frames.get_nowait()
except queue.Empty:
break
if newest is not None:
begin_transition(newest)
redraw["needed"] = True
current = transition["current"]
if isinstance(current, np.ndarray):
transition_alpha = min(
1.0,
max(
0.0,
(
time.perf_counter()
- float(transition["started_at"])
)
/ transition_duration_s,
),
)
if transition_alpha < 1.0:
redraw["needed"] = True
if redraw["needed"] and isinstance(current, np.ndarray):
canvas_width = max(canvas.winfo_width(), 1)
canvas_height = max(canvas.winfo_height(), 1)
display_frame = interpolated_frame(time.perf_counter())
if display_frame is None:
root.after(
max(1, round(1000.0 / args.display_fps)),
refresh,
)
return
transition["displayed"] = display_frame
image = Image.fromarray(display_frame, mode="RGB")
scale = min(
canvas_width / args.width,
canvas_height / args.height,
)
fitted_width = max(1, round(args.width * scale))
fitted_height = max(1, round(args.height * scale))
if fitted_width != args.width or fitted_height != args.height:
image = image.resize(
(fitted_width, fitted_height),
Image.Resampling.BILINEAR,
)
photo_reference["image"] = ImageTk.PhotoImage(image)
image_x = (canvas_width - fitted_width) // 2
image_y = (canvas_height - fitted_height) // 2
canvas.coords(image_item, image_x, image_y)
canvas.itemconfigure(
image_item,
image=photo_reference["image"],
)
canvas.tag_lower(image_item)
redraw["needed"] = False
root.after(max(1, round(1000.0 / args.display_fps)), refresh)
root.after(0, refresh)
root.mainloop()
return 0
if __name__ == "__main__":
raise SystemExit(main())