Files
Monitorink/backend/frame.py
jerem ca4febbc44 Refresh e-ink: multi-régions + full toutes les 2h (basé temps)
Le full refresh apparaissait trop souvent: getbbox() renvoyait un seul
rectangle englobant tous les pixels modifiés, donc météo (haut) + heure de
MaJ (ailleurs) qui changeaient au même cycle produisaient un rectangle
quasi plein écran -> ratio > partial_max_ratio -> full forcé.

- frame.py: détection des bandes horizontales modifiées disjointes
  (_changed_regions), refresh partiel serré par zone. Full basé sur le
  temps écoulé (last_full_at + time.monotonic) au lieu d'un compteur de
  cycles. État pngs/regions en liste, get_png(client, region).
- config.py: full_refresh_interval_minutes (MONITORINK_FULL_INTERVAL_MIN,
  défaut 120). Suppression de partial_max_ratio.
- app.py: /frame.meta renvoie un bloc multi-ligne "MODE SEQ N" + N régions
  "i x y w h"; /frame.png?region=i.
- monitorinkloop.sh: display_meta parse le bloc et fait N fbink partiels.
2026-06-16 14:06:49 +02:00

148 lines
6.3 KiB
Python

"""Refresh partiel e-ink : calcule, par client, les zones du dashboard qui ont changé depuis
l'image précédemment servie, et décide d'un refresh partiel (un ou plusieurs crops) ou complet.
Le diff se fait ici (côté serveur, PIL dispo) ; la Kobo ne reçoit que des PNG prêts à afficher
(un crop par zone modifiée en partial, image pleine en full) + leurs offsets, via les endpoints
/frame.meta et /frame.png.
Pourquoi plusieurs zones : un seul rectangle englobant (getbbox) s'étire dès que deux blocs
distants changent au même cycle (ex. météo en haut + heure de MaJ ailleurs), ce qui forçait à tort
un full. On détecte donc les bandes horizontales modifiées disjointes et on rafraîchit chacune
séparément, en partiel (sans flash). Le full flashy n'arrive plus qu'au 1er lancement/reset et à
intervalle de temps fixe (cf. full_refresh_interval_minutes) pour nettoyer le ghosting.
"""
from __future__ import annotations
import asyncio
import time
from dataclasses import dataclass, field
from PIL import Image, ImageChops
import render
from config import config
# Seuil de diff (0-255) sous lequel on considère deux pixels identiques. Le rendu PNG est
# lossless et déterministe : les zones inchangées sont strictement identiques, ce seuil ne
# sert qu'à absorber un éventuel bruit résiduel.
_DIFF_THRESHOLD = 16
# Deux bandes de lignes modifiées séparées par moins de _BAND_GAP pixels inchangés sont fusionnées
# (évite d'éclater un même bloc en multiples régions ; économise des appels fbink sur la Kobo).
_BAND_GAP = 24
# Plafond de régions par cycle : au-delà, on fusionne tout en une seule région englobante (un grand
# partiel flashless reste préférable à un full flashy parasite).
_MAX_REGIONS = 6
@dataclass
class _ClientState:
prev_image: Image.Image | None = None
last_full_at: float = 0.0 # time.monotonic() du dernier full refresh
seq: int = 0 # compteur monotone, identifie le frame courant
pngs: list[bytes] = field(default_factory=list) # un PNG par région (crop ou image pleine)
mode: str = "full"
regions: list[tuple[int, int, int, int]] = field(default_factory=list) # (x, y, w, h) par région
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
_clients: dict[str, _ClientState] = {}
def _changed_regions(prev: Image.Image, cur: Image.Image) -> list[tuple[int, int, int, int]]:
"""Liste de rectangles (x, y, w, h) serrés couvrant les pixels modifiés, ou [] si identiques.
Détecte les bandes horizontales modifiées disjointes (les blocs du dashboard sont séparés
verticalement), fusionne les bandes proches, et rabote chaque bande à son x-extent réel.
"""
diff = ImageChops.difference(prev, cur)
if _DIFF_THRESHOLD:
diff = diff.point(lambda p: 255 if p > _DIFF_THRESHOLD else 0)
if diff.getbbox() is None:
return []
full_w, full_h = diff.size
# Activité par ligne : on réduit la largeur à 1 (BOX = moyenne) ; une ligne avec au moins un
# pixel modifié donne une valeur > 0. getdata() -> H valeurs, une par ligne.
col = diff.resize((1, full_h), Image.BOX)
row_changed = [v > 0 for v in col.getdata()]
# Regroupe les lignes changées contiguës en bandes [haut, bas), en tolérant un trou < _BAND_GAP.
bands: list[list[int]] = [] # [top, bottom)
for y, changed in enumerate(row_changed):
if not changed:
continue
if bands and y - bands[-1][1] <= _BAND_GAP:
bands[-1][1] = y + 1
else:
bands.append([y, y + 1])
# Pour chaque bande, x/y-extent réel via getbbox sur la tranche.
regions: list[tuple[int, int, int, int]] = []
for top, bottom in bands:
bbox = diff.crop((0, top, full_w, bottom)).getbbox()
if bbox is None:
continue
left, upper, right, lower = bbox
regions.append((left, top + upper, right - left, lower - upper))
# Garde-fou : trop de régions -> une seule région englobante (grand partiel, jamais de full).
if len(regions) > _MAX_REGIONS:
full = diff.getbbox()
left, upper, right, lower = full
return [(left, upper, right - left, lower - upper)]
return regions
async def compute_frame(client: str, reset: bool = False) -> dict:
"""Rend le dashboard, calcule le diff vs l'image précédente du client, met à jour son état
et renvoie {mode, seq, regions:[(x,y,w,h), ...]}. Les PNG correspondants (un par région) sont
stockés pour /frame.png.
reset=True (envoyé par la Kobo au 1er cycle après un (re)démarrage) oublie l'image
précédente : l'écran a été effacé par le reboot, un diff partiel se poserait sur une base
erronée. On force alors un full refresh propre."""
state = _clients.setdefault(client, _ClientState())
async with state.lock:
if reset:
state.prev_image = None # -> force_full ci-dessous
cur = await render.render_image()
full_w, full_h = cur.size
state.seq += 1
now = time.monotonic()
force_full = (
state.prev_image is None
or (now - state.last_full_at) >= config.full_refresh_interval_minutes * 60
)
regions = [] if force_full else _changed_regions(state.prev_image, cur)
if force_full:
state.pngs = [render.encode_png(cur)]
state.mode = "full"
state.regions = [(0, 0, full_w, full_h)]
state.last_full_at = now
elif not regions:
# Aucune différence : on ne rafraîchit rien (la Kobo ignore ce cycle). On conserve les
# PNG précédents ; last_full_at reste figé (cadence du full basée sur le temps écoulé).
state.mode = "noop"
state.regions = []
else:
state.pngs = [render.encode_png(cur.crop((x, y, x + w, y + h))) for (x, y, w, h) in regions]
state.mode = "partial"
state.regions = regions
state.prev_image = cur
return {"mode": state.mode, "seq": state.seq, "regions": state.regions}
def get_png(client: str, region: int = 0) -> bytes | None:
"""PNG de la région d'index `region` stockée lors du dernier compute_frame() pour ce client
(None si jamais calculé ou index hors borne)."""
state = _clients.get(client)
if not state or region < 0 or region >= len(state.pngs):
return None
return state.pngs[region] or None