Bläddra i källkod

nutikodu-web: Varv — veebipohine tume/valge kalibreerimine (auth)

- uus tabel uus67.varvus_calib (ReplacingMergeTree); GET/POST /api/varv/calib (POST require_auth)

- /api/varv/latest tagastab kalibreeritud valjad (R=(S-D)/(W-D))

- UI: Kalibreerimise paneel (Salvesta tume/valge) + Kuva kalibreeritud varvi
ardo 1 vecka sedan
förälder
incheckning
12a8e70c8b

+ 1 - 1
dev/nutikodu-web/Dockerfile

@@ -7,7 +7,7 @@ RUN pip install --no-cache-dir -r requirements.txt \
  && pip install --no-cache-dir opentelemetry-distro opentelemetry-exporter-otlp-proto-grpc \
  && opentelemetry-bootstrap -a install
 
-COPY app.py .
+COPY app.py color.py .
 COPY static ./static
 
 EXPOSE 8095

+ 5 - 1
dev/nutikodu-web/README.md

@@ -30,7 +30,9 @@ tume/hele teema.
   (viimased 24 h tunnipõhiselt) ja kromaatilisuse (x, y) hajuvus. 20 s värskendus.
   **Sisselogitult** saab mõõtmise sisse/välja lülitada (MQTT,
   `varvus/switch/sensor`); OFF peatab ESP32 publitseerimise → **ka ClickHouse'i
-  kirjutamise**.
+  kirjutamise**. Sisselogitult saab ka salvestada **tume/valge referentsi**
+  (`uus67.varvus_calib`) ja vaadata **kalibreeritud** värvi
+  (R = (S−D)/(W−D)).
 - PWA-manifest (installitav telefonis), `noindex`.
 
 ## API
@@ -52,6 +54,8 @@ tume/hele teema.
 | `GET /api/varv/history?hours=24` | tunnipõhine keskmine värv (värvitelg) — avalik |
 | `GET /api/varv/power` | 🔒 värvisensori mõõtmise olek (ON/OFF) |
 | `POST /api/varv/power` | 🔒 lülita mõõtmine sisse/välja (keha `{"on":true\|false}`) |
+| `GET /api/varv/calib` | tume/valge referents (avalik) |
+| `POST /api/varv/calib` | 🔒 salvesta tume/valge referents (keha `{"kind":"dark"\|"white","last":5}`) |
 | `GET /api/me` | kas päring on autenditud (`{"authed":bool}`) |
 | `GET /health` | tervis |
 

+ 100 - 19
dev/nutikodu-web/app.py

@@ -31,6 +31,7 @@ from datetime import datetime, timedelta
 from pathlib import Path
 from zoneinfo import ZoneInfo
 
+import color
 import paho.mqtt.client as mqtt
 import requests
 from fastapi import Body, Depends, FastAPI, Form, HTTPException, Query, Request
@@ -57,6 +58,9 @@ VARV_MQTT_USER = os.environ.get("VARV_MQTT_USER", "")
 VARV_MQTT_PASS = os.environ.get("VARV_MQTT_PASS", "")
 VARV_CMD_TOPIC = os.environ.get("VARV_CMD_TOPIC", "varvus/switch/sensor/command")
 VARV_STATE_TOPIC = os.environ.get("VARV_STATE_TOPIC", "varvus/switch/sensor/state")
+VARV_BANDS = ("f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8")
+VARV_CALIB_FIELDS = VARV_BANDS + ("clear", "nir")
+VARV_CALIB_TABLE = os.environ.get("VARV_CALIB_TABLE", "varvus_calib")
 
 WEB_ACCESS_TOKEN = os.environ.get("WEB_ACCESS_TOKEN", "")
 WEB_PASSWORD = os.environ.get("WEB_PASSWORD", "")
@@ -546,12 +550,37 @@ async def uus67_latest() -> dict:
 
 # --- Värvisensor (uus67.varvus, AS7341) ---
 
