PiP: Make White Balance a Toggle and Apply to Other Driver Cam Things

This commit is contained in:
Danny
2026-11-05 08:02:51 -08:00
parent 8f0b3d4963
commit 5ea75564bf
12 changed files with 222 additions and 17 deletions
+1
View File
@@ -159,6 +159,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> 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}},
+24 -1
View File
@@ -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
@@ -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, /<img[^>]*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/)
@@ -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;
+6 -3
View File
@@ -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 = {
<button class="gx-btn" type="button" :disabled="capturing" @click="snapshots.capture(camera)">{{ capturing ? 'Capturing…' : 'Take snapshot' }}</button>
<button v-if="image || capturing" class="gx-btn gx-btn--tonal" type="button" @click="snapshots.stop()">{{ capturing ? 'Cancel' : 'Clear' }}</button>
<GxNotice tone="danger" v-if="error">{{ error }}</GxNotice>
<img v-if="image" :src="image" :alt="camera + ' camera snapshot'" class="gx-media gx-media--spaced" />
<WhiteBalanceSwitch v-if="camera === 'cabin'" />
<BalancedImage v-if="image" :src="image" :balance="camera === 'cabin'" :alt="camera + ' camera snapshot'" class="gx-media gx-media--spaced" />
</template>
<p v-else>Camera snapshots are available on the connected device.</p>
<small>Snapshots are not saved.</small></section>
@@ -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
+5 -2
View File
@@ -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 = {
<section class="gx-card gx-vasm__editor" aria-label="Blind Spot Camera crop editor">
<div class="gx-section__header"><i class="bi bi-crop" aria-hidden="true"></i><span class="gx-section__title">Camera Crop</span></div>
<p class="gx-note">Drag a circle onto the cabin window you want to watch. Both crops share one size.</p>
<WhiteBalanceSwitch v-if="!bluetooth" />
<p v-if="!bluetooth" class="gx-note gx-camera-status" role="status">{{ state.cameraWarming ? "Warming up the camera · about 10 seconds…" : state.imageName ? "Snapshot ready" : "Take a cabin snapshot while parked." }}</p>
<div class="gx-actions">
<button v-for="side in SIDES" :key="side.key" type="button" class="gx-btn gx-btn--tonal" :disabled="!editable" :aria-pressed="state.activeSide === side.key" :aria-label="side.label" @click="choose(side.key)">{{ side.key === "centerRight" ? "Left" : "Right" }}</button>
+4 -2
View File
@@ -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 = {
<GxNotice v-if="data.scanIncomplete" tone="warn">This scan was incomplete. More local motion events may exist.</GxNotice>
<GxState v-if="!data.events.length">No local motion events found in this scan.</GxState>
<BluetoothNotice v-if="bluetooth && data.events.some(event => event.images?.length)" feature="Motion event images" />
<WhiteBalanceSwitch v-if="!bluetooth && data.events.some(event => event.images?.includes('cabin'))" />
<section v-for="event in data.events" :key="event.eventId" class="gx-card gx-home__card">
<div class="gx-section__header"><i class="bi" :class="event.kind === 'selfie' ? 'bi-camera' : event.kind === 'alarm' ? 'bi-bell' : 'bi-exclamation-triangle'"></i><h2 class="gx-section__title">{{ event.kind === 'selfie' ? 'Selfie' : event.kind === 'alarm' ? 'Alarm' : 'Warning' }}</h2></div>
<p>System time: {{ systemTime(event.systemTimeMs) }}</p>
<small>Event ID: {{ event.eventId }}</small>
<button type="button" class="gx-btn" :disabled="deleting" @click="feed.remove(event.eventId)">Delete</button>
<template v-if="!bluetooth"><img v-for="camera in (event.images || [])" :key="camera" :src="'./api/sentry/image/' + event.eventId + '/' + camera" :alt="camera + ' camera at motion event'" class="gx-media" /></template>
<template v-if="!bluetooth"><BalancedImage v-for="camera in (event.images || [])" :key="camera" :balance="camera === 'cabin'" :src="'./api/sentry/image/' + event.eventId + '/' + camera" :alt="camera + ' camera at motion event'" class="gx-media" /></template>
</section>
</template>
</template>
+5 -2
View File
@@ -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 = {
<section class="gx-card gx-vasm__editor" aria-label="Camera window region editor">
<div class="gx-section__header"><i class="bi bi-bounding-box" aria-hidden="true"></i><span class="gx-section__title">Camera Window Regions</span></div>
<p>The display is mirrored: vehicle left is camera right; vehicle right is camera left. Trace visible side glass, leaving pillars and interior out.</p>
<WhiteBalanceSwitch v-if="!bluetooth" />
<p v-if="!bluetooth" class="gx-note gx-camera-status" role="status">{{ state.cameraWarming ? "Warming up the camera · about 10 seconds…" : state.imageName ? "Snapshot ready" : "Take a cabin snapshot while parked." }}</p>
<div class="gx-actions">
<button v-for="side in ['cameraRight', 'cameraLeft']" :key="side" type="button" class="gx-btn gx-btn--tonal" :aria-pressed="state.activeSide === side" :disabled="!drawable" :aria-label="displaySide(side)" @click="state.activeSide=side">{{ side === 'cameraRight' ? 'Left' : 'Right' }}</button>
@@ -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: `<div class="gx-row gx-row--borderless"><div class="gx-row__info"><span class="gx-row__label">White balance</span><small>Corrects the cabin camera's magenta tint.</small></div>
<label class="gx-switch"><input type="checkbox" role="switch" aria-label="White balance" :checked="pref.on" @change="pref.set($event.target.checked)"><span class="gx-switch__track"></span><span class="gx-switch__thumb"></span></label></div>`,
}
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: `<img :src="shown || src">`,
}
+33 -4
View File
@@ -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,
+4 -2
View File
@@ -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);
@@ -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)