mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-10 06:23:43 +08:00
184 lines
8.3 KiB
Python
184 lines
8.3 KiB
Python
"""RGBA -> NV12 on the GPU, so the car renderer reads back 1.5 bytes per pixel instead of 4.
|
|
|
|
The encoder wants NV12: a full-resolution luma plane followed by a
|
|
half-resolution plane of interleaved U/V. GLES can only read RGBA back
|
|
portably, so each plane is packed four bytes to an RGBA texel:
|
|
|
|
* luma target, width/4 x height: texel (x, y) = Y of pixels 4x..4x+3 on row y
|
|
* chroma target, width/4 x height/2: texel (x, y) = U, V of the 2x2 blocks at
|
|
columns 4x and 4x+2 on rows 2y and 2y+1
|
|
|
|
Read back in row order, the two targets are exactly the tightly packed NV12
|
|
frame. The integer arithmetic matches ``hw/rgba_to_nv12.h`` (BT.601 limited
|
|
range, rounded 2x2 chroma average), so either path gives the car the same
|
|
picture. By default source texel row 0 is the top of the image. ``compose`` and
|
|
margins let the car renderer convert its UI texture directly, folding the
|
|
top-down flip and padding into these passes instead of copying a full RGBA frame.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import platform
|
|
|
|
GL_ONE, GL_ZERO, GL_FUNC_ADD = 1, 0, 0x8006
|
|
|
|
VERSION = "#version 330 core\n" if platform.system() == "Darwin" else "#version 300 es\nprecision highp float;\nprecision highp int;\n"
|
|
|
|
VERTEX_SHADER = VERSION + """
|
|
in vec3 vertexPosition;
|
|
in vec2 vertexTexCoord;
|
|
in vec4 vertexColor;
|
|
uniform mat4 mvp;
|
|
out vec2 fragTexCoord;
|
|
void main() {
|
|
fragTexCoord = vertexTexCoord;
|
|
gl_Position = mvp * vec4(vertexPosition, 1.0);
|
|
}
|
|
"""
|
|
|
|
FRAGMENT_COMMON = """
|
|
uniform sampler2D texture0;
|
|
uniform ivec2 sourceOffset;
|
|
uniform int composeSource;
|
|
out vec4 finalColor;
|
|
ivec3 px(int x, int y) {
|
|
ivec2 p = ivec2(x, y) - sourceOffset;
|
|
ivec2 size = textureSize(texture0, 0);
|
|
if (any(lessThan(p, ivec2(0))) || any(greaterThanEqual(p, size))) return ivec3(6, 6, 15);
|
|
if (composeSource != 0) p.y = size.y - 1 - p.y;
|
|
// Use RGB only. The UI texture starts from an opaque clear, so its RGB is
|
|
// already the final color; its alpha depends on which blend mode drew each
|
|
// pixel and changes between frames. Blending by it made bright areas flicker.
|
|
vec3 color = texelFetch(texture0, p, 0).rgb;
|
|
return ivec3(color * 255.0 + 0.5);
|
|
}
|
|
int luma(ivec3 p) { return ((66 * p.r + 129 * p.g + 25 * p.b + 128) >> 8) + 16; }
|
|
vec2 chroma(int x, int y) {
|
|
ivec3 a = (px(x, y) + px(x + 1, y) + px(x, y + 1) + px(x + 1, y + 1) + 2) >> 2;
|
|
int u = ((-38 * a.r - 74 * a.g + 112 * a.b + 128) >> 8) + 128;
|
|
int v = ((112 * a.r - 94 * a.g - 18 * a.b + 128) >> 8) + 128;
|
|
return vec2(float(u), float(v));
|
|
}
|
|
"""
|
|
|
|
LUMA_SHADER = VERSION + FRAGMENT_COMMON + """
|
|
void main() {
|
|
ivec2 o = ivec2(gl_FragCoord.xy);
|
|
int x = o.x * 4;
|
|
finalColor = vec4(float(luma(px(x, o.y))), float(luma(px(x + 1, o.y))),
|
|
float(luma(px(x + 2, o.y))), float(luma(px(x + 3, o.y)))) / 255.0;
|
|
}
|
|
"""
|
|
|
|
CHROMA_SHADER = VERSION + FRAGMENT_COMMON + """
|
|
void main() {
|
|
ivec2 o = ivec2(gl_FragCoord.xy);
|
|
finalColor = vec4(chroma(o.x * 4, o.y * 2), chroma(o.x * 4 + 2, o.y * 2)) / 255.0;
|
|
}
|
|
"""
|
|
|
|
|
|
def compose_rgba(source, target, margin_w: int, margin_h: int, *, fit: bool = False) -> None:
|
|
"""RGBA fallback composition, matching ``Nv12Converter(compose=True)``.
|
|
|
|
Centers the UI texture inside the car's margins, flips it top-down and copies
|
|
its RGB. Like the NV12 pass it never blends by the texture's alpha, which
|
|
varies between frames; blending by it made bright areas flicker.
|
|
"""
|
|
import pyray as rl
|
|
rl.begin_texture_mode(target)
|
|
rl.clear_background(rl.Color(6, 6, 15, 255))
|
|
rl.rl_set_blend_factors(GL_ONE, GL_ZERO, GL_FUNC_ADD)
|
|
rl.begin_blend_mode(rl.BlendMode.BLEND_CUSTOM)
|
|
# A positive source height flips on the GPU so the readback is top-down for the encoder.
|
|
destination = rl.Rectangle(margin_w // 2, margin_h // 2, source.width, source.height)
|
|
if fit:
|
|
width, height = target.texture.width - margin_w, target.texture.height - margin_h
|
|
scale = min(width / source.width, height / source.height)
|
|
drawn_w, drawn_h = source.width * scale, source.height * scale
|
|
destination = rl.Rectangle(margin_w // 2 + (width - drawn_w) / 2,
|
|
margin_h // 2 + (height - drawn_h) / 2, drawn_w, drawn_h)
|
|
rl.draw_texture_pro(source, rl.Rectangle(0, 0, source.width, source.height),
|
|
destination, rl.Vector2(0, 0), 0.0, rl.WHITE)
|
|
rl.end_blend_mode()
|
|
rl.end_texture_mode()
|
|
|
|
|
|
def supported(width: int, height: int) -> bool:
|
|
return width % 4 == 0 and height % 2 == 0
|
|
|
|
|
|
# ANDROID AUTO VIDEO PERFORMANCE GUARDRAIL: Fused GPU composition/conversion reduces readback size and avoids CPU color conversion.
|
|
# Altering this can degrade video smoothness or introduce lag on Android Auto.
|
|
# Do not change it autonomously; require an explicit user request specifically for this video optimization.
|
|
class Nv12Converter:
|
|
def __init__(self, width: int, height: int, *, margin_w: int = 0, margin_h: int = 0, compose: bool = False):
|
|
if not supported(width, height):
|
|
raise ValueError("NV12 conversion needs a width divisible by 4 and an even height")
|
|
if not 0 <= margin_w < width or not 0 <= margin_h < height:
|
|
raise ValueError("NV12 margins must leave a visible image")
|
|
import pyray as rl
|
|
self.rl = rl
|
|
self.width, self.height = width, height
|
|
self.source_size = (width - margin_w, height - margin_h)
|
|
self.shaders = []
|
|
self.targets = []
|
|
try:
|
|
for fragment in (LUMA_SHADER, CHROMA_SHADER):
|
|
shader = rl.load_shader_from_memory(VERTEX_SHADER, fragment)
|
|
if not shader.id or shader.id == rl.rl_get_shader_id_default():
|
|
raise RuntimeError("NV12 shader did not compile")
|
|
self.shaders.append(shader)
|
|
# The old composition pass places floor(margin_h / 2) rows above the
|
|
# flipped texture. Readback starts at the bottom, so an odd extra row
|
|
# belongs at the start of the encoded image, matching that pass exactly.
|
|
offset = rl.ffi.new("int[]", [margin_w // 2, margin_h - margin_h // 2])
|
|
compose_value = rl.ffi.new("int *", int(compose))
|
|
rl.set_shader_value(shader, rl.get_shader_location(shader, "sourceOffset"), offset, rl.ShaderUniformDataType.SHADER_UNIFORM_IVEC2)
|
|
rl.set_shader_value(shader, rl.get_shader_location(shader, "composeSource"), compose_value, rl.ShaderUniformDataType.SHADER_UNIFORM_INT)
|
|
self.targets = [rl.load_render_texture(width // 4, height), rl.load_render_texture(width // 4, height // 2)]
|
|
except BaseException:
|
|
self.close()
|
|
raise
|
|
|
|
def convert(self, source) -> list[tuple[int, int, int, int]]:
|
|
"""Convert the visible UI texture and return padded, top-down readback regions."""
|
|
if (source.width, source.height) != self.source_size:
|
|
raise ValueError("NV12 source dimensions do not match the visible area")
|
|
rl = self.rl
|
|
for shader, target in zip(self.shaders, self.targets, strict=True):
|
|
rl.begin_texture_mode(target)
|
|
# Replace, never blend: the alpha channel carries a fourth sample.
|
|
rl.rl_set_blend_factors(GL_ONE, GL_ZERO, GL_FUNC_ADD)
|
|
rl.begin_blend_mode(rl.BlendMode.BLEND_CUSTOM)
|
|
rl.begin_shader_mode(shader)
|
|
rl.draw_texture_pro(source, rl.Rectangle(0, 0, source.width, source.height),
|
|
rl.Rectangle(0, 0, target.texture.width, target.texture.height), rl.Vector2(0, 0), 0.0, rl.WHITE)
|
|
rl.end_shader_mode()
|
|
rl.end_blend_mode()
|
|
rl.end_texture_mode()
|
|
luma, chroma = self.targets
|
|
return [(luma.id, luma.texture.width, luma.texture.height, 0),
|
|
(chroma.id, chroma.texture.width, chroma.texture.height, self.width * self.height)]
|
|
|
|
def close(self) -> None:
|
|
for target in self.targets:
|
|
self.rl.unload_render_texture(target)
|
|
for shader in self.shaders:
|
|
self.rl.unload_shader(shader)
|
|
self.targets, self.shaders = [], []
|
|
|
|
|
|
def reference_nv12(rgba: bytes, width: int, height: int) -> bytes:
|
|
"""The same conversion on the CPU (numpy), for tests and on-device checks."""
|
|
import numpy as np
|
|
image = np.frombuffer(rgba, np.uint8).reshape(height, width, 4)[..., :3].astype(np.int32)
|
|
r, g, b = image[..., 0], image[..., 1], image[..., 2]
|
|
y = ((66 * r + 129 * g + 25 * b + 128) >> 8) + 16
|
|
blocks = image.reshape(height // 2, 2, width // 2, 2, 3).sum(axis=(1, 3))
|
|
a = (blocks + 2) >> 2
|
|
u = ((-38 * a[..., 0] - 74 * a[..., 1] + 112 * a[..., 2] + 128) >> 8) + 128
|
|
v = ((112 * a[..., 0] - 94 * a[..., 1] - 18 * a[..., 2] + 128) >> 8) + 128
|
|
uv = np.stack((u, v), axis=-1).reshape(height // 2, width)
|
|
return y.astype(np.uint8).tobytes() + uv.astype(np.uint8).tobytes()
|