# HG changeset patch # User drewp@bigasterisk.com # Date 1716193692 25200 # Node ID ae4b90efb55a54f40a194674d7c70165a82a0f08 # Parent 61dc5bc8ce2ebb0d3d5a06d662331c6a1c4542ad start calibration tool diff -r 61dc5bc8ce2e -r ae4b90efb55a package.json --- a/package.json Mon May 20 01:27:09 2024 -0700 +++ b/package.json Mon May 20 01:28:12 2024 -0700 @@ -26,6 +26,7 @@ "avro-js": "^1.11.3", "d3": "^7.9.0", "debug": "^4.3.4", + "echarts": "^5.5.0", "flexlayout-react": "^0.7.15", "fpsmeter": "^0.3.1", "immutable": "^4.3.5", diff -r 61dc5bc8ce2e -r ae4b90efb55a pnpm-lock.yaml --- a/pnpm-lock.yaml Mon May 20 01:27:09 2024 -0700 +++ b/pnpm-lock.yaml Mon May 20 01:28:12 2024 -0700 @@ -50,15 +50,15 @@ avro-js: specifier: ^1.11.3 version: 1.11.3 - avsc: - specifier: ^5.7.7 - version: 5.7.7 d3: specifier: ^7.9.0 version: 7.9.0 debug: specifier: ^4.3.4 version: 4.3.4 + echarts: + specifier: ^5.5.0 + version: 5.5.0 flexlayout-react: specifier: ^0.7.15 version: 0.7.15(react-dom@18.3.1)(react@18.3.1) @@ -1086,11 +1086,6 @@ underscore: 1.13.6 dev: false - /avsc@5.7.7: - resolution: {integrity: sha512-9cYNccliXZDByFsFliVwk5GvTq058Fj513CiR4E60ndDwmuXzTJEp/Bp8FyuRmGyYupLjHLs+JA9/CBoVS4/NQ==} - engines: {node: '>=0.11'} - dev: false - /base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -1651,6 +1646,13 @@ engines: {node: '>=10'} dev: true + /echarts@5.5.0: + resolution: {integrity: sha512-rNYnNCzqDAPCr4m/fqyUFv7fD9qIsd50S6GDFgO1DxZhncCsNsG7IfUlAlvZe5oSEQxtsjnHiUuppzccry93Xw==} + dependencies: + tslib: 2.3.0 + zrender: 5.5.0 + dev: false + /elliptic@6.5.5: resolution: {integrity: sha512-7EjbcmUm17NQFu4Pmgmq2olYMj8nwMnpcddByChSUjArp8F5DQWcIcpriwO4ZToLNAJig0yiyjswfyGNje/ixw==} dependencies: @@ -2550,6 +2552,10 @@ resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} dev: false + /tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + dev: false + /tslib@2.6.2: resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} dev: false @@ -2794,3 +2800,9 @@ resolution: {integrity: sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==} engines: {node: '>=12.20'} dev: false + + /zrender@5.5.0: + resolution: {integrity: sha512-O3MilSi/9mwoovx77m6ROZM7sXShR/O/JIanvzTwjN3FORfLSr81PsUGd7jlaYOeds9d8tw82oP44+3YucVo+w==} + dependencies: + tslib: 2.3.0 + dev: false diff -r 61dc5bc8ce2e -r ae4b90efb55a web/calibrate/Light9Calibrate.ts --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/calibrate/Light9Calibrate.ts Mon May 20 01:28:12 2024 -0700 @@ -0,0 +1,128 @@ +import debug from "debug"; +import { css, html, LitElement } from "lit"; +import { customElement, query, state } from "lit/decorators.js"; +import { CollectorClient } from "../collector/CollectorClient"; +import { getTopGraph } from "../RdfdbSyncedGraph"; +import { SyncedGraph } from "../SyncedGraph"; +import { Light9Camera } from "./Light9Camera"; +import { XyPlot } from "./XyPlot"; +export { RdfdbSyncedGraph } from "../RdfdbSyncedGraph"; +export { Light9Camera } from "./Light9Camera"; +export { XyPlot } from "./XyPlot"; +debug.enable("*"); +const log = debug("calibrate"); + +async function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +@customElement("light9-calibrate") +export class Light9Calibrate extends LitElement { + graph!: SyncedGraph; + static styles = [ + css` + button { + min-height: 3em; + min-width: 10em; + } + `, + ]; + collector: CollectorClient = new CollectorClient("calibrate"); + @query("light9-camera", true) cam?: Light9Camera; + @query("xy-plot", true) plot?: XyPlot; + @state() device: string; + constructor() { + super(); + this.device = "http://light9.bigasterisk.com/theater/vet/device/parR3"; + getTopGraph().then((g) => { + this.graph = g; + }); + } + + render() { + return html` +

