From 351739ec36f8ae161500e8051f518f17111e54cb Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Fri, 23 Oct 2026 22:43:32 +0200 Subject: [PATCH] Acquisitore: calibrazione a scacchiera per sorgente, rotte e pagina /calibrazione (#83) Co-Authored-By: Claude Sonnet 5.5 --- scripts/calibrazione.py | 144 +++++++++++++++++++ scripts/calibrazione_pagina.py | 218 +++++++++++++++++++++++++++++ scripts/camera_service.py | 135 +++++++++++++++--- scripts/tests/test_calibrazione.py | 184 ++++++++++++++++++++++++ 4 files changed, 660 insertions(+), 21 deletions(-) create mode 100644 scripts/calibrazione.py create mode 100644 scripts/calibrazione_pagina.py create mode 100644 scripts/tests/test_calibrazione.py diff --git a/scripts/calibrazione.py b/scripts/calibrazione.py new file mode 100644 index 0000000..76abd81 --- /dev/null +++ b/scripts/calibrazione.py @@ -0,0 +1,144 @@ +"""Calibrazione a scacchiera dell'acquisitore (TieMeasureFlow, #83). + +Cosa fa: da una foto di scacchiera piana calcola la scala (px/mm) con +TieVision-Core (visionsuite.calibration), e conserva un risultato per sorgente +camera in un JSON (scrittura atomica). +Uso: importato da scripts/camera_service.py (rotte /camera/calibrate e +/camera/calibration); `calibra_scacchiera(gray, cols, rows, square_mm)` e' +logica pura, senza FastAPI, e si prova da sola. +Dipende da: numpy, opencv, visionsuite (vs-core). Niente altro. +""" +from __future__ import annotations + +import base64 +import hashlib +import json +import math +import os +import re +import tempfile +from datetime import datetime, timezone +from pathlib import Path + +import cv2 +import numpy as np + +from visionsuite.calibration import calibrate_planar, find_chessboard +from visionsuite.core.image import Image + + +class CalibrazioneError(ValueError): + """Parametri invalidi o scacchiera non trovata: input sbagliato, non un guasto (422).""" + + +def _gray_u8(pixels: np.ndarray) -> np.ndarray: + """Immagine -> grigio uint8 2D.""" + px = np.asarray(pixels) + if px.ndim == 3: + px = cv2.cvtColor(px, cv2.COLOR_BGRA2GRAY if px.shape[2] == 4 else cv2.COLOR_BGR2GRAY) + if px.dtype != np.uint8: + # float/uint16 -> scala sul massimo, come fa vs-core + m = float(px.max()) if px.size else 1.0 + px = np.clip(px.astype(np.float32) / max(m, 1e-9) * 255, 0, 255).astype(np.uint8) + return np.ascontiguousarray(px) + + +def calibra_scacchiera(pixels, cols: int, rows: int, square_mm: float) -> dict: + """Scala (px/mm) da una foto di scacchiera piana. cols/rows = angoli INTERNI. + + Ritorna {px_per_mm, mm_per_px, rms_px, n_corners, cols, rows, square_mm, + image_width, image_height, scale_spread_pct, overlay_png (b64)}. + """ + if cols < 2 or rows < 2: + raise CalibrazioneError("cols e rows (angoli interni) devono essere almeno 2") + if not (square_mm > 0 and math.isfinite(square_mm)): + raise CalibrazioneError("square_mm deve essere > 0") + + frame = _gray_u8(pixels) + image = Image(frame) + try: + image_pts, world_pts = find_chessboard(image, (cols, rows), square_mm) + except RuntimeError as exc: + # find_chessboard solleva RuntimeError quando la scacchiera non c'e' + raise CalibrazioneError( + f"scacchiera {cols}x{rows} non trovata nell'immagine: {exc}") from exc + model = calibrate_planar(image, image_pts, world_pts) # omografia pura + + # Scala al centro della scacchiera (puo' non stare al centro immagine) + x0, y0 = image_pts.min(axis=0) + x1, y1 = image_pts.max(axis=0) + mm_per_px = model.pixel_size((x0 + x1) / 2.0, (y0 + y1) / 2.0) + if not (mm_per_px > 0 and math.isfinite(mm_per_px)): + raise CalibrazioneError("calibrazione degenere: scala non valida") + # Scarto massimo % della scala ai quattro angoli dell'area della scacchiera + # rispetto al centro: ottica telecentrica/piana ~ 0, prospettiva = grande. + spread = max(abs(model.pixel_size(x, y) - mm_per_px) / mm_per_px * 100.0 + for x in (x0, x1) for y in (y0, y1)) + + # Overlay: angoli disegnati sull'immagine (PNG base64) + bgr = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR) + cv2.drawChessboardCorners( + bgr, (cols, rows), image_pts.reshape(-1, 1, 2).astype(np.float32), True) + ok, png = cv2.imencode(".png", bgr) + if not ok: + raise RuntimeError("PNG dell'overlay non codificabile") + + h, w = frame.shape + return { + "px_per_mm": 1.0 / mm_per_px, + "mm_per_px": mm_per_px, + "rms_px": float(model.rms_reprojection), + "n_corners": int(len(image_pts)), + "cols": int(cols), + "rows": int(rows), + "square_mm": float(square_mm), + "image_width": int(w), + "image_height": int(h), + "scale_spread_pct": float(spread), + "overlay_png": base64.b64encode(png.tobytes()).decode("ascii"), + } + + +def ora_iso() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +class Archivio: + """Un JSON per sorgente in `cartella`; nome file dalla sorgente ripulito.""" + + def __init__(self, cartella: Path | str): + self.cartella = Path(cartella) + + def _file(self, sorgente: str) -> Path: + # caratteri sicuri + hash dell'originale: due sorgenti diverse non si pestano + pulito = re.sub(r"[^A-Za-z0-9._-]+", "_", sorgente).strip("._") or "sorgente" + sigla = hashlib.sha1(sorgente.encode("utf-8")).hexdigest()[:8] + return self.cartella / f"{pulito[:60]}-{sigla}.json" + + def leggi(self, sorgente: str) -> dict | None: + try: + return json.loads(self._file(sorgente).read_text(encoding="utf-8")) + except (OSError, ValueError): + return None # assente o illeggibile = non calibrata + + def salva(self, sorgente: str, dati: dict) -> None: + destinazione = self._file(sorgente) + self.cartella.mkdir(parents=True, exist_ok=True) + # scrittura atomica: file temporaneo nella stessa cartella, poi replace + fd, tmp = tempfile.mkstemp(dir=self.cartella, suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(dati, f, ensure_ascii=False, indent=2) + os.replace(tmp, destinazione) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + + def elimina(self, sorgente: str) -> None: + try: + self._file(sorgente).unlink() + except FileNotFoundError: + pass diff --git a/scripts/calibrazione_pagina.py b/scripts/calibrazione_pagina.py new file mode 100644 index 0000000..33a9409 --- /dev/null +++ b/scripts/calibrazione_pagina.py @@ -0,0 +1,218 @@ +"""Pagina HTML della calibrazione a scacchiera (servita da camera_service su /calibrazione, #83). + +Cosa fa: una pagina sola, autonoma e offline (niente CDN, JS e CSS inline), in +italiano. Parla con le rotte dello stesso servizio (/camera/sources, +/camera/calibration, /camera/calibrate). +Dipende da: niente. E' una stringa, cosi' il pacchetto compilato non ha file dati. +""" + +PAGINA = """ + + + + +Calibrazione camera + + + +
+

Calibrazione camera con scacchiera

+ +
+
+
+
+
+
+
+

+ Gli angoli interni sono quelli dove si toccano quattro quadrati: una scacchiera di 10 x 7 quadrati ha 9 x 6 angoli. + Tenere la scacchiera piana e ferma, ben illuminata.

+
+ +
+
+ +
+ +
+
+
+ + + +
+ Calibrazione salvata +
-
+
+ +
+
+
+ + + + +""" diff --git a/scripts/camera_service.py b/scripts/camera_service.py index 15b8f50..ad4e894 100644 --- a/scripts/camera_service.py +++ b/scripts/camera_service.py @@ -6,17 +6,33 @@ OpenCV, cartella di immagini) con lo stesso contratto di `vs-gui`, ma solo GET /camera/sources -> {"sources": [{name, kind, label}]} POST /camera/grab json {source, trigger="continuo", timeout_ms=5000} -> PNG Errori: 400 parametri, 404 sorgente inesistente, 503 guasto camera, 504 timeout. + +Calibrazione a scacchiera per sorgente (#83, logica in scripts/calibrazione.py): + GET /camera/calibration?source= -> 200 {source, px_per_mm, mm_per_px, + rms_px, n_corners, cols, rows, square_mm, image_width, image_height, + scale_spread_pct, calibrated_at} | 404 "sorgente non calibrata" + POST /camera/calibrate multipart: source, cols, rows (angoli interni), + square_mm, image (file, opzionale: senza, grab dalla sorgente come + /camera/grab), save=false -> campi di sopra + overlay_png (b64) + saved; + 422 scacchiera non trovata o parametri invalidi + DELETE /camera/calibration?source= -> 204 + GET /calibrazione -> pagina HTML autonoma (offline) per fare tutto a mano +Il risultato salvato sta in un JSON per sorgente in --config-dir (default +%ProgramData%/Tielogic/TieMeasureFlow/acquisitore su Windows, ./acquisitore altrove). + Serve perche' vs-gui ascolta solo su 127.0.0.1 e il container `client` non lo raggiunge; questo servizio permette di scegliere l'host di ascolto. Uso (dalla radice del repo): vendor/visionsuite/.venv/bin/python scripts/camera_service.py \ - [--host 127.0.0.1] [--port 8765] [--folder ] + [--host 127.0.0.1] [--port 8765] [--folder ] \ + [--config-dir ] --folder abilita la sorgente finta `camera:folder` (loop sulle immagini). Da Docker su Linux: --host 172.17.0.1 (gateway di docker0). Dipende da: venv di VisionSuite (`uv sync --all-packages` in vendor/visionsuite): -vs-camera, fastapi, uvicorn, opencv. +vs-camera, fastapi, uvicorn, opencv, python-multipart (rotta /camera/calibrate), +visionsuite.calibration (vs-core). AVVERTENZA: nessuna autenticazione. Un --host diverso dal loopback espone la camera a chiunque raggiunga la porta: solo su rete isolata. @@ -24,17 +40,26 @@ camera a chiunque raggiunga la porta: solo su rete isolata. from __future__ import annotations import argparse +import os +import sys import threading from contextlib import asynccontextmanager from pathlib import Path import cv2 -from fastapi import Body, FastAPI, HTTPException -from fastapi.responses import Response +import numpy as np +from fastapi import Body, FastAPI, File, Form, HTTPException, UploadFile +from fastapi.concurrency import run_in_threadpool # #83: il grab bloccante fuori dall'event loop +from fastapi.responses import HTMLResponse, Response from visionsuite.camera.camera_interface import CameraError, CameraGrabError from visionsuite.camera.sources import SourceNotFound, list_sources, open_source +# #83: calibrazione e pagina stanno accanto a questo file (che non e' un pacchetto) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import calibrazione # noqa: E402 +from calibrazione_pagina import PAGINA # noqa: E402 + _TRIGGER = ("continuo",) # errori noti di vs-camera (inglese) -> italiano; esatti, poi per prefisso @@ -61,7 +86,18 @@ def _traduci(messaggio: str) -> str: return messaggio -def create_app(folder: Path | str | None = None) -> FastAPI: +def _config_dir_default() -> Path: + """#83: cartella delle calibrazioni salvate.""" + if os.name == "nt": + base = os.environ.get("ProgramData") or "C:/ProgramData" + return Path(base) / "Tielogic" / "TieMeasureFlow" / "acquisitore" + return Path("acquisitore") + + +def create_app(folder: Path | str | None = None, + config_dir: Path | str | None = None) -> FastAPI: + archivio = calibrazione.Archivio(config_dir if config_dir is not None + else _config_dir_default()) # #83 aperte: dict = {} lock = threading.Lock() # protegge SOLO i dizionari, mai un'apertura lock_sorgenti: dict = {} # nome sorgente -> Lock che serializza apertura e grab @@ -95,6 +131,24 @@ def create_app(folder: Path | str | None = None) -> FastAPI: return {"sources": [{"name": v.name, "kind": v.kind, "label": v.label} for v in voci if v.kind in ("camera", "virtual")]} + def _grab(nome: str, timeout: int): + """Un frame dalla sorgente (apre e tiene aperta); errori camera -> HTTP.""" + try: + with _lock_di(nome): # solo questa sorgente; le altre non aspettano + with lock: + sorgente = aperte.get(nome) + if sorgente is None: + sorgente = open_source(nome, None, config=_config()) + with lock: + aperte[nome] = sorgente + return sorgente.grab(timeout) + except SourceNotFound as e: + raise HTTPException(404, _traduci(str(e))) + except TimeoutError as e: + raise HTTPException(504, _traduci(str(e)) or "timeout") + except (CameraGrabError, CameraError) as e: + raise HTTPException(503, _traduci(str(e))) + @app.post("/camera/grab") def camera_grab(corpo: dict = Body(default=None)): nome = corpo.get("source") if isinstance(corpo, dict) else None @@ -107,26 +161,63 @@ def create_app(folder: Path | str | None = None) -> FastAPI: timeout = corpo.get("timeout_ms", 5000) if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 10000: raise HTTPException(400, "«timeout_ms» deve essere un intero fra 1 e 10000") - try: - with _lock_di(nome): # solo questa sorgente; le altre non aspettano - with lock: - sorgente = aperte.get(nome) - if sorgente is None: - sorgente = open_source(nome, None, config=_config()) - with lock: - aperte[nome] = sorgente - frame = sorgente.grab(timeout) - except SourceNotFound as e: - raise HTTPException(404, _traduci(str(e))) - except TimeoutError as e: - raise HTTPException(504, _traduci(str(e)) or "timeout") - except (CameraGrabError, CameraError) as e: - raise HTTPException(503, _traduci(str(e))) + frame = _grab(nome, timeout) # #83: corpo estratto in _grab, invariato ok, buf = cv2.imencode(".png", frame) if not ok: raise HTTPException(500, "codifica PNG fallita") return Response(buf.tobytes(), media_type="image/png") + # ---- #83: calibrazione a scacchiera per sorgente + + def _sorgente(valore) -> str: + if not isinstance(valore, str) or not valore.strip(): + raise HTTPException(400, "manca «source»") + return valore.strip() + + @app.get("/camera/calibration") + def calibrazione_leggi(source: str = ""): + dati = archivio.leggi(_sorgente(source)) + if dati is None: + raise HTTPException(404, "sorgente non calibrata") + return dati + + @app.delete("/camera/calibration", status_code=204) + def calibrazione_elimina(source: str = ""): + archivio.elimina(_sorgente(source)) + return Response(status_code=204) + + @app.post("/camera/calibrate") + async def calibrazione_calcola( + source: str = Form(...), + cols: int = Form(...), + rows: int = Form(...), + square_mm: float = Form(...), + image: UploadFile | None = File(None), + save: bool = Form(False), + ): + nome = _sorgente(source) + contenuto = await image.read() if image is not None else b"" + if contenuto: + frame = cv2.imdecode(np.frombuffer(contenuto, np.uint8), cv2.IMREAD_UNCHANGED) + if frame is None: + raise HTTPException(422, "l'immagine caricata non è leggibile") + else: + frame = await run_in_threadpool(_grab, nome, 5000) # come /camera/grab (404/503/504) + try: + risultato = calibrazione.calibra_scacchiera(frame, cols, rows, square_mm) + except calibrazione.CalibrazioneError as e: + raise HTTPException(422, str(e)) + corpo = {"source": nome, + **{k: v for k, v in risultato.items() if k != "overlay_png"}, + "calibrated_at": calibrazione.ora_iso()} + if save: + archivio.salva(nome, corpo) + return {**corpo, "overlay_png": risultato["overlay_png"], "saved": bool(save)} + + @app.get("/calibrazione", response_class=HTMLResponse) + def calibrazione_pagina(): + return HTMLResponse(PAGINA) + return app @@ -136,8 +227,10 @@ def main() -> None: p.add_argument("--host", default="127.0.0.1") p.add_argument("--port", type=int, default=8765) p.add_argument("--folder", default=None, help="cartella immagini per camera:folder") + p.add_argument("--config-dir", default=None, + help="cartella delle calibrazioni salvate (default: ProgramData su Windows)") # #83 a = p.parse_args() - uvicorn.run(create_app(a.folder), host=a.host, port=a.port) + uvicorn.run(create_app(a.folder, a.config_dir), host=a.host, port=a.port) if __name__ == "__main__": diff --git a/scripts/tests/test_calibrazione.py b/scripts/tests/test_calibrazione.py new file mode 100644 index 0000000..726ec1a --- /dev/null +++ b/scripts/tests/test_calibrazione.py @@ -0,0 +1,184 @@ +"""Test della calibrazione dell'acquisitore (#83): logica pura e rotte di camera_service. + +Uso: dalla radice, `uv run --python 3.13 --extra vision-worker --extra dev +--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q` +(o il venv di VisionSuite come per test_camera_service.py). +""" +import base64 +import importlib.util +import json +from pathlib import Path + +import cv2 +import numpy as np +import pytest +from fastapi.testclient import TestClient + +SCRIPTS = Path(__file__).resolve().parents[1] +MODULO = SCRIPTS / "camera_service.py" + + +def _carica(): + spec = importlib.util.spec_from_file_location("camera_service", MODULO) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def una_scacchiera(square_px: int = 40, nx: int = 10, ny: int = 7, margin: int = 60) -> np.ndarray: + """nx x ny quadrati => (nx-1) x (ny-1) angoli interni, su fondo bianco.""" + img = np.full((ny * square_px + 2 * margin, nx * square_px + 2 * margin), 255, np.uint8) + for j in range(ny): + for i in range(nx): + if (i + j) % 2 == 0: + y, x = margin + j * square_px, margin + i * square_px + img[y:y + square_px, x:x + square_px] = 0 + return img + + +def _png(img: np.ndarray) -> bytes: + ok, buf = cv2.imencode(".png", img) + assert ok + return buf.tobytes() + + +@pytest.fixture +def cartella(tmp_path): + # la sorgente camera:folder fa il loop su una scacchiera: il grab la trova + cartella = tmp_path / "immagini" + cartella.mkdir() + cv2.imwrite(str(cartella / "scacchiera.png"), una_scacchiera()) + return cartella + + +@pytest.fixture +def client(cartella, tmp_path): + mod = _carica() + with TestClient(mod.create_app(cartella, config_dir=tmp_path / "config")) as c: + c.config = tmp_path / "config" + yield c + + +CAMPI = {"cols": "9", "rows": "6", "square_mm": "2"} +CHIAVI_C1 = {"source", "px_per_mm", "mm_per_px", "rms_px", "n_corners", "cols", "rows", + "square_mm", "image_width", "image_height", "scale_spread_pct", "calibrated_at"} +NON_IMMAGINE = b"questo non e una immagine" + + +# ---- logica pura + +def test_scacchiera_sintetica_da_la_scala(): + _carica() # mette scripts/ in sys.path + import calibrazione + r = calibrazione.calibra_scacchiera(una_scacchiera(), 9, 6, 2.0) + assert r["px_per_mm"] == pytest.approx(20.0, rel=0.005) + assert r["mm_per_px"] == pytest.approx(0.05, rel=0.005) + assert r["rms_px"] < 0.5 + assert r["n_corners"] == 54 + assert (r["image_width"], r["image_height"]) == (520, 400) + assert r["scale_spread_pct"] < 1.0 + assert base64.b64decode(r["overlay_png"])[:8] == b"\x89PNG\r\n\x1a\n" + + +def test_immagine_bianca_solleva(): + _carica() + import calibrazione + with pytest.raises(calibrazione.CalibrazioneError, match="non trovata"): + calibrazione.calibra_scacchiera(np.full((300, 300), 255, np.uint8), 9, 6, 2.0) + + +@pytest.mark.parametrize("cols,rows,quadrato", [(1, 6, 2.0), (9, 6, 0.0), (9, 6, -1.0)]) +def test_parametri_invalidi_sollevano(cols, rows, quadrato): + _carica() + import calibrazione + with pytest.raises(calibrazione.CalibrazioneError): + calibrazione.calibra_scacchiera(una_scacchiera(), cols, rows, quadrato) + + +# ---- rotte + +def test_c1_non_calibrata_404(client): + r = client.get("/camera/calibration", params={"source": "camera:folder"}) + assert r.status_code == 404 + assert r.json()["detail"] == "sorgente non calibrata" + + +def test_c1_senza_source_400(client): + assert client.get("/camera/calibration").status_code == 400 + + +def test_c2_immagine_caricata_senza_salvare(client): + r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI}, + files={"image": ("s.png", _png(una_scacchiera()), "image/png")}) + assert r.status_code == 200, r.text + corpo = r.json() + assert CHIAVI_C1 | {"overlay_png", "saved"} == set(corpo) + assert corpo["saved"] is False + assert corpo["px_per_mm"] == pytest.approx(20.0, rel=0.005) + # senza save non si scrive nulla + assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404 + assert not client.config.exists() or not list(client.config.glob("*.json")) + + +def test_c2_c1_c3_ciclo_con_grab_dalla_sorgente(client): + # nessun file: grab da camera:folder (la scacchiera in cartella), con save + r = client.post("/camera/calibrate", + data={"source": "camera:folder", "save": "true", **CAMPI}) + assert r.status_code == 200, r.text + assert r.json()["saved"] is True + + r1 = client.get("/camera/calibration", params={"source": "camera:folder"}) + assert r1.status_code == 200 + assert set(r1.json()) == CHIAVI_C1 # nessun overlay nel file salvato + assert r1.json()["px_per_mm"] == pytest.approx(20.0, rel=0.005) + assert r1.json()["source"] == "camera:folder" + file_salvati = list(client.config.glob("*.json")) + assert len(file_salvati) == 1 + assert json.loads(file_salvati[0].read_text(encoding="utf-8"))["cols"] == 9 + + assert client.delete("/camera/calibration", params={"source": "camera:folder"}).status_code == 204 + assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404 + # cancellare di nuovo e' idempotente + assert client.delete("/camera/calibration", params={"source": "camera:folder"}).status_code == 204 + + +def test_c2_scacchiera_non_trovata_422(client): + bianca = np.full((300, 300), 255, np.uint8) + r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI}, + files={"image": ("b.png", _png(bianca), "image/png")}) + assert r.status_code == 422 + assert "non trovata" in r.json()["detail"] + + +def test_c2_parametri_invalidi_422(client): + r = client.post("/camera/calibrate", + data={"source": "camera:folder", "cols": "1", "rows": "6", "square_mm": "2"}, + files={"image": ("s.png", _png(una_scacchiera()), "image/png")}) + assert r.status_code == 422 + + +def test_c2_immagine_illeggibile_422(client): + r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI}, + files={"image": ("x.png", NON_IMMAGINE, "image/png")}) + assert r.status_code == 422 + + +def test_c2_errori_camera_come_grab(client): + r = client.post("/camera/calibrate", data={"source": "camera:non-esiste", **CAMPI}) + assert r.status_code == 404 + + +def test_due_sorgenti_hanno_file_diversi(client): + for nome in ("camera:a b", "camera:a_b"): + r = client.post("/camera/calibrate", data={"source": nome, "save": "true", **CAMPI}, + files={"image": ("s.png", _png(una_scacchiera()), "image/png")}) + assert r.status_code == 200 + assert len(list(client.config.glob("*.json"))) == 2 + + +def test_c4_pagina_html(client): + r = client.get("/calibrazione") + assert r.status_code == 200 + assert r.headers["content-type"].startswith("text/html") + assert "Acquisisci e calcola" in r.text + assert "http://" not in r.text and "https://" not in r.text # offline, niente CDN