-VARV_BANDS = ("f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8")
+
+def varv_load_calib() -> dict:
+    """Loe tume/valge referents (uus67.varvus_calib, viimane kirje liigi kohta)."""
+    try:
+        rows = ch_query(
+            f"SELECT kind, {', '.join(VARV_CALIB_FIELDS)} "
+            f"FROM {CH_DB}.{VARV_CALIB_TABLE} FINAL"
+        )
+    except HTTPException:
+        return {}
+    out: dict[str, dict] = {}
+    for r in rows:
+        out[r["kind"]] = {f: r.get(f) for f in VARV_CALIB_FIELDS}
+    return out
+
+
+def varv_reflectance(channels: dict, dark: dict, white: dict) -> dict:
+    """R = (S - D) / (W - D) kanali kohta."""
+    out: dict[str, float] = {}
+    for band in VARV_BANDS:
+        v = channels.get(band)
+        d = float(dark.get(band) or 0.0)
+        w = float(white.get(band) or 0.0)
+        if v is not None and (w - d) > 0:
+            out[band] = max(0.0, (float(v) - d) / (w - d))
+    return out
 
 
 @app.get("/api/varv/latest")
 async def varv_latest(limit: int = Query(60, ge=1, le=300)) -> dict:
-    """Värvisensori viimased mõõtmised (avalik)."""
+    """Värvisensori viimased mõõtmised (avalik; kalibreeritud väljad kui referentsid olemas)."""
     cols = ", ".join(VARV_BANDS)
     rows = ch_query(
         f"SELECT toString(ts) AS ts, toUInt64(toUnixTimestamp(ts)) * 1000 AS t, "
@@ -559,27 +588,79 @@ async def varv_latest(limit: int = Query(60, ge=1, le=300)) -> dict:
         f"{cols}, clear, nir, gain "
         f"FROM {CH_DB}.varvus ORDER BY ts DESC LIMIT {int(limit)}"
     )
+    calib = varv_load_calib()
+    dark, white = calib.get("dark"), calib.get("white")
+    ready = bool(dark and white)
+    out = []
+    for r in rows:
+        bands = {b: r.get(b) for b in VARV_BANDS}
+        item = {
+            "ts": r["ts"],
+            "t": int(r["t"]),
+            "rgb_hex": r.get("rgb_hex") or "#000000",
+            "r": int(r.get("r") or 0),
+            "g": int(r.get("g") or 0),
+            "b": int(r.get("b") or 0),
+            "x_chrom": r.get("x_chrom"),
+            "y_chrom": r.get("y_chrom"),
+            "bands": bands,
+            "clear": r.get("clear"),
+            "nir": r.get("nir"),
+            "gain": r.get("gain"),
+        }
+        if ready:
+            cc = color.analyze(varv_reflectance(bands, dark, white))
+            item["rgb_hex_cal"] = cc["rgb_hex"]
+            item["x_chrom_cal"] = cc["x_chrom"]
+            item["y_chrom_cal"] = cc["y_chrom"]
+        out.append(item)
+    return {"rows": out, "calibrated": ready}
+
+
+@app.get("/api/varv/calib")
+async def varv_calib_get() -> dict:
+    """Tume/valge referents (avalik lugemine)."""
+    calib = varv_load_calib()
     return {
-        "rows": [
-            {
-                "ts": r["ts"],
-                "t": int(r["t"]),
-                "rgb_hex": r.get("rgb_hex") or "#000000",
-                "r": int(r.get("r") or 0),
-                "g": int(r.get("g") or 0),
-                "b": int(r.get("b") or 0),
-                "x_chrom": r.get("x_chrom"),
-                "y_chrom": r.get("y_chrom"),
-                "bands": {b: r.get(b) for b in VARV_BANDS},
-                "clear": r.get("clear"),
-                "nir": r.get("nir"),
-                "gain": r.get("gain"),
-            }
-            for r in rows
-        ]
+        "dark": calib.get("dark"),
+        "white": calib.get("white"),
+        "ready": bool(calib.get("dark") and calib.get("white")),
     }
 
 