Calibrate

+ + +

Device to calibrate: [ ${this.device} ]

+ +
    +
  1. +
  2. +
  3. +
  4. +
  5. +
+ + r/g/b/r*g*b lines ,x=send y=seen `; + } + + async withButtonSpinner(ev: MouseEvent, fn: () => Promise) { + const btn = ev.target as HTMLButtonElement; + try { + btn.disabled = true; + await fn(); + } finally { + btn.disabled = false; + } + } + async setToFull(ev: MouseEvent) { + await this.withButtonSpinner(ev, async () => { + this.collector.updateSettings([ + /// device,attr,value + [this.device, "http://light9.bigasterisk.com/color", "#ffffff"], + [this.device, "http://light9.bigasterisk.com/white", 1], + ]); + }); + } + + async findSafeExposure(ev: MouseEvent) { + await this.withButtonSpinner(ev, async () => { + + const gatherSample = async (expo: number) => { + await this.cam?.set("exposureTime", expo); + const settleUntil = Date.now() + 1000; + let miny = this.cam?.saturatedPixelCount!; + while (Date.now() < settleUntil) { + await sleep(50); + miny = Math.min(miny, this.cam?.saturatedPixelCount!); + } + this.plot!.insertPoint(expo, miny); + }; + + // todo: drive around without big skips, gradually slower, looking for the max workable expo + let fixedSteps = 8; + const expoMin = 1; + const expoMax = 200; + let expo = 0; + const data=this.plot!.data; + while (data.length < 20) { + if (data.length < fixedSteps + 1) { + expo = expoMin + ((expoMax - expoMin) / fixedSteps) * data.length; + } else { + let x2 = data.findIndex(([_, y]) => y > 2); + if (x2 < 1) x2 = 1; + const x1 = x2 - 1; + log(JSON.stringify([x1, data[x1], x2, data[x2]])); + expo = (data[x1][0] + data[x2][0]) / 2; + log(data); + } + await gatherSample(expo); + } + }); + } + async setToZero(ev: MouseEvent) { + await this.withButtonSpinner(ev, async () => { + this.collector.updateSettings([ + [this.device, "http://light9.bigasterisk.com/color", "#000000"], + [this.device, "http://light9.bigasterisk.com/white", 0], + ]); + }); + } + async markTare(ev: MouseEvent) { + await this.withButtonSpinner(ev, async () => {}); + } + async calibrateLoop(ev: MouseEvent) { + await this.withButtonSpinner(ev, async () => {}); + } +} diff -r 61dc5bc8ce2e -r ae4b90efb55a web/calibrate/Light9Camera.ts --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/calibrate/Light9Camera.ts Mon May 20 01:28:12 2024 -0700 @@ -0,0 +1,227 @@ +import debug from "debug"; +import { css, html, LitElement, PropertyValueMap, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; + +@customElement("light9-camera") +export class Light9Camera extends LitElement { + static styles = [ + css` + :host { + display: flex; + } + #video { + display: none; + } + #stack { + position: relative; + width: 640px; + height: 480px; + } + #stack > * { + position: absolute; + left: 0; + top: 0; + } + #stack > :first-child { + position: static; + } + #stack > img { + opacity: 0; + animation: fadeIn 1s 1s ease-in-out forwards; + } + @keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + `, + ]; + videoEl!: HTMLVideoElement; + canvas!: HTMLCanvasElement; + ctx!: CanvasRenderingContext2D; + @state() + vtrack: MediaStreamTrack | undefined; + + @property() saturatedPixelCount = 0; + @property() saturatedPixelFraction = 0; + + @property() videoSettings: MediaTrackSettings & any = {}; + + render() { + const saturatedCountDisplay = `${this.saturatedPixelCount} (${(this.saturatedPixelFraction * 100).toFixed(2)}%)`; + + return html` + + +
+ + +
+
+

