Parcourir la source

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 il y a 1 semaine
Parent
commit
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 \
  && pip install --no-cache-dir opentelemetry-distro opentelemetry-exporter-otlp-proto-grpc \
  && opentelemetry-bootstrap -a install
  && opentelemetry-bootstrap -a install
 
 
-COPY app.py .
+COPY app.py color.py .
 COPY static ./static
 COPY static ./static
 
 
 EXPOSE 8095
 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.
   (viimased 24 h tunnipõhiselt) ja kromaatilisuse (x, y) hajuvus. 20 s värskendus.
   **Sisselogitult** saab mõõtmise sisse/välja lülitada (MQTT,
   **Sisselogitult** saab mõõtmise sisse/välja lülitada (MQTT,
   `varvus/switch/sensor`); OFF peatab ESP32 publitseerimise → **ka ClickHouse'i
   `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`.
 - PWA-manifest (installitav telefonis), `noindex`.
 
 
 ## API
 ## 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/history?hours=24` | tunnipõhine keskmine värv (värvitelg) — avalik |
 | `GET /api/varv/power` | 🔒 värvisensori mõõtmise olek (ON/OFF) |
 | `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}`) |
 | `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 /api/me` | kas päring on autenditud (`{"authed":bool}`) |
 | `GET /health` | tervis |
 | `GET /health` | tervis |
 
 

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

@@ -31,6 +31,7 @@ from datetime import datetime, timedelta
 from pathlib import Path
 from pathlib import Path
 from zoneinfo import ZoneInfo
 from zoneinfo import ZoneInfo
 
 
+import color
 import paho.mqtt.client as mqtt
 import paho.mqtt.client as mqtt
 import requests
 import requests
 from fastapi import Body, Depends, FastAPI, Form, HTTPException, Query, Request
 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_MQTT_PASS = os.environ.get("VARV_MQTT_PASS", "")
 VARV_CMD_TOPIC = os.environ.get("VARV_CMD_TOPIC", "varvus/switch/sensor/command")
 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_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_ACCESS_TOKEN = os.environ.get("WEB_ACCESS_TOKEN", "")
 WEB_PASSWORD = os.environ.get("WEB_PASSWORD", "")
 WEB_PASSWORD = os.environ.get("WEB_PASSWORD", "")
@@ -546,12 +550,37 @@ async def uus67_latest() -> dict:
 
 
 # --- Värvisensor (uus67.varvus, AS7341) ---
 # --- 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")
 @app.get("/api/varv/latest")
 async def varv_latest(limit: int = Query(60, ge=1, le=300)) -> dict:
 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)
     cols = ", ".join(VARV_BANDS)
     rows = ch_query(
     rows = ch_query(
         f"SELECT toString(ts) AS ts, toUInt64(toUnixTimestamp(ts)) * 1000 AS t, "
         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"{cols}, clear, nir, gain "
         f"FROM {CH_DB}.varvus ORDER BY ts DESC LIMIT {int(limit)}"
         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 {
     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")
 @app.get("/api/varv/history")
 async def varv_history(hours: int = Query(24, ge=1, le=168)) -> dict:
 async def varv_history(hours: int = Query(24, ge=1, le=168)) -> dict:
     """Värvitelg: tunnipõhine keskmine värv viimaste tundide jooksul (avalik)."""
     """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;
   if (prot) prot.hidden = !authed;
   const vp = document.getElementById("varv-power");
   const vp = document.getElementById("varv-power");
   if (vp) vp.hidden = !authed;
   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");
   const link = document.getElementById("auth-link");
   if (link) {
   if (link) {
     link.href = authed ? "/logout" : "/login";
     link.href = authed ? "/logout" : "/login";
@@ -676,20 +678,22 @@ function renderVarvCurrent() {
     return;
     return;
   }
   }
   const c = varvLatest[0];
   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 =
   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() {
 function renderVarvGrid() {
   const grid = document.getElementById("varv-grid");
   const grid = document.getElementById("varv-grid");
   if (!grid) return;
   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) => {
   grid.querySelectorAll(".swatch").forEach((el) => {
     el.addEventListener("click", () => {
     el.addEventListener("click", () => {
       varvSelected = Number(el.dataset.i) || 0;
       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", () => {
 window.addEventListener("resize", () => {
   if (chartVarvSpectrum) chartVarvSpectrum.resize();
   if (chartVarvSpectrum) chartVarvSpectrum.resize();
   if (chartVarvChroma) chartVarvChroma.resize();
   if (chartVarvChroma) chartVarvChroma.resize();
@@ -887,7 +964,7 @@ function switchView(view) {
     loadTransport();
     loadTransport();
   } else if (view === "varv") {
   } else if (view === "varv") {
     loadVarv();
     loadVarv();
-    if (state.authed) loadVarvPower();
+    if (state.authed) { loadVarvPower(); loadVarvCalib(); }
   } else {
   } else {
     if (chart) chart.resize();
     if (chart) chart.resize();
     if (chartEnergy) chartEnergy.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>
         <div id="varv-power-hint" class="hint" hidden></div>
       </section>
       </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">
       <section class="panel">
         <div class="varv-current">
         <div class="varv-current">
           <div id="varv-current-swatch" class="varv-current-swatch"></div>
           <div id="varv-current-swatch" class="varv-current-swatch"></div>