mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-12 15:33:45 +08:00
PiP: Make White Balance a Toggle and Apply to Other Driver Cam Things
This commit is contained in:
@@ -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}},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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">`,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user