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.
148 lines
6.3 KiB
Python
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
|