diff --git a/selfdrive/ui/onroad/starpilot/pip_sidecam.py b/selfdrive/ui/onroad/starpilot/pip_sidecam.py index 533f28621..31fbb6af9 100644 --- a/selfdrive/ui/onroad/starpilot/pip_sidecam.py +++ b/selfdrive/ui/onroad/starpilot/pip_sidecam.py @@ -41,9 +41,14 @@ uniform sampler2D texture0; uniform sampler2D texture1; uniform vec2 uCropMin; uniform vec2 uCropSize; +uniform int uFlipX; out vec4 fragColor; void main() { - vec2 uv = uCropMin + fragTexCoord * uCropSize; + vec2 cropCoord = fragTexCoord; + if (uFlipX == 1) { + cropCoord.x = 1.0 - cropCoord.x; + } + vec2 uv = uCropMin + cropCoord * uCropSize; float y = texture(texture0, uv).r; vec2 c = texture(texture1, uv).ra - 0.5; vec3 rgb = vec3(y + 1.402 * c.y, y - 0.344 * c.x - 0.714 * c.y, y + 1.772 * c.x); @@ -58,6 +63,12 @@ void main() { """ UNIFORM_VEC2 = rl.ShaderUniformDataType.SHADER_UNIFORM_VEC2 +UNIFORM_INT = rl.ShaderUniformDataType.SHADER_UNIFORM_INT + +IMAGE_TO_VEHICLE_SIDE = { + "left": "right", + "right": "left", +} CONNECTION_RETRY_INTERVAL = 0.2 PARAM_REFRESH_INTERVAL = 2.0 @@ -95,6 +106,8 @@ class PipSideCamera: self._texture1_loc = rl.get_shader_location(self.shader, "texture1") self._crop_min_loc = rl.get_shader_location(self.shader, "uCropMin") self._crop_size_loc = rl.get_shader_location(self.shader, "uCropSize") + self._flip_x_loc = rl.get_shader_location(self.shader, "uFlipX") + self._flip_x_value = rl.ffi.new("int[1]", [1]) self_ref = weakref.ref(self) @@ -146,7 +159,12 @@ class PipSideCamera: self._mask = {} def active_sides(self) -> list[str]: - """Return the car-side keys ('left'/'right') whose preview bubble should show.""" + """Return vehicle-side keys whose preview bubble should show. + + The driver camera is not mirrored in the Galaxy snapshot. Its image-left + side is the vehicle's right side, so translate the image-relative mask + keys before applying vehicle signals and blind-spot state. + """ if not ui_state.started: return [] self._refresh_config() @@ -165,14 +183,19 @@ class PipSideCamera: right_bsm = bool(car_state.rightBlindspot) or vasm_right sides = [] - if self._mask.get("center_left") and ((self._show_on_blinker and left_blinker) or (self._show_on_bsm and left_bsm)): - sides.append("left") - if self._mask.get("center_right") and ((self._show_on_blinker and right_blinker) or (self._show_on_bsm and right_bsm)): - sides.append("right") + for image_side, vehicle_side, blinker, blindspot in ( + ("left", IMAGE_TO_VEHICLE_SIDE["left"], right_blinker, right_bsm), + ("right", IMAGE_TO_VEHICLE_SIDE["right"], left_blinker, left_bsm), + ): + if self._mask.get(f"center_{image_side}") and ( + (self._show_on_blinker and blinker) or (self._show_on_bsm and blindspot) + ): + sides.append(vehicle_side) return sides - def _crop_rect(self, side: str) -> rl.Rectangle | None: - center = self._mask.get(f"center_{side}") + def _crop_rect(self, vehicle_side: str) -> rl.Rectangle | None: + image_side = IMAGE_TO_VEHICLE_SIDE[vehicle_side] + center = self._mask.get(f"center_{image_side}") size = self._mask.get("crop_size") if not center or len(center) < 2 or not size: return None @@ -248,6 +271,7 @@ class PipSideCamera: rl.begin_shader_mode(self.shader) rl.set_shader_value(self.shader, self._crop_min_loc, crop_min, UNIFORM_VEC2) rl.set_shader_value(self.shader, self._crop_size_loc, crop_size, UNIFORM_VEC2) + rl.set_shader_value(self.shader, self._flip_x_loc, self._flip_x_value, UNIFORM_INT) rl.set_shader_value_texture(self.shader, self._texture1_loc, self.texture_uv) rl.draw_texture_pro(self.texture_y, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE) rl.end_shader_mode() diff --git a/selfdrive/ui/tests/test_pip_sidecam.py b/selfdrive/ui/tests/test_pip_sidecam.py new file mode 100644 index 000000000..900bf59a8 --- /dev/null +++ b/selfdrive/ui/tests/test_pip_sidecam.py @@ -0,0 +1,28 @@ +from openpilot.selfdrive.ui.onroad.starpilot.pip_sidecam import ( + IMAGE_TO_VEHICLE_SIDE, + PIP_FRAGMENT_SHADER, + PipSideCamera, +) + + +def test_pip_maps_raw_driver_image_sides_to_vehicle_sides(): + assert IMAGE_TO_VEHICLE_SIDE == {"left": "right", "right": "left"} + + camera = PipSideCamera.__new__(PipSideCamera) + camera._closed = True + camera._mask = { + "center_left": [100, 200], + "center_right": [900, 200], + "crop_size": 100, + } + + left_vehicle_crop = camera._crop_rect("left") + right_vehicle_crop = camera._crop_rect("right") + + assert (left_vehicle_crop.x, left_vehicle_crop.y) == (850, 150) + assert (right_vehicle_crop.x, right_vehicle_crop.y) == (50, 150) + + +def test_pip_driver_camera_shader_mirrors_the_crop(): + assert "uniform int uFlipX" in PIP_FRAGMENT_SHADER + assert "cropCoord.x = 1.0 - cropCoord.x" in PIP_FRAGMENT_SHADER diff --git a/starpilot/system/the_galaxy/assets/components/tools/pip_sidecam.css b/starpilot/system/the_galaxy/assets/components/tools/pip_sidecam.css index 32762e894..046e140c1 100644 --- a/starpilot/system/the_galaxy/assets/components/tools/pip_sidecam.css +++ b/starpilot/system/the_galaxy/assets/components/tools/pip_sidecam.css @@ -1,6 +1,13 @@ /* PiP Side Camera reuses the V-ASM tool styling. */ @import url("./v_asm.css"); +.v-asm-wrapper .v-asm-btn { + height: auto; + min-height: 32px; + text-align: center; + white-space: normal; +} + .pip-zoom-control { margin-top: 18px; padding: 14px 18px; diff --git a/starpilot/system/the_galaxy/assets/components/tools/pip_sidecam.js b/starpilot/system/the_galaxy/assets/components/tools/pip_sidecam.js index 983c68204..14e00a4ba 100644 --- a/starpilot/system/the_galaxy/assets/components/tools/pip_sidecam.js +++ b/starpilot/system/the_galaxy/assets/components/tools/pip_sidecam.js @@ -5,6 +5,18 @@ const CANVAS_H = 480; const ZOOM_MIN = 60; const ZOOM_MAX = 640; const ZOOM_STEP = 5; +const VEHICLE_SIDE_LABELS = { + left: { + canvas: "LEFT SIDE OF VEHICLE - LEFT HERE", + button: "Set Left Side of Vehicle - Left Here", + moveButton: "Move Left Side of Vehicle - Left Here", + }, + right: { + canvas: "RIGHT SIDE OF VEHICLE - RIGHT HERE", + button: "Set Right Side of Vehicle - Right Here", + moveButton: "Move Right Side of Vehicle - Right Here", + }, +}; let initialLoadTriggered = false; const state = reactive({ @@ -60,7 +72,20 @@ function redraw() { const img = _loadedImage; if (img) { canvas._img = img; + ctx.save(); + ctx.translate(canvas.width, 0); + ctx.scale(-1, 1); ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + ctx.restore(); + + ctx.font = "bold 14px sans-serif"; + ctx.textAlign = "center"; + ctx.fillStyle = "rgba(0, 0, 0, 0.65)"; + ctx.fillRect(0, 0, canvas.width / 2, 34); + ctx.fillRect(canvas.width / 2, 0, canvas.width / 2, 34); + ctx.fillStyle = "#fff"; + ctx.fillText(VEHICLE_SIDE_LABELS.left.canvas, canvas.width / 4, 22); + ctx.fillText(VEHICLE_SIDE_LABELS.right.canvas, canvas.width * 3 / 4, 22); } else { ctx.fillStyle = "#222"; ctx.fillRect(0, 0, canvas.width, canvas.height); @@ -73,8 +98,8 @@ function redraw() { const half = state.zoom / 2; const sides = [ - { key: "left", center: state.leftCenter, color: "#0d6efd", label: "LEFT OF IMAGE" }, - { key: "right", center: state.rightCenter, color: "#fd7e14", label: "RIGHT OF IMAGE" }, + { key: "left", center: state.leftCenter, color: "#0d6efd", label: VEHICLE_SIDE_LABELS.left.canvas }, + { key: "right", center: state.rightCenter, color: "#fd7e14", label: VEHICLE_SIDE_LABELS.right.canvas }, ]; for (const side of sides) { @@ -117,7 +142,7 @@ function redraw() { ctx.font = "bold 15px monospace"; ctx.textAlign = "center"; ctx.fillText( - state.armSide === "left" ? "Click to set the LEFT side of the photo (as seen)" : "Click to set the RIGHT side of the photo (as seen)", + state.armSide === "left" ? "Click LEFT HERE for the LEFT SIDE OF VEHICLE" : "Click RIGHT HERE for the RIGHT SIDE OF VEHICLE", canvas.width / 2, canvas.height - 18, ); @@ -195,7 +220,17 @@ function canvasClick(e) { const rect = canvas.getBoundingClientRect(); const x = Math.round((e.clientX - rect.left) * (canvas.width / rect.width)); const y = Math.round((e.clientY - rect.top) * (canvas.height / rect.height)); - if (x < 0 || y < 0) return; + if (x < 0 || y < 0 || x > canvas.width || y > canvas.height) return; + + const leftHalf = x < canvas.width / 2; + if ((state.armSide === "left" && !leftHalf) || (state.armSide === "right" && leftHalf)) { + state.error = state.armSide === "left" + ? "LEFT SIDE OF VEHICLE is on the LEFT. Click the left half of the preview." + : "RIGHT SIDE OF VEHICLE is on the RIGHT. Click the right half of the preview."; + state.success = ""; + requestAnimationFrame(redraw); + return; + } if (state.armSide === "left") { state.leftCenter = [x, y]; @@ -249,16 +284,17 @@ async function saveConfig() { const { cw, ch, nativeW, nativeH } = canvasScale(); + // The preview is mirrored for vehicle-side clarity; the daemon still stores raw image coordinates. function toNative(center) { if (!center) return []; - return [Math.round(center[0] * nativeW / cw), Math.round(center[1] * nativeH / ch)]; + return [Math.round((cw - center[0]) * nativeW / cw), Math.round(center[1] * nativeH / ch)]; } const config = { width: nativeW, height: nativeH, - center_left: toNative(state.leftCenter), - center_right: toNative(state.rightCenter), + center_left: toNative(state.rightCenter), + center_right: toNative(state.leftCenter), crop_size: Math.round(state.zoom * nativeW / cw), }; @@ -309,11 +345,11 @@ function applyConfigToCanvas() { function toCanvas(center) { if (!Array.isArray(center) || center.length < 2) return null; - return [Math.round(center[0] * cw / nativeW), Math.round(center[1] * ch / nativeH)]; + return [Math.round(cw - center[0] * cw / nativeW), Math.round(center[1] * ch / nativeH)]; } - state.leftCenter = toCanvas(config.center_left); - state.rightCenter = toCanvas(config.center_right); + state.leftCenter = toCanvas(config.center_right); + state.rightCenter = toCanvas(config.center_left); if (Number.isFinite(Number(config.crop_size))) { state.zoom = Math.round(Number(config.crop_size) * cw / nativeW); } @@ -385,8 +421,9 @@ export function PipSideCamera() {