+@app.post("/api/varv/calib", dependencies=[Depends(require_auth)])
+async def varv_calib_set(payload: dict = Body(...)) -> dict:
+    """Salvesta tume/valge referents viimasest N mõõtmisest (ainult sisselogitult)."""
+    kind = str(payload.get("kind", "")).strip().lower()
+    if kind not in ("dark", "white"):
+        raise HTTPException(status_code=422, detail="kind peab olema 'dark' või 'white'")
+    last = max(1, min(int(payload.get("last", 5)), 60))
+    rows = ch_query(
+        f"SELECT {', '.join(VARV_CALIB_FIELDS)} FROM {CH_DB}.varvus "
+        f"ORDER BY ts DESC LIMIT {last}"
+    )
+    if not rows:
+        raise HTTPException(status_code=409, detail="Mõõtmisi pole")
+    ref: dict[str, float] = {}
+    for f in VARV_CALIB_FIELDS:
+        vals = [float(r[f]) for r in rows if r.get(f) is not None]
+        ref[f] = round(sum(vals) / len(vals), 3) if vals else 0.0
+    row = {"kind": kind, **ref}
+    resp = requests.post(
+        f"{CH_URL}/",
+        params={
+            "database": CH_DB,
+            "query": f"INSERT INTO {CH_DB}.{VARV_CALIB_TABLE} FORMAT JSONEachRow",
+        },
+        data=json.dumps(row) + "\n",
+        auth=(CH_USER, CH_PASS),
+        timeout=10,
+    )
+    if resp.status_code != 200:
+        raise HTTPException(status_code=502, detail=f"ClickHouse: {resp.text[:200]}")
+    return {"kind": kind, "reference": ref, "rows": len(rows)}
+
+
 @app.get("/api/varv/history")
 async def varv_history(hours: int = Query(24, ge=1, le=168)) -> dict:
     """Värvitelg: tunnipõhine keskmine värv viimaste tundide jooksul (avalik)."""

+ 101 - 0
dev/nutikodu-web/color.py

@@ -0,0 +1,101 @@
+"""AS7341 spektri -> ligikaudne värvus (CIE 1931 XYZ -> sRGB).
+
+Kasutab Wyman, Sloan & Shirley (2013) analüütilist CIE 1931 2° CMF
+lähendust 8 kanali kesk lainepikkustel.
+
+NB! See EI ole kalibreeritud kolorimeetria — absoluutse värvuse jaoks on
+vaja valge referentsi ja fikseeritud valgustust (vt LOEMIND.md).
+"""
+from __future__ import annotations
+
+import math
+
+# ESPHome as7341 kanalite F1..F8 kesk lainepikkused (nm)
+CHANNEL_WAVELENGTHS: dict[str, int] = {
+    "f1": 415,
+    "f2": 445,
+    "f3": 480,
+    "f4": 515,
+    "f5": 555,
+    "f6": 590,
+    "f7": 630,
+    "f8": 680,
+}
+
+
+def _piecewise_gaussian(x: float, mu: float, sigma1: float, sigma2: float) -> float:
+    sigma = sigma1 if x < mu else sigma2
+    return math.exp(-0.5 * ((x - mu) / sigma) ** 2)
+
+
+def cie_xyz_cmf(wavelength_nm: float) -> tuple[float, float, float]:
+    """Wyman et al. (2013) lähendus CIE 1931 2° värvusfunktsioonidele."""
+    x_bar = (
+        1.056 * _piecewise_gaussian(wavelength_nm, 599.8, 37.9, 31.0)
+        + 0.362 * _piecewise_gaussian(wavelength_nm, 442.0, 16.0, 26.7)
+        - 0.065 * _piecewise_gaussian(wavelength_nm, 501.1, 20.4, 26.2)
+    )
+    y_bar = (
+        0.821 * _piecewise_gaussian(wavelength_nm, 568.8, 46.9, 40.5)
+        + 0.286 * _piecewise_gaussian(wavelength_nm, 530.9, 16.3, 31.1)
+    )
+    z_bar = (
+        1.217 * _piecewise_gaussian(wavelength_nm, 437.0, 11.8, 36.0)
+        + 0.681 * _piecewise_gaussian(wavelength_nm, 459.0, 26.0, 13.8)
+    )
+    return x_bar, y_bar, z_bar
+
+
+def channels_to_xyz(channels: dict[str, float]) -> tuple[float, float, float]:
+    """Ligikaudne XYZ: iga kanali väärtus kaalutakse CMF-iga kesk lainepikkusel."""
+    x = y = z = 0.0
+    for name, wavelength in CHANNEL_WAVELENGTHS.items():
+        value = channels.get(name)
+        if value is None:
+            continue
+        cx, cy, cz = cie_xyz_cmf(wavelength)
+        x += value * cx
+        y += value * cy
+        z += value * cz
+    return x, y, z
+
+
+def xyz_to_srgb(x: float, y: float, z: float) -> tuple[int, int, int]:
+    """XYZ -> lineaarne sRGB (D65) -> gamma -> 8-bit. Heledus normaliseeritakse."""
+    r_lin = 3.2406 * x - 1.5372 * y - 0.4986 * z
+    g_lin = -0.9689 * x + 1.8758 * y + 0.0415 * z
+    b_lin = 0.0557 * x - 0.2040 * y + 1.0570 * z
+
+    def gamma(c: float) -> float:
+        c = max(0.0, c)
+        return 12.92 * c if c <= 0.0031308 else 1.055 * c ** (1 / 2.4) - 0.055
+
+    r, g, b = gamma(r_lin), gamma(g_lin), gamma(b_lin)
+    scale = max(r, g, b, 1e-9)
+    return (
+        int(round(255 * r / scale)),
+        int(round(255 * g / scale)),
+        int(round(255 * b / scale)),
+    )
+
+
+def chromaticity_xy(x: float, y: float, z: float) -> tuple[float, float]:
+    total = x + y + z
+    if total <= 0:
+        return 0.0, 0.0
+    return x / total, y / total
+
+
+def analyze(channels: dict[str, float]) -> dict:
+    """Tagastab tuletatud värviväljad ClickHouse'i rea jaoks."""
+    x, y, z = channels_to_xyz(channels)
+    cx, cy = chromaticity_xy(x, y, z)
+    r, g, b = xyz_to_srgb(x, y, z)
+    return {
+        "x_chrom": round(cx, 5),
+        "y_chrom": round(cy, 5),
+        "r": r,
+        "g": g,
+        "b": b,
+        "rgb_hex": f"#{r:02X}{g:02X}{b:02X}",
+    }

