Files @ 7888cfff657b
Branch filter:

Location: light9/web/calibrate/Light9Camera.ts

drewp@bigasterisk.com
+ py-spy
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 },
        width: 640,
        height: 480,
      },
    };
    const stream = await navigator.mediaDevices.getUserMedia(constraints);
    const t = stream.getVideoTracks()[0];
    await t.applyConstraints({
      brightness: 0,
      contrast: 32,
      colorTemperature: 4600,
      exposureMode: "manual",
      exposureTime: 250,
      whiteBalanceMode: "manual",
      // this could stop focus from moving around, but it also makes my cam
      // click on every page reload
      //   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 = [
    "autoGainControl",
    "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: "800" },
  };

  @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, out: TemplateResult<1>[]) {
    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} />
      `;
    }
    out.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));
  }
}