Переглянути джерело

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 тиждень тому
батько
коміт
fdfb1bb3ce

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

@@ -32,6 +32,7 @@ from pathlib import Path
 from zoneinfo import ZoneInfo
 
 import color
+import numpy as np
 import paho.mqtt.client as mqtt
 import requests
 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_CALIB_FIELDS = VARV_BANDS + ("clear", "nir")
 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_PASSWORD = os.environ.get("WEB_PASSWORD", "")
@@ -578,6 +581,130 @@ def varv_reflectance(channels: dict, dark: dict, white: dict) -> dict:
     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")
 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)."""
@@ -591,6 +718,7 @@ async def varv_latest(limit: int = Query(60, ge=1, le=300)) -> dict:
     calib = varv_load_calib()
     dark, white = calib.get("dark"), calib.get("white")
     ready = bool(dark and white)
+    ccm = varv_load_ccm()
     out = []
     for r in rows:
         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["x_chrom_cal"] = cc["x_chrom"]
             item["y_chrom_cal"] = cc["y_chrom"]
+        if ccm:
+            item["rgb_hex_ccm"] = varv_apply_ccm(ccm, bands)
         out.append(item)
-    return {"rows": out, "calibrated": ready}
+    return {"rows": out, "calibrated": ready, "ccm": ccm is not None}
 
 
 @app.get("/api/varv/calib")

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

@@ -4,3 +4,4 @@ requests
 itsdangerous
 python-multipart
 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;
   const vc = document.getElementById("varv-calib-panel");
   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");
   if (link) {
     link.href = authed ? "/logout" : "/login";
@@ -904,6 +906,9 @@ function varvBandSum(c) {
 
 function varvPick(c) {
   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) {
     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", () => {
   if (chartVarvSpectrum) chartVarvSpectrum.resize();
   if (chartVarvChroma) chartVarvChroma.resize();
@@ -1031,7 +1116,7 @@ function switchView(view) {
     loadTransport();
   } else if (view === "varv") {
     loadVarv();
-    if (state.authed) { loadVarvPower(); loadVarvCalib(); }
+    if (state.authed) { loadVarvPower(); loadVarvCalib(); loadVarvCcm(); }
   } else {
     if (chart) chart.resize();
     if (chartEnergy) chartEnergy.resize();
@@ -1058,7 +1143,7 @@ function initTabs() {
     if (transport.active) loadTransport();
   }, 60000);
   setInterval(() => {
-    if (varvActive) { loadVarv(); if (state.authed) loadVarvCalib(); }
+    if (varvActive) { loadVarv(); if (state.authed) { loadVarvCalib(); loadVarvCcm(); } }
   }, 20000);
   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>
       </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">
         <div class="varv-current">
           <div id="varv-current-swatch" class="varv-current-swatch"></div>