+ 87 - 10
dev/nutikodu-web/static/app.js

@@ -541,7 +541,9 @@ function applyAuth(authed) {
   if (prot) prot.hidden = !authed;
   const vp = document.getElementById("varv-power");
   if (vp) vp.hidden = !authed;
-  if (authed) { initVarvPowerButtons(); loadVarvPower(); }
+  const vc = document.getElementById("varv-calib-panel");
+  if (vc) vc.hidden = !authed;
+  if (authed) { initVarvPowerButtons(); loadVarvPower(); initVarvCalibButtons(); loadVarvCalib(); }
   const link = document.getElementById("auth-link");
   if (link) {
     link.href = authed ? "/logout" : "/login";
@@ -676,20 +678,22 @@ function renderVarvCurrent() {
     return;
   }
   const c = varvLatest[0];
-  sw.style.background = c.rgb_hex;
-  if (hex) hex.textContent = c.rgb_hex.toUpperCase();
+  const p = varvPick(c);
+  sw.style.background = p.hex;
+  if (hex) hex.textContent = p.hex.toUpperCase();
   if (meta) meta.innerHTML =
-    `${c.ts}<br>RGB ${c.r} · ${c.g} · ${c.b}<br>` +
-    `x = ${varvFmt(c.x_chrom)} · y = ${varvFmt(c.y_chrom)} · gain ${c.gain}`;
+    `${c.ts}${varvCalibView ? " · kalibreeritud" : ""}<br>` +
+    `x = ${varvFmt(p.x)} · y = ${varvFmt(p.y)} · gain ${c.gain}`;
 }
 
 function renderVarvGrid() {
   const grid = document.getElementById("varv-grid");
   if (!grid) return;
-  grid.innerHTML = varvLatest.map((c, i) =>
-    `<button type="button" class="swatch${i === varvSelected ? " sel" : ""}" ` +
-    `style="background:${c.rgb_hex}" title="${c.ts}  ${c.rgb_hex}" data-i="${i}"></button>`
-  ).join("");
+  grid.innerHTML = varvLatest.map((c, i) => {
+    const p = varvPick(c);
+    return `<button type="button" class="swatch${i === varvSelected ? " sel" : ""}" ` +
+      `style="background:${p.hex}" title="${c.ts}  ${p.hex}" data-i="${i}"></button>`;
+  }).join("");
   grid.querySelectorAll(".swatch").forEach((el) => {
     el.addEventListener("click", () => {
       varvSelected = Number(el.dataset.i) || 0;
@@ -862,6 +866,79 @@ async function setVarvPower(on) {
   }
 }
 
+// --- Värv: kalibreerimine (tume/valge; nupud ainult sisselogitult) ---
+let varvCalibView = false;
+let varvCalibWired = false;
+
+function varvPick(c) {
+  if (varvCalibView && c.rgb_hex_cal) {
+    return { hex: c.rgb_hex_cal, x: c.x_chrom_cal, y: c.y_chrom_cal };
+  }
+  return { hex: c.rgb_hex, x: c.x_chrom, y: c.y_chrom };
+}
+
+function initVarvCalibButtons() {
+  if (varvCalibWired) return;
+  const d = document.getElementById("varv-calib-dark");
+  const w = document.getElementById("varv-calib-white");
+  const t = document.getElementById("varv-calib-view");
+  if (d) d.addEventListener("click", () => captureVarvCalib("dark"));
+  if (w) w.addEventListener("click", () => captureVarvCalib("white"));
+  if (t) t.addEventListener("change", (e) => {
+    varvCalibView = e.target.checked;
+    renderVarvCurrent();
+    renderVarvGrid();
+  });
+  varvCalibWired = true;
+}
+
+function renderVarvCalib(d) {
+  const st = document.getElementById("varv-calib-status");
+  if (st) {
+    const haveD = !!d.dark;
+    const haveW = !!d.white;
+    if (haveD && haveW) { st.textContent = "valmis"; st.className = "badge ok"; }
+    else {
+      st.textContent = `${haveD ? "tume ✓" : "tume ✗"} / ${haveW ? "valge ✓" : "valge ✗"}`;
+      st.className = "badge warn";
+    }
+  }
+  const view = document.getElementById("varv-calib-view");
+  if (view) view.disabled = !(d.dark && d.white);
+}
+
+async function loadVarvCalib() {
+  try {
+    const res = await fetch("/api/varv/calib");
+    if (!res.ok) throw new Error("HTTP " + res.status);
+    renderVarvCalib(await res.json());
+  } catch (e) { console.error(e); }
+}
+
+async function captureVarvCalib(kind) {
+  if (!state.authed) return;
+  const hint = document.getElementById("varv-calib-hint");
+  try {
+    const res = await fetch("/api/varv/calib", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ kind, last: 5 }),
+    });
+    if (!res.ok) throw new Error("HTTP " + res.status);
+    const d = await res.json();
+    if (hint) {
+      hint.hidden = false;
+      hint.textContent = `Salvestatud: ${kind} (${d.rows} mõõtmist).`;
+      setTimeout(() => { hint.hidden = true; }, 4500);
+    }
+    await loadVarvCalib();
+    loadVarv();
+  } catch (e) {
+    console.error(e);
+    if (hint) { hint.hidden = false; hint.textContent = "Salvestamine ebaõnnestus."; }
+  }
+}
+
 window.addEventListener("resize", () => {
   if (chartVarvSpectrum) chartVarvSpectrum.resize();
   if (chartVarvChroma) chartVarvChroma.resize();
@@ -887,7 +964,7 @@ function switchView(view) {
     loadTransport();
   } else if (view === "varv") {
     loadVarv();
-    if (state.authed) loadVarvPower();
+    if (state.authed) { loadVarvPower(); loadVarvCalib(); }
   } else {
     if (chart) chart.resize();
     if (chartEnergy) chartEnergy.resize();

+ 15 - 0
dev/nutikodu-web/static/index.html

@@ -125,6 +125,21 @@
         <div id="varv-power-hint" class="hint" hidden></div>
       </section>
 
+      <section class="panel" id="varv-calib-panel" hidden>
+        <div class="varv-power-row">
+          <h2 class="panel-title" style="margin:0">🎯 Kalibreerimine</h2>
+          <span id="varv-calib-status" class="badge muted">—</span>
+          <div class="varv-power-btns">
+            <button type="button" id="varv-calib-dark" class="btn">Salvesta tume</button>
+            <button type="button" id="varv-calib-white" class="btn">Salvesta valge</button>
+          </div>
+        </div>
+        <label class="chk" style="margin-top:10px;display:inline-flex">
+          <input type="checkbox" id="varv-calib-view" /> <span>Kuva kalibreeritud värvi</span>
+        </label>
+        <div id="varv-calib-hint" class="hint" hidden></div>
+      </section>
+
       <section class="panel">
         <div class="varv-current">
           <div id="varv-current-swatch" class="varv-current-swatch"></div>