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.
This commit is contained in:
jerem
2026-06-16 14:06:49 +02:00
parent d7f52210e7
commit ca4febbc44
6 changed files with 140 additions and 83 deletions

View File

@@ -1,12 +1,20 @@
"""Refresh partiel e-ink : calcule, par client, la zone du dashboard qui a changé depuis
l'image précédemment servie, et décide d'un refresh partiel (crop) ou complet (full).
"""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 qu'un PNG prêt à afficher
(crop en partial, image pleine en full) + son offset, via les endpoints /frame.meta et /frame.png.
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
@@ -19,32 +27,78 @@ from config import config
# 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
since_full: int = 0 # cycles écoulés depuis le dernier full refresh
seq: int = 0 # compteur monotone, identifie le frame courant
png: bytes = b"" # PNG à servir (crop ou image pleine)
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"
region: tuple[int, int, int, int] = (0, 0, 0, 0) # x, y, w, h
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_bbox(prev: Image.Image, cur: Image.Image) -> tuple[int, int, int, int] | None:
"""Rectangle (left, upper, right, lower) englobant les pixels modifiés, ou None si identiques."""
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)
return diff.getbbox()
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, x, y, w, h, seq}. Le PNG correspondant est stocké pour /frame.png.
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
@@ -57,45 +111,37 @@ async def compute_frame(client: str, reset: bool = False) -> dict:
full_w, full_h = cur.size
state.seq += 1
now = time.monotonic()
force_full = (
state.prev_image is None
or state.since_full + 1 >= config.full_refresh_every
or (now - state.last_full_at) >= config.full_refresh_interval_minutes * 60
)
bbox = None if force_full else _changed_bbox(state.prev_image, cur)
if not force_full and bbox is not None:
left, upper, right, lower = bbox
w, h = right - left, lower - upper
ratio = (w * h) / float(full_w * full_h)
if ratio > config.partial_max_ratio:
force_full = True
regions = [] if force_full else _changed_regions(state.prev_image, cur)
if force_full:
state.png = render.encode_png(cur)
state.pngs = [render.encode_png(cur)]
state.mode = "full"
state.region = (0, 0, full_w, full_h)
state.since_full = 0
elif bbox is None:
# Aucune différence : on ne rafraîchit rien (la Kobo ignore ce cycle). On conserve
# le PNG précédent et on incrémente quand même since_full pour garder la cadence du
# full refresh horaire (basée sur le temps écoulé).
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.region = (0, 0, 0, 0)
state.since_full += 1
state.regions = []
else:
left, upper, right, lower = bbox
state.png = render.encode_png(cur.crop(bbox))
state.pngs = [render.encode_png(cur.crop((x, y, x + w, y + h))) for (x, y, w, h) in regions]
state.mode = "partial"
state.region = (left, upper, right - left, lower - upper)
state.since_full += 1
state.regions = regions
state.prev_image = cur
x, y, w, h = state.region
return {"mode": state.mode, "x": x, "y": y, "w": w, "h": h, "seq": state.seq}
return {"mode": state.mode, "seq": state.seq, "regions": state.regions}
def get_png(client: str) -> bytes | None:
"""PNG stocké lors du dernier compute_frame() pour ce client (None si jamais calculé)."""
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)
return state.png if state and state.png else None
if not state or region < 0 or region >= len(state.pngs):
return None
return state.pngs[region] or None