changeset 2417:ae4b90efb55a

start calibration tool
author drewp@bigasterisk.com
date Mon, 20 May 2024 01:28:12 -0700
parents 61dc5bc8ce2e
children 9bb0eb587d5b
files package.json pnpm-lock.yaml web/calibrate/Light9Calibrate.ts web/calibrate/Light9Camera.ts web/calibrate/XyPlot.ts web/calibrate/index.html web/calibrate/zebra.png web/collector/CollectorClient.ts web/panels.ts
diffstat 9 files changed, 481 insertions(+), 8 deletions(-) [+]
line wrap: on
line diff
--- 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",
--- 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
--- /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`<rdfdb-synced-graph></rdfdb-synced-graph>
+      <h1>Calibrate</h1>
+      <light9-camera></light9-camera>
+
+      <p>Device to calibrate: [ ${this.device} ]</p>
+
+      <ol>
+        <li><button @click=${this.setToFull}>Set to full</button></li>
+        <li><button @click=${this.findSafeExposure}>Find safe exposure</button></li>
+        <li><button @click=${this.setToZero}>Set to 0</button></li>
+        <li><button @click=${this.markTare}>Mark tare</button></li>
+        <li><button @click=${this.calibrateLoop}>Calibrate loop</button></li>
+      </ol>
+      <xy-plot label="zebra pixels vs exposure"></xy-plot>
+      r/g/b/r*g*b lines ,x=send y=seen `;
+  }
+
+  async withButtonSpinner(ev: MouseEvent, fn: () => Promise<void>) {
+    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 () => {});
+  }
+}
--- /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`
+      <video id="video"></video>
+
+      <div id="stack">
+        <img src="zebra.png" />
+        <canvas id="canvas"></canvas>
+      </div>
+      <div id="controls">
+        <p>saturated pixels: ${saturatedCountDisplay}</p>
+        <light9-camera-settings-table .cam=${this} .videoSettings=${this.videoSettings}></light9-camera-settings-table>
+      </div>
+    `;
+  }
+
+  protected async firstUpdated(_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>) {
+    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<string, { min: string; max: string }> = {
+    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`<table>
+      ${rows}
+    </table>`;
+  }
+
+  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`
+        <input type="range" min="${conf.min}" max="${conf.max}" value="${this.videoSettings[key]}" data-param="${key}" @input=${this.setFromSlider} />
+      `;
+    }
+    rows.push(
+      html`<tr>
+        <td>${key}</td>
+        <td>${valueDisplay}</td>
+        <td>${adjuster}</td>
+      </tr>`
+    );
+  }
+
+  async setFromSlider(ev: InputEvent) {
+    const el = ev.target as HTMLInputElement;
+    await this.cam.set(el.dataset.param as string, parseFloat(el.value));
+  }
+}
--- /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`
+      <fieldset>
+        <legend>${this.label}</legend>
+        <div id="chart"></div>
+      </fieldset>
+    `;
+  }
+
+  protected async firstUpdated(_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>) {
+    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 }] });
+  }
+}
--- /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 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>calibrate</title>
+    <meta charset="utf-8" />
+    <link rel="stylesheet" href="../style.css" />
+    <script type="module" src="Light9Calibrate"></script>
+  </head>
+  <body>
+    <light9-calibrate></light9-calibrate>
+  </body>
+</html>
Binary file web/calibrate/zebra.png has changed
--- /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()
+  }
+}
--- 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<string, Array<string>> = 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[] {