Browse Source

nutikodu-web: Varv — varvikaardi (CCM) kalibratsioon (3x8 maatriks)

- POST /api/varv/ref (require_auth): salvesta referentsvarv ekraanilt

- POST /api/varv/ccm/fit: sobi 3x8 maatriks (kanalid -> lineaarne sRGB), salvesta

- UI: CCM paneel (8 varvi nupud + Arvuta maatriks); rgb_hex_ccm kuvatakse kalibreeritud vaates
ardo 1 week ago
parent
commit
fdfb1bb3ce

+ 131 - 1
dev/nutikodu-web/app.py

@@ -32,6 +32,7 @@ from pathlib import Path
 from zoneinfo import ZoneInfo
 from zoneinfo import ZoneInfo
 
 
 import color
 import color
+import numpy as np
 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
@@ -61,6 +62,8 @@ VARV_STATE_TOPIC = os.environ.get("VARV_STATE_TOPIC", "varvus/switch/sensor/stat
 VARV_BANDS = ("f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8")
 VARV_BANDS = ("f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8")
 VARV_CALIB_FIELDS = VARV_BANDS + ("clear", "nir")
 VARV_CALIB_FIELDS = VARV_BANDS + ("clear", "nir")
 VARV_CALIB_TABLE = os.environ.get("VARV_CALIB_TABLE", "varvus_calib")
 VARV_CALIB_TABLE = os.environ.get("VARV_CALIB_TABLE", "varvus_calib")
+VARV_REF_TABLE = os.environ.get("VARV_REF_TABLE", "varvus_ccm_ref")
+VARV_CCM_TABLE = os.environ.get("VARV_CCM_TABLE", "varvus_ccm")
 
 
 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", "")
@@ -578,6 +581,130 @@ def varv_reflectance(channels: dict, dark: dict, white: dict) -> dict:
     return out
     return out
 
 
 
 
+# --- Värvikaardi (CCM) kalibratsioon: 3x8 maatriks kanalitest -> lineaarne sRGB ---
+
+
+def varv_load_ccm() -> list[list[float]] | None:
+    try:
+        rows = ch_query(
+            f"SELECT matrix FROM {CH_DB}.{VARV_CCM_TABLE} FINAL "
+            f"ORDER BY updated_at DESC LIMIT 1"
+        )
+    except HTTPException:
+        return None
+    if not rows:
+        return None
+    try:
+        m = json.loads(rows[0]["matrix"])
+        if len(m) == 3 and all(len(row) == len(VARV_BANDS) for row in m):
+            return [[float(x) for x in row] for row in m]
+    except Exception:
+        return None
+    return None
+
+
+def _srgb_to_linear(v: float) -> float:
+    c = v / 255.0
+    return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
+
+
+def _linear_to_srgb8(c: float) -> int:
+    c = max(0.0, min(1.0, c))
+    v = 12.92 * c if c <= 0.0031308 else 1.055 * (c ** (1 / 2.4)) - 0.055
+    return int(round(255 * max(0.0, min(1.0, v))))
+
+
+def varv_apply_ccm(matrix: list[list[float]], channels: dict) -> str:
+    vec = np.array([float(channels.get(b) or 0.0) for b in VARV_BANDS], dtype=float)
+    lin = np.array(matrix, dtype=float) @ vec
+    r = _linear_to_srgb8(float(lin[0]))
+    g = _linear_to_srgb8(float(lin[1]))
+    b = _linear_to_srgb8(float(lin[2]))
+    return f"#{r:02X}{g:02X}{b:02X}"
+
+
+@app.post("/api/varv/ref", dependencies=[Depends(require_auth)])
+async def varv_ref_set(payload: dict = Body(...)) -> dict:
+    """Salvesta referentsvärv (ekraanil kuvatud) CCM-i jaoks."""
+    name = str(payload.get("name", "")).strip().lower()
+    hexv = str(payload.get("hex", "")).strip().lstrip("#")
+    if len(hexv) != 6:
+        raise HTTPException(status_code=422, detail="hex peab olema 6-kohaline (nt FF0000)")
+    try:
+        tr, tg, tb = int(hexv[0:2], 16), int(hexv[2:4], 16), int(hexv[4:6], 16)
+    except ValueError:
+        raise HTTPException(status_code=422, detail="vigane hex")
+    last = max(1, min(int(payload.get("last", 5)), 30))
+    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 = {"name": name, "tr": tr, "tg": tg, "tb": tb, **ref}
+    resp = requests.post(
+        f"{CH_URL}/",
+        params={"database": CH_DB, "query": f"INSERT INTO {CH_DB}.{VARV_REF_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 {"name": name, "target": hexv.upper(), "reference": ref, "rows": len(rows)}
+
+
+@app.get("/api/varv/ref")
+async def varv_ref_get() -> dict:
+    rows = ch_query(
+        f"SELECT name, tr, tg, tb, {', '.join(VARV_CALIB_FIELDS)} "
+        f"FROM {CH_DB}.{VARV_REF_TABLE} FINAL ORDER BY name"
+    )
+    return {"refs": rows}
+
+
+@app.post("/api/varv/ccm/fit", dependencies=[Depends(require_auth)])
+async def varv_ccm_fit() -> dict:
+    """Sobita 3x8 maatriks (kanalid -> lineaarne sRGB) referentsidest."""
+    rows = ch_query(
+        f"SELECT name, tr, tg, tb, {', '.join(VARV_BANDS)} "
+        f"FROM {CH_DB}.{VARV_REF_TABLE} FINAL ORDER BY name"
+    )
+    if len(rows) < 3:
+        raise HTTPException(status_code=422, detail="Vaja vähemalt 3 referentsvärvi (soovitav 6-8).")
+    C = np.array([[float(r[b] or 0.0) for b in VARV_BANDS] for r in rows], dtype=float)
+    T = np.array(
+        [[_srgb_to_linear(float(r["tr"])), _srgb_to_linear(float(r["tg"])), _srgb_to_linear(float(r["tb"]))]
+         for r in rows],
+        dtype=float,
+    )
+    M, *_ = np.linalg.lstsq(C, T, rcond=None)  # (8,3)
+    matrix = M.T.tolist()  # (3,8)
+    resp = requests.post(
+        f"{CH_URL}/",
+        params={
+            "database": CH_DB,
+            "query": f"INSERT INTO {CH_DB}.{VARV_CCM_TABLE} (id, matrix, n_refs) FORMAT JSONEachRow",
+        },
+        data=json.dumps({"id": 1, "matrix": json.dumps(matrix), "n_refs": len(rows)}) + "\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 {"n_refs": len(rows), "matrix": matrix}
+
+
+@app.get("/api/varv/ccm")
+async def varv_ccm_get() -> dict:
+    m = varv_load_ccm()
+    return {"matrix": m, "ready": m is not None}
+
+
 @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; kalibreeritud väljad kui referentsid olemas)."""
     """Värvisensori viimased mõõtmised (avalik; kalibreeritud väljad kui referentsid olemas)."""
@@ -591,6 +718,7 @@ async def varv_latest(limit: int = Query(60, ge=1, le=300)) -> dict:
     calib = varv_load_calib()
     calib = varv_load_calib()
     dark, white = calib.get("dark"), calib.get("white")
     dark, white = calib.get("dark"), calib.get("white")
     ready = bool(dark and white)
     ready = bool(dark and white)
+    ccm = varv_load_ccm()
     out = []
     out = []
     for r in rows:
     for r in rows:
         bands = {b: r.get(b) for b in VARV_BANDS}
         bands = {b: r.get(b) for b in VARV_BANDS}
@@ -614,8 +742,10 @@ async def varv_latest(limit: int = Query(60, ge=1, le=300)) -> dict:
             item["rgb_hex_cal"] = cc["rgb_hex"]
             item["rgb_hex_cal"] = cc["rgb_hex"]
             item["x_chrom_cal"] = cc["x_chrom"]
             item["x_chrom_cal"] = cc["x_chrom"]
             item["y_chrom_cal"] = cc["y_chrom"]
             item["y_chrom_cal"] = cc["y_chrom"]
+        if ccm:
+            item["rgb_hex_ccm"] = varv_apply_ccm(ccm, bands)
         out.append(item)
         out.append(item)
-    return {"rows": out, "calibrated": ready}
+    return {"rows": out, "calibrated": ready, "ccm": ccm is not None}
 
 
 
 
 @app.get("/api/varv/calib")
 @app.get("/api/varv/calib")

+ 1 - 0
dev/nutikodu-web/requirements.txt

@@ -4,3 +4,4 @@ requests
 itsdangerous
 itsdangerous
 python-multipart
 python-multipart
 paho-mqtt>=2.0,<3
 paho-mqtt>=2.0,<3
+numpy

+ 88 - 3
dev/nutikodu-web/static/app.js

@@ -543,7 +543,9 @@ function applyAuth(authed) {
   if (vp) vp.hidden = !authed;
   if (vp) vp.hidden = !authed;
   const vc = document.getElementById("varv-calib-panel");
   const vc = document.getElementById("varv-calib-panel");
   if (vc) vc.hidden = !authed;
   if (vc) vc.hidden = !authed;
-  if (authed) { initVarvPowerButtons(); loadVarvPower(); initVarvCalibButtons(); loadVarvCalib(); }
+  const vccm = document.getElementById("varv-ccm-panel");
+  if (vccm) vccm.hidden = !authed;
+  if (authed) { initVarvPowerButtons(); loadVarvPower(); initVarvCalibButtons(); loadVarvCalib(); initVarvCcmButtons(); loadVarvCcm(); }
   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";
@@ -904,6 +906,9 @@ function varvBandSum(c) {
 
 
 function varvPick(c) {
 function varvPick(c) {
   if (varvBandSum(c) < 250) return { hex: "#33383f", x: null, y: null, dark: true };
   if (varvBandSum(c) < 250) return { hex: "#33383f", x: null, y: null, dark: true };
+  if (varvCalibView && c.rgb_hex_ccm) {
+    return { hex: c.rgb_hex_ccm, x: c.x_chrom_cal, y: c.y_chrom_cal };
+  }
   if (varvCalibView && c.rgb_hex_cal) {
   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_cal, x: c.x_chrom_cal, y: c.y_chrom_cal };
   }
   }
@@ -1006,6 +1011,86 @@ async function captureVarvCalib(kind) {
   }
   }
 }
 }
 
 
+// --- Värv: CCM (värvikaart) kalibratsioon ---
+let varvCcmWired = false;
+
+function initVarvCcmButtons() {
+  if (varvCcmWired) return;
+  document.querySelectorAll("[data-ref]").forEach((btn) => {
+    btn.addEventListener("click", () => captureVarvRef(btn.dataset.ref, btn.dataset.hex));
+  });
+  const fit = document.getElementById("varv-ccm-fit");
+  if (fit) fit.addEventListener("click", fitVarvCcm);
+  varvCcmWired = true;
+}
+
+function renderVarvCcm(ccm, refs) {
+  const st = document.getElementById("varv-ccm-status");
+  if (st) {
+    if (ccm && ccm.ready) { st.textContent = "maatriks olemas"; st.className = "badge ok"; }
+    else { st.textContent = "maatriks puudub"; st.className = "badge warn"; }
+  }
+  const list = document.getElementById("varv-ccm-list");
+  const names = (refs && refs.refs ? refs.refs : []).map((r) => r.name);
+  if (list) list.textContent = names.length ? "Salvestatud: " + names.join(", ") : "Ühtegi referentsi pole veel.";
+}
+
+async function loadVarvCcm() {
+  try {
+    const [r1, r2] = await Promise.all([fetch("/api/varv/ccm"), fetch("/api/varv/ref")]);
+    if (!r1.ok || !r2.ok) return;
+    renderVarvCcm(await r1.json(), await r2.json());
+  } catch (e) { console.error(e); }
+}
+
+async function captureVarvRef(name, hex) {
+  if (!state.authed) return;
+  const hint = document.getElementById("varv-ccm-hint");
+  try {
+    const res = await fetch("/api/varv/ref", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ name, hex, last: 5 }),
+    });
+    if (!res.ok) {
+      let msg = "Salvestamine ebaõnnestus.";
+      try {
+        const e = await res.json();
+        if (typeof e.detail === "string") msg = e.detail;
+      } catch (ignored) { /* ignore */ }
+      throw new Error(msg);
+    }
+    if (hint) hint.textContent = `Salvestatud: ${name} (${hex})`;
+    loadVarvCcm();
+  } catch (e) {
+    console.error(e);
+    if (hint) hint.textContent = e.message || "Viga";
+  }
+}
+
+async function fitVarvCcm() {
+  if (!state.authed) return;
+  const hint = document.getElementById("varv-ccm-hint");
+  try {
+    const res = await fetch("/api/varv/ccm/fit", { method: "POST" });
+    if (!res.ok) {
+      let msg = "Sobitamine ebaõnnestus.";
+      try {
+        const e = await res.json();
+        if (typeof e.detail === "string") msg = e.detail;
+      } catch (ignored) { /* ignore */ }
+      throw new Error(msg);
+    }
+    const d = await res.json();
+    if (hint) hint.textContent = `Maatriks arvutatud (${d.n_refs} värvi).`;
+    loadVarvCcm();
+    loadVarv();
+  } catch (e) {
+    console.error(e);
+    if (hint) hint.textContent = e.message || "Viga";
+  }
+}
+
 window.addEventListener("resize", () => {
 window.addEventListener("resize", () => {
   if (chartVarvSpectrum) chartVarvSpectrum.resize();
   if (chartVarvSpectrum) chartVarvSpectrum.resize();
   if (chartVarvChroma) chartVarvChroma.resize();
   if (chartVarvChroma) chartVarvChroma.resize();
@@ -1031,7 +1116,7 @@ function switchView(view) {
     loadTransport();
     loadTransport();
   } else if (view === "varv") {
   } else if (view === "varv") {
     loadVarv();
     loadVarv();
-    if (state.authed) { loadVarvPower(); loadVarvCalib(); }
+    if (state.authed) { loadVarvPower(); loadVarvCalib(); loadVarvCcm(); }
   } else {
   } else {
     if (chart) chart.resize();
     if (chart) chart.resize();
     if (chartEnergy) chartEnergy.resize();
     if (chartEnergy) chartEnergy.resize();
@@ -1058,7 +1143,7 @@ function initTabs() {
     if (transport.active) loadTransport();
     if (transport.active) loadTransport();
   }, 60000);
   }, 60000);
   setInterval(() => {
   setInterval(() => {
-    if (varvActive) { loadVarv(); if (state.authed) loadVarvCalib(); }
+    if (varvActive) { loadVarv(); if (state.authed) { loadVarvCalib(); loadVarvCcm(); } }
   }, 20000);
   }, 20000);
   switchView(VIEWS.includes(location.hash.slice(1)) ? location.hash.slice(1) : "andurid");
   switchView(VIEWS.includes(location.hash.slice(1)) ? location.hash.slice(1) : "andurid");
 }
 }

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

@@ -145,6 +145,32 @@
         <div id="varv-calib-hint" class="hint" hidden></div>
         <div id="varv-calib-hint" class="hint" hidden></div>
       </section>
       </section>
 
 
+      <section class="panel" id="varv-ccm-panel" hidden>
+        <div class="varv-power-row">
+          <h2 class="panel-title" style="margin:0">🎯 Värvikaardi (CCM) kalibratsioon</h2>
+          <span id="varv-ccm-status" class="badge muted">—</span>
+        </div>
+        <div class="varv-sub" style="margin-top:6px">
+          Kuva ekraanil üks värv korraga (täisekraanil) ja vajuta vastavat nuppu — mõõdetakse viimased 5.
+          Soovitav: punane, roheline, sinine, valge, must, kollane, tsüaan, magenta.
+        </div>
+        <div class="varv-power-btns" style="margin:8px 0; justify-content:flex-start; flex-wrap:wrap">
+          <button type="button" class="btn" data-ref="red" data-hex="FF0000">Punane</button>
+          <button type="button" class="btn" data-ref="green" data-hex="00FF00">Roheline</button>
+          <button type="button" class="btn" data-ref="blue" data-hex="0000FF">Sinine</button>
+          <button type="button" class="btn" data-ref="white" data-hex="FFFFFF">Valge</button>
+          <button type="button" class="btn" data-ref="black" data-hex="000000">Must</button>
+          <button type="button" class="btn" data-ref="yellow" data-hex="FFFF00">Kollane</button>
+          <button type="button" class="btn" data-ref="cyan" data-hex="00FFFF">Tsüaan</button>
+          <button type="button" class="btn" data-ref="magenta" data-hex="FF00FF">Magenta</button>
+        </div>
+        <div class="varv-power-row">
+          <button type="button" id="varv-ccm-fit" class="btn">Arvuta maatriks</button>
+          <span id="varv-ccm-hint" class="varv-sub"></span>
+        </div>
+        <div id="varv-ccm-list" class="varv-sub" style="margin-top:6px"></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>