saturated pixels: ${saturatedCountDisplay}

+ +
+ `; + } + + protected async firstUpdated(_changedProperties: PropertyValueMap | Map) { + this.videoEl = this.shadowRoot!.getElementById("video") as HTMLVideoElement; + this.canvas = this.shadowRoot!.getElementById("canvas") as HTMLCanvasElement; + this.ctx = this.canvas.getContext("2d", { willReadFrequently: true })!; + + const constraints: MediaStreamConstraints = { + video: { + facingMode: { ideal: "environment" }, + frameRate: { max: 10 }, + }, + }; + const stream = await navigator.mediaDevices.getUserMedia(constraints); + const t = stream.getVideoTracks()[0]; + await t.applyConstraints({ + brightness: 0, + contrast: 32, + colorTemperature: 6600, + exposureMode: "manual", + exposureTime: 250, + whiteBalanceMode: "manual", + focusMode: "manual", + focusDistance: 235, + } as MediaTrackConstraints); + + this.vtrack = t; + this.videoEl.srcObject = stream; + this.videoEl.play(); + this.videoSettings = this.vtrack.getSettings(); + + this.redrawLoop(); + } + + redrawLoop() { + if (this.videoEl.videoWidth !== 0 && this.videoEl.videoHeight !== 0) { + this.redraw(); + } + // todo: video frames come slower than raf is waiting + requestAnimationFrame(this.redrawLoop.bind(this)); + } + + public async set(k: string, v: any) { + if (!this.vtrack) { + throw new Error("vtrack"); + } + await this.vtrack.applyConstraints({ [k]: v }); + this.videoSettings = this.vtrack.getSettings(); + } + + private redraw() { + this.canvas.width = this.videoEl.videoWidth; + this.canvas.height = this.videoEl.videoHeight; + this.ctx.drawImage(this.videoEl, 0, 0); + this.makeSaturatedPixelsTransparent(); + } + + private makeSaturatedPixelsTransparent() { + const imageData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height); + const data = imageData.data; + this.saturatedPixelCount = 0; + for (let i = 0; i < data.length; i += 4) { + if (data[i] === 255 || data[i + 1] === 255 || data[i + 2] === 255) { + this.saturatedPixelCount += 1; + + data[i + 3] = 0; + } + } + this.saturatedPixelFraction = this.saturatedPixelCount / (data.length / 4); + this.ctx.putImageData(imageData, 0, 0); + } +} + +@customElement("light9-camera-settings-table") +export class Light9CameraSettingsTable extends LitElement { + static styles = [ + css` + table { + border-collapse: collapse; + } + td { + border: 1px solid gray; + padding: 1px 6px; + } + `, + ]; + + boring = [ + "aspectRatio", + "backgroundBlur", + "channelCount", + "deviceId", + "displaySurface", + "echoCancellation", + "eyeGazeCorrection", + "faceFraming", + "groupId", + "latency", + "noiseSuppression", + "pointsOfInterest", + "resizeMode", + "sampleRate", + "sampleSize", + "suppressLocalAudioPlayback", + "torch", + "voiceIsolation", + ]; + + adjustable: Record = { + focusDistance: { min: "0", max: "1023" }, + brightness: { min: "0", max: "64" }, + colorTemperature: { min: "2800", max: "6500" }, + exposureTime: { min: "0", max: "400" }, + }; + + @property() cam!: Light9Camera; + @property() videoSettings: MediaTrackSettings & any = {}; + supportedByBrowser: MediaTrackSupportedConstraints; + constructor() { + super(); + this.supportedByBrowser = navigator.mediaDevices.getSupportedConstraints(); + } + render() { + const rows: TemplateResult<1>[] = []; + for (const key of Object.keys(this.supportedByBrowser)) { + if (!this.boring.includes(key)) { + this.renderRow(key, rows); + } + } + return html` + ${rows} +
`; + } + + private renderRow(key: string, rows: any[]) { + let valueDisplay = ""; + if (this.videoSettings[key] !== undefined) { + valueDisplay = JSON.stringify(this.videoSettings[key]); + } + let adjuster = html``; + let conf = this.adjustable[key]; + if (conf !== undefined) { + adjuster = html` + + `; + } + rows.push( + html` + ${key} + ${valueDisplay} + ${adjuster} + ` + ); + } + + async setFromSlider(ev: InputEvent) { + const el = ev.target as HTMLInputElement; + await this.cam.set(el.dataset.param as string, parseFloat(el.value)); + } +} diff -r 61dc5bc8ce2e -r ae4b90efb55a web/calibrate/XyPlot.ts --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/calibrate/XyPlot.ts Mon May 20 01:28:12 2024 -0700 @@ -0,0 +1,61 @@ +import debug from "debug"; +import * as echarts from "echarts"; +import { css, html, LitElement, PropertyValueMap } from "lit"; +import { customElement, property } from "lit/decorators.js"; +debug.enable("*"); +const log = debug("calibrate"); + +@customElement("xy-plot") +export class XyPlot extends LitElement { + static styles = [ + css` + #chart { + width: 800px; + height: 300px; + } + `, + ]; + chart!: echarts.ECharts; + @property() label: string = ""; + @property() data: number[][] = []; + + render() { + return html` +
+ ${this.label} +
+
+ `; + } + + protected async firstUpdated(_changedProperties: PropertyValueMap | Map) { + var chartDom = this.shadowRoot!.getElementById("chart")!; + this.chart = echarts.init(chartDom); + this.chart.setOption({ + animation: false, + xAxis: { + type: "value", + }, + yAxis: { + type: "value", + }, + series: [ + { + name: "d", + data: [], + type: "line", + }, + ], + }); + } + + clear() { + this.data.length = 0; + } + + insertPoint(x: number, y: number) { + this.data.push([x, y]); + this.data.sort((a, b) => a[0] - b[0]); + this.chart.setOption({ series: [{ name: "d", data: this.data }] }); + } +} diff -r 61dc5bc8ce2e -r ae4b90efb55a web/calibrate/index.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/calibrate/index.html Mon May 20 01:28:12 2024 -0700 @@ -0,0 +1,12 @@ + + + + calibrate + + + + + + + + diff -r 61dc5bc8ce2e -r ae4b90efb55a web/calibrate/zebra.png Binary file web/calibrate/zebra.png has changed diff -r 61dc5bc8ce2e -r ae4b90efb55a web/collector/CollectorClient.ts --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/collector/CollectorClient.ts Mon May 20 01:28:12 2024 -0700 @@ -0,0 +1,30 @@ +type Settings = Array<[string,string,string|number]>; + +export class CollectorClient { + private settings: Settings; + constructor(public clientName:string) { + this.settings = []; + this.putLoop(); + } + private async putLoop() { + await this.put(); + setTimeout(() => { + this.putLoop(); + }, 1000); + } + private async put() { + await fetch("/service/collector/attrs", { + method: "PUT", + body: JSON.stringify({ + client: this.clientName, + clientSession: "unused", + sendTime: Date.now() / 1000, + settings: this.settings, + }), + }); + } + public async updateSettings(settings: Settings) { + this.settings = settings; + await this.put() + } +} diff -r 61dc5bc8ce2e -r ae4b90efb55a web/panels.ts --- a/web/panels.ts Mon May 20 01:27:09 2024 -0700 +++ b/web/panels.ts Mon May 20 01:28:12 2024 -0700 @@ -3,6 +3,7 @@ export { Light9EffectListing } from "./effects/Light9EffectListing"; export { Light9FadeUi } from "./fade/Light9FadeUi"; export { Light9DeviceSettings } from "./live/Light9DeviceSettings"; +export { Light9Calibrate } from "./calibrate/Light9Calibrate"; const panels: Map> = new Map([ ["light9-ascoltami-ui", ["ascoltami", "ascoltami"]], @@ -10,6 +11,7 @@ ["light9-device-settings", ["device-settings", "live"]], ["light9-effect-listing", ["effect-listing" , "effectListing"]], ["light9-fade-ui", ["fade", "fade"]], + ["light9-calibrate", ["calibrate", "calibrate"]], ]); export function panelElementNames(): string[] {