diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index 8aa036deef..b3e5d3b76d 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -159,6 +159,7 @@ inline static std::unordered_map keys = { {"PIPPreviewShowOnBlinker", {PERSISTENT, BOOL, "0"}}, {"PIPPreviewShowOnBSM", {PERSISTENT, BOOL, "0"}}, {"PIPPreviewInvert", {PERSISTENT, BOOL, "0"}}, + {"CabinWhiteBalance", {PERSISTENT, BOOL, "1"}}, {"StarPilotDisplayPreferencesEnabled", {PERSISTENT, BOOL}}, {"ScreenBrightness", {PERSISTENT, INT}}, {"ScreenBrightnessOnroad", {PERSISTENT, INT}}, diff --git a/openpilot/starpilot/galaxy/server.py b/openpilot/starpilot/galaxy/server.py index 00fc7b7e58..2a11f9e6e0 100644 --- a/openpilot/starpilot/galaxy/server.py +++ b/openpilot/starpilot/galaxy/server.py @@ -1671,7 +1671,7 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe '/api/settings/reset-default', '/api/settings/restore', '/api/recordings/action', '/api/replay/start', '/api/replay/control', '/api/replay/stop', '/api/replay/export-start', - '/api/galaxy/pair', '/api/galaxy/unpair', '/api/galaxy/device-name', '/api/galaxy/hotspot', '/api/cameras/snapshot', + '/api/galaxy/pair', '/api/galaxy/unpair', '/api/galaxy/device-name', '/api/galaxy/hotspot', '/api/cameras/snapshot', '/api/cameras/white-balance', '/api/starport/layout', '/api/starport/enable', '/api/starport/control', '/api/starport/pairing', '/api/starport/logs/send', '/api/starport/pairing/response', '/api/starport/pairing/cancel', '/api/starport/pairing/select', @@ -1989,6 +1989,29 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe else: self.respond(200, body, 'image/jpeg') return + if path == '/api/cameras/white-balance': + if type(payload) is not dict or not (payload == {} or (set(payload) == {'enabled'} and type(payload['enabled']) is bool)): + self.json(400, {'error': 'Invalid white balance choice'}) + return + identity = self.settings_session() + if identity is None: + self.json(401, {'error': 'Sign in to Galaxy'}) + return + try: + from openpilot.common.params import Params + params = Params() + with effect_lock: + if 'enabled' in payload: + params.put_bool('CabinWhiteBalance', payload['enabled']) + enabled = params.get_bool('CabinWhiteBalance') + except Exception: + self.json(503, {'error': 'White balance is unavailable'}) + else: + if self.settings_session() == identity: + self.json(200, {'enabled': enabled}) + else: + self.json(401, {'error': 'Sign in to Galaxy'}) + return if path == '/api/galaxy/device-name': if not self.require_session(): return diff --git a/openpilot/starpilot/galaxy/tests/test_sentry.mjs b/openpilot/starpilot/galaxy/tests/test_sentry.mjs index 04f62d354c..a1f89edb22 100644 --- a/openpilot/starpilot/galaxy/tests/test_sentry.mjs +++ b/openpilot/starpilot/galaxy/tests/test_sentry.mjs @@ -17,7 +17,7 @@ assert.equal(validSentryEvents({ ...inventory, events: [{ ...inventory.events[0] assert.equal(validSentryEvents({ ...inventory, events: [inventory.events[0], inventory.events[0]] }), false) assert.equal(validSentryEvents({ ...inventory, source: 'remote' }), false) assert.match(SentryEventsPage.template, /device clock and may be inaccurate/) -assert.match(SentryEventsPage.template, /]*event.images[^>]*api\/sentry\/image\//) +assert.match(SentryEventsPage.template, /<(?:img|BalancedImage)[^>]*event.images[^>]*api\/sentry\/image\//) assert.doesNotMatch(SentryEventsPage.template, /v-html|deleteEvent|Download/) assert.match(CamerasPage.template, /MenuTile v-for="tool in tools"/) assert.match(cameraTools.find(tool => tool.path === "/cameras/events").description, /View motion events/) diff --git a/openpilot/starpilot/galaxy/web/css/galaxy.css b/openpilot/starpilot/galaxy/web/css/galaxy.css index d1b51a8333..ad82da4fbe 100644 --- a/openpilot/starpilot/galaxy/web/css/galaxy.css +++ b/openpilot/starpilot/galaxy/web/css/galaxy.css @@ -36,6 +36,10 @@ .gx-map-manager { padding: 16px; } .gx-map-manager__region { align-items: flex-start; flex-direction: column; } } +@media (max-width: 680px) { + .gx-pip .gx-settings__header, .gx-vasm .gx-settings__header { flex-direction: column; align-items: stretch; } + .gx-pip .gx-settings__header > .gx-actions, .gx-vasm .gx-settings__header > .gx-actions { flex: 1 1 auto; width: 100%; } +} @media (max-width: 680px) { .gx-settings__header { align-items: flex-start; } .gx-settings__subhead { align-items: flex-start; flex-direction: column; } } :root { color-scheme: dark; diff --git a/openpilot/starpilot/galaxy/web/js/cameras.js b/openpilot/starpilot/galaxy/web/js/cameras.js index 9eb714f23d..e20642dc0a 100644 --- a/openpilot/starpilot/galaxy/web/js/cameras.js +++ b/openpilot/starpilot/galaxy/web/js/cameras.js @@ -2,6 +2,7 @@ import { MenuTile } from "./menu-tile.js" import { GxNotice } from "./notice.js" import { GalaxySelect } from "./galaxy-select.js" import { BluetoothNotice, bluetoothConnection } from "./bluetooth-notice.js" +import { balancedBlobUrl, whiteBalancePref, WhiteBalanceSwitch, BalancedImage } from "./white-balance.js" export class CameraSnapshotFeed { constructor({ publish, unauthorized, fetcher = (...args) => fetch(...args), @@ -65,7 +66,7 @@ export class CameraSnapshotFeed { export const CamerasPage = { name: "CamerasPage", - components: { GxNotice, BluetoothNotice, GalaxySelect, MenuTile }, + components: { GxNotice, BluetoothNotice, GalaxySelect, MenuTile, WhiteBalanceSwitch, BalancedImage }, props: { mode: { type: String, required: true }, go: { type: Function, required: true }, tools: { type: Array, required: true }, unauthorized: { type: Function, required: true } }, @@ -98,7 +99,8 @@ export const CamerasPage = { {{ error }} - + +

Camera snapshots are available on the connected device.

Snapshots are not saved. @@ -153,7 +155,8 @@ export class LiveCameraPreview { image.onerror = failed this.cancelTimer(this.decodeTimer) this.decodeTimer = this.later(failed, 4000) - image.src = update.image + if (whiteBalancePref.on) balancedBlobUrl(update.image).then(url => { image.src = url }, () => { image.src = update.image }) + else image.src = update.image } start() { if (bluetoothConnection()) return diff --git a/openpilot/starpilot/galaxy/web/js/pip.js b/openpilot/starpilot/galaxy/web/js/pip.js index a1e4c2d376..ef068cfd9e 100644 --- a/openpilot/starpilot/galaxy/web/js/pip.js +++ b/openpilot/starpilot/galaxy/web/js/pip.js @@ -3,6 +3,7 @@ import { GxState } from "./state.js" import { GxNotice } from "./notice.js" import { reactive } from "../vendor/vue/vue.esm-browser.js" import { LiveCameraPreview } from "./cameras.js" +import { WhiteBalanceSwitch, whiteBalancePref } from "./white-balance.js" import { BluetoothNotice, bluetoothConnection } from "./bluetooth-notice.js" import { SettingsFeed, isPipCropRow, settingsBusy } from "./settings.js" import { GalaxySettingRow } from "./galaxy-setting-row.js" @@ -15,7 +16,7 @@ const SIDES = [ export const PipPage = { name: "PipPage", - components: { GxDialog, GxState, GxNotice, BluetoothNotice, GalaxySettingRow }, + components: { GxDialog, GxState, GxNotice, BluetoothNotice, GalaxySettingRow, WhiteBalanceSwitch }, props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true }, go: { type: Function, required: true } }, setup(props) { @@ -42,7 +43,7 @@ export const PipPage = { } } } }) - return { state, feed, FORMATS, SIDES, bluetooth: bluetoothConnection() } + return { state, feed, FORMATS, SIDES, bluetooth: bluetoothConnection(), pref: whiteBalancePref } }, mounted() { this._dragIndex = -1 @@ -57,6 +58,7 @@ export const PipPage = { }, updated() { this.$nextTick(() => this.redraw()) }, beforeUnmount() { this.liveCamera.stop(); this.feed.stop() }, + watch: { "pref.on"() { this.liveCamera?.refresh() } }, computed: { controls() { return (this.state.data?.rows || []).map((row, index) => ({ row, index })) .filter(({ row }) => !isPipCropRow(row) && !/camera availability/i.test(row.label)) }, @@ -189,6 +191,7 @@ export const PipPage = {
Camera Crop

Drag a circle onto the cabin window you want to watch. Both crops share one size.

+

{{ state.cameraWarming ? "Warming up the camera · about 10 seconds…" : state.imageName ? "Snapshot ready" : "Take a cabin snapshot while parked." }}

diff --git a/openpilot/starpilot/galaxy/web/js/sentry.js b/openpilot/starpilot/galaxy/web/js/sentry.js index 457153c4d8..b9518d0e0e 100644 --- a/openpilot/starpilot/galaxy/web/js/sentry.js +++ b/openpilot/starpilot/galaxy/web/js/sentry.js @@ -4,6 +4,7 @@ import { GxNotice } from "./notice.js" import { BluetoothNotice, bluetoothConnection } from "./bluetooth-notice.js" import { SentryNotifications } from "./sentry-notifications.js" import { SnapshotFeed } from "./snapshot-feed.js" +import { BalancedImage, WhiteBalanceSwitch } from "./white-balance.js" import { deleteRecords, storedSize } from "./record-deletion.js" // Historical motion records and independently configured notifications. @@ -50,7 +51,7 @@ export class SentryEventsFeed extends SnapshotFeed { export const SentryEventsPage = { name: "SentryEventsPage", - components: { GxState, GxNotice, BluetoothNotice, SentryNotifications, SettingsPage }, + components: { GxState, GxNotice, BluetoothNotice, SentryNotifications, SettingsPage, BalancedImage, WhiteBalanceSwitch }, props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true }, go: { type: Function, required: true } }, data: () => ({ bluetooth: bluetoothConnection(), status: "idle", data: null, error: "", deleting: false }), @@ -79,12 +80,13 @@ export const SentryEventsPage = { This scan was incomplete. More local motion events may exist. No local motion events found in this scan. +

{{ event.kind === 'selfie' ? 'Selfie' : event.kind === 'alarm' ? 'Alarm' : 'Warning' }}

System time: {{ systemTime(event.systemTimeMs) }}

Event ID: {{ event.eventId }} - +
diff --git a/openpilot/starpilot/galaxy/web/js/vasm.js b/openpilot/starpilot/galaxy/web/js/vasm.js index 80433fc4c8..4f5d99955d 100644 --- a/openpilot/starpilot/galaxy/web/js/vasm.js +++ b/openpilot/starpilot/galaxy/web/js/vasm.js @@ -4,13 +4,14 @@ import { reactive } from "../vendor/vue/vue.esm-browser.js" import { SettingsFeed, settingsBusy } from "./settings.js" import { GalaxySettingRow } from "./galaxy-setting-row.js" import { LiveCameraPreview } from "./cameras.js" +import { WhiteBalanceSwitch, whiteBalancePref } from "./white-balance.js" import { GxNotice } from "./notice.js" import { BluetoothNotice, bluetoothConnection } from "./bluetooth-notice.js" import { FORMATS, annotationDraft, displaySide, sourcePoint } from "./vasm-geometry.js" export const VasmPage = { name: "VasmPage", - components: { GxDialog, GxState, GalaxySettingRow, GxNotice, BluetoothNotice }, + components: { GxDialog, GxState, GalaxySettingRow, GxNotice, BluetoothNotice, WhiteBalanceSwitch }, props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true }, go: { type: Function, required: true } }, setup(props) { @@ -34,7 +35,7 @@ export const VasmPage = { "This saved camera size cannot be edited here. Choose a supported size and redraw the regions." } } }) - return { state, feed, FORMATS, displaySide, bluetooth: bluetoothConnection() } + return { state, feed, FORMATS, displaySide, bluetooth: bluetoothConnection(), pref: whiteBalancePref } }, mounted() { this._dragIndex = -1 @@ -49,6 +50,7 @@ export const VasmPage = { }, updated() { this.$nextTick(() => this.redraw()) }, beforeUnmount() { this.liveCamera.stop(); this.feed.stop() }, + watch: { "pref.on"() { this.liveCamera?.refresh() } }, computed: { controls() { return (this.state.data?.rows || []).map((row, index) => ({ row, index })) .filter(({ index }) => index !== this.state.data?.editorRow && !["Saved spot-monitor settings"].includes(this.state.data.rows[index].label)) }, @@ -181,6 +183,7 @@ export const VasmPage = {
Camera Window Regions

The display is mirrored: vehicle left is camera right; vehicle right is camera left. Trace visible side glass, leaving pillars and interior out.

+

{{ state.cameraWarming ? "Warming up the camera · about 10 seconds…" : state.imageName ? "Snapshot ready" : "Take a cabin snapshot while parked." }}

diff --git a/openpilot/starpilot/galaxy/web/js/white-balance.js b/openpilot/starpilot/galaxy/web/js/white-balance.js new file mode 100644 index 0000000000..4539c1ee7a --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/white-balance.js @@ -0,0 +1,113 @@ +import { reactive } from "../vendor/vue/vue.esm-browser.js" + +const GAIN_MIN = 0.6 +const GAIN_MAX = 1.6 +const SAMPLE_STEP = 8 +const DESATURATE = 0.75 + +export function whiteBalanceGains(data, width, height) { + let r = 0, g = 0, b = 0, count = 0 + for (let y = 0; y < height; y += SAMPLE_STEP) { + for (let x = 0; x < width; x += SAMPLE_STEP) { + const i = (y * width + x) * 4 + r += data[i]; g += data[i + 1]; b += data[i + 2]; count++ + } + } + if (!count) return [1, 1, 1] + const mean = [r / count / 255, g / count / 255, b / count / 255] + const luma = 0.299 * mean[0] + 0.587 * mean[1] + 0.114 * mean[2] + if (luma < 0.02) return [1, 1, 1] + return mean.map(channel => Math.min(GAIN_MAX, Math.max(GAIN_MIN, luma / Math.max(channel, 1e-3)))) +} + +// Cancels the cabin camera's IR magenta cast, leaving clipped highlights white. +export function whiteBalance(data, width, height) { + const gains = whiteBalanceGains(data, width, height) + for (let i = 0; i < data.length; i += 4) { + const rgb = [data[i] / 255, data[i + 1] / 255, data[i + 2] / 255] + const peak = Math.max(...rgb) + const t = Math.min(1, Math.max(0, (peak - 0.95) / 0.05)) + const mixed = rgb.map((v, c) => v * gains[c] * (1 - t) + v * t) + const gray = 0.299 * mixed[0] + 0.587 * mixed[1] + 0.114 * mixed[2] + for (let c = 0; c < 3; c++) { + const out = gray + (mixed[c] - gray) * DESATURATE + data[i + c] = Math.round(Math.min(1, Math.max(0, out)) * 255) + } + } + return data +} + +export function balancedBlobUrl(src) { + return new Promise((resolve, reject) => { + const image = new Image() + image.onload = () => { + const canvas = document.createElement("canvas") + canvas.width = image.naturalWidth + canvas.height = image.naturalHeight + const ctx = canvas.getContext("2d", { willReadFrequently: true }) + ctx.drawImage(image, 0, 0) + const frame = ctx.getImageData(0, 0, canvas.width, canvas.height) + whiteBalance(frame.data, canvas.width, canvas.height) + ctx.putImageData(frame, 0, 0) + canvas.toBlob(blob => blob ? resolve(URL.createObjectURL(blob)) : reject(new Error("Balance failed")), "image/jpeg", 0.92) + } + image.onerror = () => reject(new Error("Balance failed")) + image.src = src + }) +} + +const KEY = "gx-snapshot-white-balance" +const ENDPOINT = "./api/cameras/white-balance" +const send = (body) => fetch(ENDPOINT, { method: "POST", credentials: "same-origin", cache: "no-store", + headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }) + .then(response => response.ok ? response.json() : null).then(result => typeof result?.enabled === "boolean" ? result.enabled : null, () => null) + +// The device owns this choice, so the on-screen cameras follow it; the browser copy is the fallback. +export const whiteBalancePref = reactive({ + on: (() => { try { return localStorage.getItem(KEY) !== "0" } catch { return true } })(), + loaded: false, + remember(on) { this.on = on; try { localStorage.setItem(KEY, on ? "1" : "0") } catch {} }, + async load() { + if (this.loaded) return + this.loaded = true + const saved = await send({}) + if (saved !== null) this.remember(saved) + }, + async set(on) { + this.remember(on) + const saved = await send({ enabled: on }) + if (saved !== null && saved !== on) this.remember(saved) + }, +}) + +export const WhiteBalanceSwitch = { + name: "WhiteBalanceSwitch", + setup: () => ({ pref: whiteBalancePref }), + mounted() { this.pref.load() }, + template: `
White balanceCorrects the cabin camera's magenta tint.
+
`, +} + +export const BalancedImage = { + name: "BalancedImage", + props: { src: { type: String, required: true }, balance: { type: Boolean, default: true } }, + setup: () => ({ pref: whiteBalancePref }), + data: () => ({ shown: "" }), + computed: { active() { return this.balance && this.pref.on } }, + watch: { src() { this.refresh() }, active() { this.refresh() } }, + mounted() { this.pref.load(); this.refresh() }, + beforeUnmount() { this.token++; this.release() }, + methods: { + release() { if (this.shown && this.shown !== this.src) URL.revokeObjectURL(this.shown); this.shown = "" }, + async refresh() { + const token = this.token = (this.token || 0) + 1 + if (!this.active || !this.src) { this.release(); return } + let url = "" + try { url = await balancedBlobUrl(this.src) } catch { url = "" } + if (token !== this.token) { if (url) URL.revokeObjectURL(url); return } + this.release() + this.shown = url + }, + }, + template: ``, +} diff --git a/openpilot/starpilot/ui/pip_render.py b/openpilot/starpilot/ui/pip_render.py index 45cb3388a1..b6bffa5333 100644 --- a/openpilot/starpilot/ui/pip_render.py +++ b/openpilot/starpilot/ui/pip_render.py @@ -41,6 +41,9 @@ WB_SAMPLES = 16 # 16x16 grid of NV12 samples per crop WB_INTERVAL = 0.25 # seconds between samples per side WB_SMOOTHING = 0.35 # EMA weight of a new sample NEUTRAL_GAIN = (1.0, 1.0, 1.0) +WB_SATURATION = 0.75 +WB_PARAM = "CabinWhiteBalance" +WB_PARAM_INTERVAL = 1.0 def crop_white_balance(frame, crop) -> tuple[float, float, float] | None: @@ -95,6 +98,27 @@ class _WhiteBalance: return NEUTRAL_GAIN if gains is None else (float(gains[0]), float(gains[1]), float(gains[2])) +class _WhiteBalanceSwitch: + """The saved Galaxy choice, re-read about once a second; on when unreadable.""" + + def __init__(self): + self._enabled = True + self._read_at: float | None = None + self._params = None + + def enabled(self, now: float) -> bool: + if self._read_at is None or now - self._read_at >= WB_PARAM_INTERVAL or now < self._read_at: + self._read_at = now + try: + if self._params is None: + from openpilot.common.params import Params + self._params = Params() + self._enabled = bool(self._params.get_bool(WB_PARAM)) + except Exception: + self._enabled = True + return self._enabled + + class PiPRenderer: """One renderer; close it on onroad exit or when the owning shell closes.""" @@ -115,6 +139,7 @@ class PiPRenderer: self._active_sides: set[str] = set() self._activated_at: dict[str, float] = {} self._white_balance = _WhiteBalance() + self._white_balance_switch = _WhiteBalanceSwitch() def _shader(self) -> rl.Shader: if self.shader is None: @@ -256,9 +281,11 @@ class PiPRenderer: if placements is not None: rect = placements[side] warning = (signals.vasm_left or signals.left_blindspot) if side == "left" else (signals.vasm_right or signals.right_blindspot) - gain = self._white_balance.update(side, frame, crop, observed_now) - def draw(rect=rect, crop=crop, warning=warning, gain=gain): - self._draw(shader, texture, frame, rect, crop.x, crop.y, crop.size, invert, warning=warning, gain=gain) + balanced = self._white_balance_switch.enabled(observed_now) + gain = self._white_balance.update(side, frame, crop, observed_now) if balanced else NEUTRAL_GAIN + def draw(rect=rect, crop=crop, warning=warning, gain=gain, balanced=balanced): + self._draw(shader, texture, frame, rect, crop.x, crop.y, crop.size, invert, warning=warning, gain=gain, + saturation=WB_SATURATION if balanced else 1.0) if submit is None: draw() else: @@ -267,7 +294,7 @@ class PiPRenderer: def _draw(self, shader: rl.Shader, texture: rl.Texture, frame, rect: Rect, crop_x: float, crop_y: float, crop_size: float, invert: bool, *, warning: bool = False, - gain: tuple[float, float, float] = NEUTRAL_GAIN) -> None: + gain: tuple[float, float, float] = NEUTRAL_GAIN, saturation: float = WB_SATURATION) -> None: texture_width = frame.width if COMMA_HARDWARE else frame.stride crop_min = rl.Vector2(crop_x / texture_width, crop_y / frame.height) crop_extent = rl.Vector2(crop_size / texture_width, crop_size / frame.height) @@ -288,6 +315,8 @@ class PiPRenderer: shader_gain = np.power(np.array(gain, dtype=np.float32), 1.0 / 1.28) if COMMA_HARDWARE else np.array(gain, dtype=np.float32) rl.set_shader_value(shader, rl.get_shader_location(shader, "uGain"), shader_gain, rl.ShaderUniformDataType.SHADER_UNIFORM_VEC3) + rl.set_shader_value(shader, rl.get_shader_location(shader, "uSaturation"), np.array([saturation], dtype=np.float32), + rl.ShaderUniformDataType.SHADER_UNIFORM_FLOAT) if self.shape == "curved": panel = rl.Vector2(rect.width, rect.height) rl.set_shader_value(shader, rl.get_shader_location(shader, "uRectSize"), panel, diff --git a/openpilot/starpilot/ui/pip_shaders.py b/openpilot/starpilot/ui/pip_shaders.py index 381b50affd..a77cc53114 100644 --- a/openpilot/starpilot/ui/pip_shaders.py +++ b/openpilot/starpilot/ui/pip_shaders.py @@ -27,6 +27,7 @@ uniform vec2 uCropMin; uniform vec2 uCropSize; uniform int uFlipX; uniform vec3 uGain; +uniform float uSaturation; out vec4 fragColor; const float BUBBLE_REFRACTION = 0.016; @@ -65,7 +66,7 @@ void main() { // The cast is strongest through the glass and absent in open air, so one gain can't fix both: // a mild desaturation hides what's left (pink foliage, a slightly cyan sky). rgb = mix(rgb * uGain, rgb, smoothstep(0.95, 1.0, max(rgb.r, max(rgb.g, rgb.b)))); - rgb = mix(vec3(dot(rgb, vec3(0.299, 0.587, 0.114))), rgb, 0.75); + rgb = mix(vec3(dot(rgb, vec3(0.299, 0.587, 0.114))), rgb, uSaturation); // Keep the interior gradient and use cheap analytic lighting instead of specular math. float edgeShade = smoothstep(0.22, 0.98, radius); @@ -91,6 +92,7 @@ uniform vec2 uCropMin; uniform vec2 uCropSize; uniform int uFlipX; uniform vec3 uGain; +uniform float uSaturation; uniform vec2 uRectSize; out vec4 fragColor; @@ -140,7 +142,7 @@ void main() { // The cast is strongest through the glass and absent in open air, so one gain can't fix both: // a mild desaturation hides what's left (pink foliage, a slightly cyan sky). rgb = mix(rgb * uGain, rgb, smoothstep(0.95, 1.0, max(rgb.r, max(rgb.g, rgb.b)))); - rgb = mix(vec3(dot(rgb, vec3(0.299, 0.587, 0.114))), rgb, 0.75); + rgb = mix(vec3(dot(rgb, vec3(0.299, 0.587, 0.114))), rgb, uSaturation); // Let the rim blend into the camera image and the UI underneath it. float edgeShade = smoothstep(-radius, 0.0, dist); diff --git a/openpilot/starpilot/ui/tests/test_pip_sidecam.py b/openpilot/starpilot/ui/tests/test_pip_sidecam.py index bb612ebd93..e1eec29491 100644 --- a/openpilot/starpilot/ui/tests/test_pip_sidecam.py +++ b/openpilot/starpilot/ui/tests/test_pip_sidecam.py @@ -789,3 +789,25 @@ def test_camera_resolution_adaptation_preserves_saved_crop_and_side_identity(): assert mask.width == 1928 and mask.center_left == (315, 548) assert mask.for_frame(1928, 1208) is mask assert mask.for_frame(640, 480) is None + + +def test_white_balance_switch_follows_saved_choice(monkeypatch): + import sys + import types + from openpilot.starpilot.ui.pip_render import _WhiteBalanceSwitch + + class FakeParams: + value = True + + def get_bool(self, key): + assert key == "CabinWhiteBalance" + return self.value + + module = types.ModuleType("openpilot.common.params") + module.Params = FakeParams + monkeypatch.setitem(sys.modules, "openpilot.common.params", module) + switch = _WhiteBalanceSwitch() + assert switch.enabled(0.0) + FakeParams.value = False + assert switch.enabled(0.5) + assert not switch.enabled(1.5)