oledgifstudio 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- oledgif/__init__.py +3 -0
- oledgif/__main__.py +3 -0
- oledgif/cli.py +176 -0
- oledgif/describe.py +60 -0
- oledgif/effects.py +321 -0
- oledgif/fonts.py +34 -0
- oledgif/patterns.py +165 -0
- oledgif/presets.py +29 -0
- oledgif/render.py +209 -0
- oledgifstudio-0.1.0.dist-info/METADATA +245 -0
- oledgifstudio-0.1.0.dist-info/RECORD +15 -0
- oledgifstudio-0.1.0.dist-info/WHEEL +5 -0
- oledgifstudio-0.1.0.dist-info/entry_points.txt +2 -0
- oledgifstudio-0.1.0.dist-info/licenses/LICENSE +21 -0
- oledgifstudio-0.1.0.dist-info/top_level.txt +1 -0
oledgif/__init__.py
ADDED
oledgif/__main__.py
ADDED
oledgif/cli.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""CLI : python -m oledgif "TEXTE" [options]"""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import random
|
|
6
|
+
import string
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .describe import parse as parse_description
|
|
11
|
+
from .effects import ALNUM, DESCRIPTIONS, EFFECTS, Ctx
|
|
12
|
+
from .fonts import load_font
|
|
13
|
+
from .patterns import PATTERNS
|
|
14
|
+
from .presets import DEFAULT_PRESET, PRESETS, resolve_size
|
|
15
|
+
from .render import convert_animation, load_source, save_gif
|
|
16
|
+
|
|
17
|
+
from PIL import Image
|
|
18
|
+
|
|
19
|
+
CHARSETS = {
|
|
20
|
+
"alnum": ALNUM,
|
|
21
|
+
"digits": string.digits,
|
|
22
|
+
"upper": string.digits + string.ascii_uppercase,
|
|
23
|
+
"letters": string.ascii_letters,
|
|
24
|
+
"ascii": "".join(c for c in string.printable if c.isprintable() and c != " "),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_parser():
|
|
29
|
+
p = argparse.ArgumentParser(
|
|
30
|
+
prog="oledgif",
|
|
31
|
+
description="Génère des GIFs animés 1 bit pour écrans OLED (SteelSeries, SSD1306...).",
|
|
32
|
+
)
|
|
33
|
+
p.add_argument("text", nargs="?", help="texte à animer (optionnel avec --image ou un motif)")
|
|
34
|
+
p.add_argument("-d", "--describe", metavar="PHRASE",
|
|
35
|
+
help="décrire l'animation en langage naturel, ex: 'GG qui clignote vite'")
|
|
36
|
+
p.add_argument("-i", "--image", metavar="FICHIER",
|
|
37
|
+
help="image source (PNG/JPG/GIF...) : convertie en 1 bit avec tramage ; "
|
|
38
|
+
"un GIF animé est converti tel quel si --effect vaut auto/convert")
|
|
39
|
+
p.add_argument("--style", default="photo", choices=["photo", "solid", "comic", "edges"],
|
|
40
|
+
help="rendu de --image : photo = contraste+netteté+tramage (défaut), "
|
|
41
|
+
"solid = seuillage net (logos), comic = solid + contours blancs "
|
|
42
|
+
"des formes perdues dans le noir (illustrations), "
|
|
43
|
+
"edges = contours blancs sur noir")
|
|
44
|
+
p.add_argument("--fit", default="contain", choices=["contain", "cover"],
|
|
45
|
+
help="contain = image entière (défaut), cover = remplit l'écran en "
|
|
46
|
+
"recadrant — recommandé pour les photos paysage")
|
|
47
|
+
p.add_argument("--no-dither", action="store_true",
|
|
48
|
+
help="alias de --style solid")
|
|
49
|
+
p.add_argument("--no-denoise", action="store_true",
|
|
50
|
+
help="garde le bruit : désactive le filtre médian et la suppression "
|
|
51
|
+
"des pixels isolés (reflets, sources de lumière ponctuelles)")
|
|
52
|
+
p.add_argument("-o", "--out", default=None, help="fichier de sortie (.gif)")
|
|
53
|
+
p.add_argument("-p", "--preset", default=None,
|
|
54
|
+
help=f"écran cible (défaut: {DEFAULT_PRESET}) — voir --list-presets")
|
|
55
|
+
p.add_argument("--size", default=None, metavar="WxH", help="taille custom, ex: 128x40")
|
|
56
|
+
p.add_argument("-e", "--effect", default="auto",
|
|
57
|
+
help="effet (défaut: auto) — voir --list-effects")
|
|
58
|
+
p.add_argument("--fps", type=int, default=15)
|
|
59
|
+
p.add_argument("--seconds", type=float, default=4.0, help="durée cible (certains effets la déduisent)")
|
|
60
|
+
p.add_argument("--speed", type=float, default=None, help="vitesse de défilement en px/s (scroll)")
|
|
61
|
+
p.add_argument("--font", default=None, help="chemin d'une police .ttf")
|
|
62
|
+
p.add_argument("--font-size", type=int, default=None)
|
|
63
|
+
p.add_argument("--charset", default="alnum",
|
|
64
|
+
help="alnum|digits|upper|letters|ascii ou chaîne littérale (matrix/slot)")
|
|
65
|
+
p.add_argument("--invert", action="store_true", help="noir sur blanc")
|
|
66
|
+
p.add_argument("--scale", type=int, default=1, help="agrandit le GIF xN (aperçu)")
|
|
67
|
+
p.add_argument("--seed", type=int, default=None, help="graine aléatoire (rendu reproductible)")
|
|
68
|
+
p.add_argument("--demo", action="store_true", help="génère un exemple de chaque effet dans ./samples")
|
|
69
|
+
p.add_argument("--list-effects", action="store_true")
|
|
70
|
+
p.add_argument("--list-presets", action="store_true")
|
|
71
|
+
p.add_argument("--version", action="version", version=f"oledgif {__version__}")
|
|
72
|
+
return p
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def make_gif(text, out, *, preset=None, size=None, effect="auto", fps=15, seconds=4.0,
|
|
76
|
+
speed=None, font_path=None, font_size=None, charset=ALNUM,
|
|
77
|
+
invert=False, scale=1, seed=None, image=None, style="photo", fit="contain",
|
|
78
|
+
denoise=True):
|
|
79
|
+
W, H = resolve_size(preset, size)
|
|
80
|
+
font = load_font(font_path, font_size, screen_h=H)
|
|
81
|
+
|
|
82
|
+
src = None
|
|
83
|
+
if image:
|
|
84
|
+
im = Image.open(image)
|
|
85
|
+
if getattr(im, "is_animated", False) and effect in ("auto", "convert"):
|
|
86
|
+
# GIF animé -> conversion frame par frame, durées d'origine conservées
|
|
87
|
+
frames, durations = convert_animation(image, W, H, style, fit, denoise)
|
|
88
|
+
n, duration = save_gif(frames, out, fps=fps, invert=invert,
|
|
89
|
+
scale=scale, durations=durations)
|
|
90
|
+
return out, "convert", W, H, n, duration
|
|
91
|
+
im.close()
|
|
92
|
+
src = load_source(image, W, H, style, fit, denoise)
|
|
93
|
+
if effect in ("auto", "convert"):
|
|
94
|
+
effect = "vhs"
|
|
95
|
+
|
|
96
|
+
if effect == "auto":
|
|
97
|
+
try:
|
|
98
|
+
wide = font.getlength(text) > W - 4
|
|
99
|
+
except AttributeError:
|
|
100
|
+
wide = len(text) * 7 > W - 4
|
|
101
|
+
effect = "scroll" if wide else "wave"
|
|
102
|
+
if effect not in EFFECTS:
|
|
103
|
+
raise SystemExit(f"Effet inconnu: {effect!r}. Effets: {', '.join(EFFECTS)}")
|
|
104
|
+
|
|
105
|
+
ctx = Ctx(text=text or "", W=W, H=H, fps=fps, seconds=seconds, font=font,
|
|
106
|
+
font_path=font_path, font_size=font_size, charset=charset,
|
|
107
|
+
speed=speed, src=src, rng=random.Random(seed))
|
|
108
|
+
frames = EFFECTS[effect](ctx)
|
|
109
|
+
n, duration = save_gif(frames, out, fps=fps, invert=invert, scale=scale)
|
|
110
|
+
return out, effect, W, H, n, duration
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def main(argv=None):
|
|
114
|
+
args = build_parser().parse_args(argv)
|
|
115
|
+
|
|
116
|
+
if args.list_effects:
|
|
117
|
+
for name, desc in DESCRIPTIONS.items():
|
|
118
|
+
print(f" {name:<11} {desc}")
|
|
119
|
+
return 0
|
|
120
|
+
if args.list_presets:
|
|
121
|
+
for name, (w, h, desc) in PRESETS.items():
|
|
122
|
+
print(f" {name:<12} {w}x{h:<4} {desc}")
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
charset = CHARSETS.get(args.charset, args.charset)
|
|
126
|
+
|
|
127
|
+
if args.demo:
|
|
128
|
+
outdir = "samples"
|
|
129
|
+
os.makedirs(outdir, exist_ok=True)
|
|
130
|
+
text = args.text or "GG WP 42"
|
|
131
|
+
for name in EFFECTS:
|
|
132
|
+
out = os.path.join(outdir, f"{name}.gif")
|
|
133
|
+
make_gif(text, out, preset=args.preset, size=args.size, effect=name,
|
|
134
|
+
fps=args.fps, seconds=args.seconds, speed=args.speed,
|
|
135
|
+
font_path=args.font, font_size=args.font_size, charset=charset,
|
|
136
|
+
invert=args.invert, scale=args.scale, seed=args.seed or 42)
|
|
137
|
+
print(f" {out}")
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
text, effect, fps, seconds = args.text, args.effect, args.fps, args.seconds
|
|
141
|
+
if args.describe:
|
|
142
|
+
parsed = parse_description(args.describe)
|
|
143
|
+
text = parsed.get("text", text)
|
|
144
|
+
if effect == "auto":
|
|
145
|
+
effect = parsed.get("effect", "auto")
|
|
146
|
+
fps = parsed.get("fps", fps)
|
|
147
|
+
seconds = parsed.get("seconds", seconds)
|
|
148
|
+
|
|
149
|
+
textless_ok = args.image or effect in PATTERNS or effect == "matrix"
|
|
150
|
+
if not text and not textless_ok:
|
|
151
|
+
print("Erreur: aucun texte. Passe-le en argument, entre guillemets dans --describe, "
|
|
152
|
+
"ou utilise --image / un motif (starfield, plasma, life, eq, scope, radar).",
|
|
153
|
+
file=sys.stderr)
|
|
154
|
+
return 2
|
|
155
|
+
|
|
156
|
+
if args.out:
|
|
157
|
+
out = args.out
|
|
158
|
+
elif text:
|
|
159
|
+
out = "".join(c if c.isalnum() else "_" for c in text)[:24] + ".gif"
|
|
160
|
+
elif args.image:
|
|
161
|
+
out = os.path.splitext(os.path.basename(args.image))[0] + "_oled.gif"
|
|
162
|
+
else:
|
|
163
|
+
out = f"{effect}.gif"
|
|
164
|
+
out, effect, W, H, n, duration = make_gif(
|
|
165
|
+
text, out, preset=args.preset, size=args.size, effect=effect, fps=fps,
|
|
166
|
+
seconds=seconds, speed=args.speed, font_path=args.font,
|
|
167
|
+
font_size=args.font_size, charset=charset, invert=args.invert,
|
|
168
|
+
scale=args.scale, seed=args.seed, image=args.image,
|
|
169
|
+
style="solid" if args.no_dither else args.style, fit=args.fit,
|
|
170
|
+
denoise=not args.no_denoise)
|
|
171
|
+
print(f"{out} — {W}x{H}, effet {effect}, {n} frames @ {duration} ms")
|
|
172
|
+
return 0
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
if __name__ == "__main__":
|
|
176
|
+
raise SystemExit(main())
|
oledgif/describe.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Mode langage naturel (FR/EN) : une phrase -> effet + paramètres.
|
|
2
|
+
|
|
3
|
+
Pas de modèle IA : simple correspondance de mots-clés, instantané et gratuit.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
import unicodedata
|
|
8
|
+
|
|
9
|
+
KEYWORDS = {
|
|
10
|
+
"scroll": ["defile", "defilant", "scroll", "marquee", "bandeau", "glisse a gauche"],
|
|
11
|
+
"typewriter": ["machine a ecrire", "typewriter", "tape", "frappe", "ecrit lettre"],
|
|
12
|
+
"wave": ["vague", "wave", "ondule", "ondulation", "flotte"],
|
|
13
|
+
"blink": ["clignote", "clignotant", "blink", "flash", "flashe"],
|
|
14
|
+
"bounce": ["rebond", "rebondit", "bounce", "dvd"],
|
|
15
|
+
"matrix": ["matrix", "pluie", "rain", "code qui tombe", "hacker"],
|
|
16
|
+
"slot": ["slot", "machine a sous", "roulette", "casino", "tirage", "jackpot"],
|
|
17
|
+
"glitch": ["glitch", "bug", "corrompu", "casse", "parasite"],
|
|
18
|
+
"pulse": ["pulse", "bat", "battement", "coeur", "respire", "zoom"],
|
|
19
|
+
"slide": ["slide", "monte", "remonte", "entre par le bas", "générique", "credits"],
|
|
20
|
+
"vhs": ["vhs", "vintage", "retro", "cassette", "tracking", "analogique", "crt"],
|
|
21
|
+
# motifs sans texte
|
|
22
|
+
"starfield": ["etoile", "star", "warp", "espace", "hyperespace", "galaxie"],
|
|
23
|
+
"plasma": ["plasma", "lave", "psychedelique"],
|
|
24
|
+
"life": ["jeu de la vie", "conway", "cellule", "automate"],
|
|
25
|
+
"eq": ["egaliseur", "equalizer", "musique", "spectre", "barres audio", "visualiseur"],
|
|
26
|
+
"scope": ["oscilloscope", "oscillo", "onde", "signal", "sinusoide"],
|
|
27
|
+
"radar": ["radar", "sonar", "balayage", "echo"],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _fold(s: str) -> str:
|
|
32
|
+
s = unicodedata.normalize("NFD", s.lower())
|
|
33
|
+
return "".join(c for c in s if unicodedata.category(c) != "Mn")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse(description: str) -> dict:
|
|
37
|
+
"""Retourne {effect, text?, fps?, seconds?} déduits de la phrase."""
|
|
38
|
+
folded = _fold(description)
|
|
39
|
+
out: dict = {}
|
|
40
|
+
|
|
41
|
+
# texte entre guillemets : "..." '...' «...» “...”
|
|
42
|
+
m = re.search(r'["\'«“](.+?)["\'»”]', description)
|
|
43
|
+
if m:
|
|
44
|
+
out["text"] = m.group(1)
|
|
45
|
+
|
|
46
|
+
for effect, words in KEYWORDS.items():
|
|
47
|
+
if any(w in folded for w in words):
|
|
48
|
+
out["effect"] = effect
|
|
49
|
+
break
|
|
50
|
+
|
|
51
|
+
if any(w in folded for w in ("rapide", "vite", "fast", "quick")):
|
|
52
|
+
out["fps"] = 25
|
|
53
|
+
elif any(w in folded for w in ("lent", "lentement", "slow", "doucement")):
|
|
54
|
+
out["fps"] = 10
|
|
55
|
+
|
|
56
|
+
m = re.search(r"(\d+(?:[.,]\d+)?)\s*s(?:ec|econdes?)?\b", folded)
|
|
57
|
+
if m:
|
|
58
|
+
out["seconds"] = float(m.group(1).replace(",", "."))
|
|
59
|
+
|
|
60
|
+
return out
|
oledgif/effects.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Effets d'animation. Chaque effet reçoit un Ctx et retourne une liste de frames "L"."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import random
|
|
5
|
+
import string
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from PIL import Image, ImageDraw
|
|
9
|
+
|
|
10
|
+
from .fonts import fit_font, load_font
|
|
11
|
+
from .render import canvas, char_images, text_image
|
|
12
|
+
|
|
13
|
+
ALNUM = string.digits + string.ascii_uppercase + string.ascii_lowercase
|
|
14
|
+
|
|
15
|
+
EFFECTS: dict[str, callable] = {}
|
|
16
|
+
DESCRIPTIONS: dict[str, str] = {}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def effect(name: str, desc: str):
|
|
20
|
+
def deco(fn):
|
|
21
|
+
EFFECTS[name] = fn
|
|
22
|
+
DESCRIPTIONS[name] = desc
|
|
23
|
+
return fn
|
|
24
|
+
return deco
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Ctx:
|
|
29
|
+
text: str
|
|
30
|
+
W: int
|
|
31
|
+
H: int
|
|
32
|
+
fps: int = 15
|
|
33
|
+
seconds: float = 4.0
|
|
34
|
+
font: object = None
|
|
35
|
+
font_path: str | None = None
|
|
36
|
+
font_size: int | None = None
|
|
37
|
+
charset: str = ALNUM
|
|
38
|
+
speed: float | None = None # px/s pour scroll
|
|
39
|
+
src: object = None # image source (--image) ; sinon le texte est rendu
|
|
40
|
+
rng: random.Random = field(default_factory=random.Random)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def nframes(self) -> int:
|
|
44
|
+
return max(2, int(round(self.fps * self.seconds)))
|
|
45
|
+
|
|
46
|
+
def center(self, img) -> tuple[int, int]:
|
|
47
|
+
return (self.W - img.width) // 2, (self.H - img.height) // 2
|
|
48
|
+
|
|
49
|
+
def sprite(self):
|
|
50
|
+
"""Ce que l'effet anime : l'image fournie, sinon le texte rendu."""
|
|
51
|
+
return self.src if self.src is not None else text_image(self.text, self.font)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------- scroll
|
|
55
|
+
|
|
56
|
+
@effect("scroll", "le texte/l'image défile de droite à gauche (boucle parfaite)")
|
|
57
|
+
def scroll(ctx: Ctx):
|
|
58
|
+
timg = ctx.sprite()
|
|
59
|
+
speed = ctx.speed or max(20.0, ctx.W / 3) # px/s
|
|
60
|
+
travel = ctx.W + timg.width
|
|
61
|
+
n = max(2, int(round(travel / speed * ctx.fps)))
|
|
62
|
+
y = (ctx.H - timg.height) // 2
|
|
63
|
+
frames = []
|
|
64
|
+
for i in range(n):
|
|
65
|
+
f = canvas(ctx.W, ctx.H)
|
|
66
|
+
x = ctx.W - int(round(travel * i / n))
|
|
67
|
+
f.paste(timg, (x, y))
|
|
68
|
+
frames.append(f)
|
|
69
|
+
return frames
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------- typewriter
|
|
73
|
+
|
|
74
|
+
@effect("typewriter", "machine à écrire : les lettres apparaissent une à une, curseur clignotant")
|
|
75
|
+
def typewriter(ctx: Ctx):
|
|
76
|
+
cps = 8 # caractères/seconde
|
|
77
|
+
fpc = max(1, round(ctx.fps / cps))
|
|
78
|
+
blink = max(1, ctx.fps // 2)
|
|
79
|
+
hold = int(ctx.fps * 1.5)
|
|
80
|
+
frames = []
|
|
81
|
+
total = len(ctx.text) * fpc + hold
|
|
82
|
+
for t in range(total):
|
|
83
|
+
k = min(len(ctx.text), t // fpc + 1) if t < len(ctx.text) * fpc else len(ctx.text)
|
|
84
|
+
part = ctx.text[:k]
|
|
85
|
+
timg = text_image(part, ctx.font) if part.strip() else None
|
|
86
|
+
f = canvas(ctx.W, ctx.H)
|
|
87
|
+
w = timg.width if timg else int(ctx.font.getlength(part)) if part else 0
|
|
88
|
+
x = min(2, ctx.W - 6 - w) # reste lisible si le texte déborde
|
|
89
|
+
y = (ctx.H - (timg.height if timg else ctx.H // 2)) // 2
|
|
90
|
+
if timg:
|
|
91
|
+
f.paste(timg, (x, y))
|
|
92
|
+
if (t // blink) % 2 == 0: # curseur bloc
|
|
93
|
+
ch = timg.height if timg else max(8, ctx.H // 2)
|
|
94
|
+
cx = x + w + 2
|
|
95
|
+
ImageDraw.Draw(f).rectangle([cx, y, cx + max(3, ch // 2), y + ch], fill=255)
|
|
96
|
+
frames.append(f)
|
|
97
|
+
return frames
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------------- wave
|
|
101
|
+
|
|
102
|
+
@effect("wave", "les lettres ondulent verticalement en vague")
|
|
103
|
+
def wave(ctx: Ctx):
|
|
104
|
+
chars = char_images(ctx.text, ctx.font)
|
|
105
|
+
total_w = sum(adv for _, adv in chars)
|
|
106
|
+
x0 = (ctx.W - total_w) // 2
|
|
107
|
+
max_h = max((im.height for im, _ in chars if im), default=ctx.H // 2)
|
|
108
|
+
mid = (ctx.H - max_h) // 2
|
|
109
|
+
amp = max(1, min(mid, int(ctx.H * 0.18)))
|
|
110
|
+
frames = []
|
|
111
|
+
for t in range(ctx.nframes):
|
|
112
|
+
f = canvas(ctx.W, ctx.H)
|
|
113
|
+
phase = 2 * math.pi * t / ctx.nframes * max(1, round(ctx.seconds))
|
|
114
|
+
x = x0
|
|
115
|
+
for i, (im, adv) in enumerate(chars):
|
|
116
|
+
if im:
|
|
117
|
+
y = mid + int(round(amp * math.sin(phase + i * 0.7)))
|
|
118
|
+
f.paste(im, (x, y))
|
|
119
|
+
x += adv
|
|
120
|
+
frames.append(f)
|
|
121
|
+
return frames
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------- blink
|
|
125
|
+
|
|
126
|
+
@effect("blink", "le texte/l'image clignote")
|
|
127
|
+
def blink(ctx: Ctx):
|
|
128
|
+
timg = ctx.sprite()
|
|
129
|
+
pos = ctx.center(timg)
|
|
130
|
+
period = max(2, ctx.fps // 2)
|
|
131
|
+
frames = []
|
|
132
|
+
for t in range(ctx.nframes):
|
|
133
|
+
f = canvas(ctx.W, ctx.H)
|
|
134
|
+
if (t // period) % 2 == 0:
|
|
135
|
+
f.paste(timg, pos)
|
|
136
|
+
frames.append(f)
|
|
137
|
+
return frames
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------- bounce
|
|
141
|
+
|
|
142
|
+
@effect("bounce", "le texte/l'image rebondit sur les bords (façon logo DVD)")
|
|
143
|
+
def bounce(ctx: Ctx):
|
|
144
|
+
timg = ctx.sprite()
|
|
145
|
+
free_x, free_y = max(0, ctx.W - timg.width), max(0, ctx.H - timg.height)
|
|
146
|
+
vx = free_x / (ctx.fps * 1.6) if free_x else 0.0
|
|
147
|
+
vy = free_y / (ctx.fps * 0.9) if free_y else 0.0
|
|
148
|
+
vx, vy = max(vx, 0.4) if free_x else 0, max(vy, 0.4) if free_y else 0
|
|
149
|
+
x, y = free_x * 0.3, free_y * 0.7
|
|
150
|
+
frames = []
|
|
151
|
+
for _ in range(ctx.nframes):
|
|
152
|
+
f = canvas(ctx.W, ctx.H)
|
|
153
|
+
f.paste(timg, (int(round(x)), int(round(y))))
|
|
154
|
+
frames.append(f)
|
|
155
|
+
x += vx
|
|
156
|
+
y += vy
|
|
157
|
+
if free_x and (x < 0 or x > free_x):
|
|
158
|
+
vx = -vx
|
|
159
|
+
x = max(0.0, min(x, float(free_x)))
|
|
160
|
+
if free_y and (y < 0 or y > free_y):
|
|
161
|
+
vy = -vy
|
|
162
|
+
y = max(0.0, min(y, float(free_y)))
|
|
163
|
+
return frames
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ---------------------------------------------------------------- matrix
|
|
167
|
+
|
|
168
|
+
@effect("matrix", "pluie de caractères alphanumériques ; le texte se révèle au centre (texte optionnel)")
|
|
169
|
+
def matrix(ctx: Ctx):
|
|
170
|
+
fm = load_font(ctx.font_path, size=max(8, ctx.H // 4), screen_h=ctx.H)
|
|
171
|
+
cell_w = max(4, int(round(fm.getlength("0")))) if hasattr(fm, "getlength") else 6
|
|
172
|
+
cell_h = max(6, text_image("0", fm).height + 1)
|
|
173
|
+
ncols, nrows = max(1, ctx.W // cell_w), max(1, ctx.H // cell_h) + 1
|
|
174
|
+
rng = ctx.rng
|
|
175
|
+
grid = [[rng.choice(ctx.charset) for _ in range(nrows)] for _ in range(ncols)]
|
|
176
|
+
heads = [rng.uniform(0, nrows) for _ in range(ncols)]
|
|
177
|
+
speeds = [rng.uniform(0.15, 0.55) for _ in range(ncols)]
|
|
178
|
+
trails = [rng.randint(2, max(3, nrows)) for _ in range(ncols)]
|
|
179
|
+
reveal_at = int(ctx.nframes * 0.55)
|
|
180
|
+
timg = ctx.sprite() if (ctx.text.strip() or ctx.src is not None) else None
|
|
181
|
+
frames = []
|
|
182
|
+
for t in range(ctx.nframes):
|
|
183
|
+
f = canvas(ctx.W, ctx.H)
|
|
184
|
+
d = ImageDraw.Draw(f)
|
|
185
|
+
for c in range(ncols):
|
|
186
|
+
heads[c] = (heads[c] + speeds[c]) % (nrows + trails[c])
|
|
187
|
+
if rng.random() < 0.15:
|
|
188
|
+
grid[c][rng.randrange(nrows)] = rng.choice(ctx.charset)
|
|
189
|
+
for k in range(trails[c]):
|
|
190
|
+
r = int(heads[c]) - k
|
|
191
|
+
if 0 <= r < nrows:
|
|
192
|
+
d.text((c * cell_w, r * cell_h), grid[c][r], font=fm, fill=255)
|
|
193
|
+
if timg and t >= reveal_at:
|
|
194
|
+
x, y = ctx.center(timg)
|
|
195
|
+
d.rectangle([x - 3, y - 2, x + timg.width + 2, y + timg.height + 1], fill=0)
|
|
196
|
+
f.paste(timg, (x, y))
|
|
197
|
+
frames.append(f)
|
|
198
|
+
return frames
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ---------------------------------------------------------------- slot
|
|
202
|
+
|
|
203
|
+
@effect("slot", "machine à sous : chaque position cycle sur tout l'alphabet puis se fige")
|
|
204
|
+
def slot(ctx: Ctx):
|
|
205
|
+
cells = len(ctx.text)
|
|
206
|
+
size = ctx.font_size or max(8, int(ctx.H * 0.62))
|
|
207
|
+
font = fit_font("0" * max(1, cells), ctx.W - 4, ctx.font_path, size, ctx.H)
|
|
208
|
+
cell_w = int(round(font.getlength("0"))) + 1 if hasattr(font, "getlength") else 7
|
|
209
|
+
x0 = (ctx.W - cell_w * cells) // 2
|
|
210
|
+
lock = [int(ctx.nframes * 0.7 * (i + 1) / (cells + 1)) for i in range(cells)]
|
|
211
|
+
rng = ctx.rng
|
|
212
|
+
frames = []
|
|
213
|
+
for t in range(ctx.nframes):
|
|
214
|
+
f = canvas(ctx.W, ctx.H)
|
|
215
|
+
d = ImageDraw.Draw(f)
|
|
216
|
+
for i, target in enumerate(ctx.text):
|
|
217
|
+
ch = target if (t >= lock[i] or target == " ") else rng.choice(ctx.charset)
|
|
218
|
+
im = text_image(ch, font) if ch != " " else None
|
|
219
|
+
if im:
|
|
220
|
+
f.paste(im, (x0 + i * cell_w + (cell_w - im.width) // 2,
|
|
221
|
+
(ctx.H - im.height) // 2))
|
|
222
|
+
frames.append(f)
|
|
223
|
+
return frames
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ---------------------------------------------------------------- glitch
|
|
227
|
+
|
|
228
|
+
@effect("glitch", "texte/image corrompu : bandes décalées et bruit aléatoire")
|
|
229
|
+
def glitch(ctx: Ctx):
|
|
230
|
+
base = canvas(ctx.W, ctx.H)
|
|
231
|
+
timg = ctx.sprite()
|
|
232
|
+
base.paste(timg, ctx.center(timg))
|
|
233
|
+
rng = ctx.rng
|
|
234
|
+
frames = []
|
|
235
|
+
for t in range(ctx.nframes):
|
|
236
|
+
f = base.copy()
|
|
237
|
+
if t % 7 != 0: # frame propre régulière, ça respire
|
|
238
|
+
for _ in range(rng.randint(1, 3)):
|
|
239
|
+
y0 = rng.randrange(0, ctx.H - 2)
|
|
240
|
+
h = rng.randint(2, max(3, ctx.H // 5))
|
|
241
|
+
dx = rng.randint(-ctx.W // 8, ctx.W // 8)
|
|
242
|
+
band = f.crop((0, y0, ctx.W, min(ctx.H, y0 + h)))
|
|
243
|
+
ImageDraw.Draw(f).rectangle([0, y0, ctx.W, y0 + h], fill=0)
|
|
244
|
+
f.paste(band, (dx, y0))
|
|
245
|
+
px = f.load()
|
|
246
|
+
for _ in range(int(ctx.W * ctx.H * 0.006)):
|
|
247
|
+
px[rng.randrange(ctx.W), rng.randrange(ctx.H)] = 255
|
|
248
|
+
frames.append(f)
|
|
249
|
+
return frames
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ---------------------------------------------------------------- pulse
|
|
253
|
+
|
|
254
|
+
@effect("pulse", "le texte/l'image bat comme un cœur (zoom avant/arrière)")
|
|
255
|
+
def pulse(ctx: Ctx):
|
|
256
|
+
timg = ctx.sprite()
|
|
257
|
+
frames = []
|
|
258
|
+
for t in range(ctx.nframes):
|
|
259
|
+
s = 0.55 + 0.45 * (0.5 + 0.5 * math.sin(2 * math.pi * t / ctx.fps))
|
|
260
|
+
im = timg.resize((max(1, int(timg.width * s)), max(1, int(timg.height * s))),
|
|
261
|
+
Image.NEAREST)
|
|
262
|
+
f = canvas(ctx.W, ctx.H)
|
|
263
|
+
f.paste(im, ctx.center(im))
|
|
264
|
+
frames.append(f)
|
|
265
|
+
return frames
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ---------------------------------------------------------------- vhs
|
|
269
|
+
|
|
270
|
+
@effect("vhs", "tremblement horizontal façon cassette vidéo (défaut pour les images)")
|
|
271
|
+
def vhs(ctx: Ctx):
|
|
272
|
+
base = canvas(ctx.W, ctx.H)
|
|
273
|
+
timg = ctx.sprite()
|
|
274
|
+
base.paste(timg, ctx.center(timg))
|
|
275
|
+
rng = ctx.rng
|
|
276
|
+
n = ctx.nframes
|
|
277
|
+
tau = 2 * math.pi
|
|
278
|
+
cycles = max(1, round(ctx.seconds / 2)) # ondulations complètes par boucle
|
|
279
|
+
frames = []
|
|
280
|
+
for t in range(n):
|
|
281
|
+
ph = tau * t / n
|
|
282
|
+
f = canvas(ctx.W, ctx.H)
|
|
283
|
+
band_y = int(ctx.H * t / n) # bande de tracking, 1 passage par boucle
|
|
284
|
+
jerk = rng.choice((-2, 2)) if rng.random() < 0.12 else 0 # à-coup
|
|
285
|
+
for y in range(ctx.H):
|
|
286
|
+
off = round(1.6 * math.sin(ph * cycles + y / 6.5)
|
|
287
|
+
+ 0.9 * math.sin(ph * 2 * cycles + y / 2.8)) + jerk
|
|
288
|
+
if abs(y - band_y) < 2:
|
|
289
|
+
off += 4 + rng.randint(0, 3)
|
|
290
|
+
row = base.crop((0, y, ctx.W, y + 1))
|
|
291
|
+
f.paste(row, (off % ctx.W, y))
|
|
292
|
+
f.paste(row, (off % ctx.W - ctx.W, y)) # bouclage horizontal
|
|
293
|
+
d = ImageDraw.Draw(f)
|
|
294
|
+
for _ in range(rng.randint(2, 6)): # poussière sur la bande
|
|
295
|
+
x = rng.randrange(ctx.W)
|
|
296
|
+
d.line([x, band_y, min(ctx.W - 1, x + rng.randint(1, 4)), band_y], fill=255)
|
|
297
|
+
frames.append(f)
|
|
298
|
+
return frames
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# ---------------------------------------------------------------- slide
|
|
302
|
+
|
|
303
|
+
@effect("slide", "le texte/l'image entre par le bas, pause, sort par le haut")
|
|
304
|
+
def slide(ctx: Ctx):
|
|
305
|
+
timg = ctx.sprite()
|
|
306
|
+
x, yc = ctx.center(timg)
|
|
307
|
+
n = ctx.nframes
|
|
308
|
+
n_in, n_out = int(n * 0.25), int(n * 0.25)
|
|
309
|
+
frames = []
|
|
310
|
+
for t in range(n):
|
|
311
|
+
f = canvas(ctx.W, ctx.H)
|
|
312
|
+
if t < n_in:
|
|
313
|
+
y = ctx.H - int((ctx.H - yc) * (t + 1) / n_in)
|
|
314
|
+
elif t >= n - n_out:
|
|
315
|
+
k = t - (n - n_out)
|
|
316
|
+
y = yc - int((yc + timg.height) * (k + 1) / n_out)
|
|
317
|
+
else:
|
|
318
|
+
y = yc
|
|
319
|
+
f.paste(timg, (x, y))
|
|
320
|
+
frames.append(f)
|
|
321
|
+
return frames
|
oledgif/fonts.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Chargement de police. Priorité aux monospaces (rendu stable sur petit écran)."""
|
|
2
|
+
|
|
3
|
+
from PIL import ImageFont
|
|
4
|
+
|
|
5
|
+
# ImageFont.truetype cherche automatiquement dans C:\Windows\Fonts sous Windows.
|
|
6
|
+
CANDIDATES = ["consola.ttf", "lucon.ttf", "cour.ttf", "DejaVuSansMono.ttf", "arial.ttf"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def load_font(path: str | None = None, size: int | None = None, screen_h: int = 40):
|
|
10
|
+
if size is None:
|
|
11
|
+
size = max(8, int(screen_h * 0.62))
|
|
12
|
+
if path:
|
|
13
|
+
return ImageFont.truetype(path, size)
|
|
14
|
+
for name in CANDIDATES:
|
|
15
|
+
try:
|
|
16
|
+
return ImageFont.truetype(name, size)
|
|
17
|
+
except OSError:
|
|
18
|
+
continue
|
|
19
|
+
return ImageFont.load_default()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def fit_font(text: str, max_w: int, path: str | None, size: int, screen_h: int):
|
|
23
|
+
"""Réduit la taille jusqu'à ce que `text` tienne dans max_w pixels."""
|
|
24
|
+
font = load_font(path, size, screen_h)
|
|
25
|
+
while size > 7:
|
|
26
|
+
try:
|
|
27
|
+
width = font.getlength(text)
|
|
28
|
+
except AttributeError: # bitmap fallback sans getlength
|
|
29
|
+
break
|
|
30
|
+
if width <= max_w:
|
|
31
|
+
break
|
|
32
|
+
size -= 1
|
|
33
|
+
font = load_font(path, size, screen_h)
|
|
34
|
+
return font
|
oledgif/patterns.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Motifs animés sans texte : écrans de veille pour OLED.
|
|
2
|
+
|
|
3
|
+
Importé pour ses effets de bord (enregistrement dans EFFECTS).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import math
|
|
7
|
+
|
|
8
|
+
from PIL import ImageDraw
|
|
9
|
+
|
|
10
|
+
from .effects import Ctx, effect
|
|
11
|
+
from .render import canvas
|
|
12
|
+
|
|
13
|
+
# Effets utilisables sans texte ni image
|
|
14
|
+
PATTERNS = {"starfield", "plasma", "life", "eq", "scope", "radar"}
|
|
15
|
+
|
|
16
|
+
_BAYER4 = [[0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9], [15, 7, 13, 5]]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@effect("starfield", "champ d'étoiles en hyperespace (sans texte)")
|
|
20
|
+
def starfield(ctx: Ctx):
|
|
21
|
+
rng = ctx.rng
|
|
22
|
+
n = max(24, ctx.W * ctx.H // 140)
|
|
23
|
+
stars = [[rng.uniform(-1, 1), rng.uniform(-1, 1), rng.uniform(0.08, 1.0)]
|
|
24
|
+
for _ in range(n)]
|
|
25
|
+
cx, cy = ctx.W / 2, ctx.H / 2
|
|
26
|
+
frames = []
|
|
27
|
+
for _ in range(ctx.nframes):
|
|
28
|
+
f = canvas(ctx.W, ctx.H)
|
|
29
|
+
px = f.load()
|
|
30
|
+
for s in stars:
|
|
31
|
+
s[2] -= 0.022
|
|
32
|
+
if s[2] <= 0.06:
|
|
33
|
+
s[0], s[1], s[2] = rng.uniform(-1, 1), rng.uniform(-1, 1), 1.0
|
|
34
|
+
x, y = cx + s[0] / s[2] * cx, cy + s[1] / s[2] * cy
|
|
35
|
+
if 0 <= x < ctx.W and 0 <= y < ctx.H:
|
|
36
|
+
px[int(x), int(y)] = 255
|
|
37
|
+
if s[2] < 0.3: # étoiles proches plus grosses
|
|
38
|
+
if x + 1 < ctx.W:
|
|
39
|
+
px[int(x) + 1, int(y)] = 255
|
|
40
|
+
if y + 1 < ctx.H:
|
|
41
|
+
px[int(x), int(y) + 1] = 255
|
|
42
|
+
frames.append(f)
|
|
43
|
+
return frames
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@effect("plasma", "plasma rétro tramé Bayer, boucle parfaite (sans texte)")
|
|
47
|
+
def plasma(ctx: Ctx):
|
|
48
|
+
W, H = ctx.W, ctx.H
|
|
49
|
+
frames = []
|
|
50
|
+
for t in range(ctx.nframes):
|
|
51
|
+
ph = 2 * math.pi * t / ctx.nframes
|
|
52
|
+
f = canvas(W, H)
|
|
53
|
+
px = f.load()
|
|
54
|
+
for y in range(H):
|
|
55
|
+
for x in range(W):
|
|
56
|
+
v = (math.sin(x / 9 + 2 * ph)
|
|
57
|
+
+ math.sin(y / 5 - 2 * ph)
|
|
58
|
+
+ math.sin((x + y) / 11 + 4 * ph)
|
|
59
|
+
+ math.sin(math.hypot(x - W / 2, y - H / 2) / 6 - 2 * ph)) / 4
|
|
60
|
+
if (v + 1) * 8 > _BAYER4[y % 4][x % 4]:
|
|
61
|
+
px[x, y] = 255
|
|
62
|
+
frames.append(f)
|
|
63
|
+
return frames
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@effect("life", "jeu de la vie de Conway, soupe aléatoire (sans texte)")
|
|
67
|
+
def life(ctx: Ctx):
|
|
68
|
+
cell = 2
|
|
69
|
+
gw, gh = max(4, ctx.W // cell), max(4, ctx.H // cell)
|
|
70
|
+
rng = ctx.rng
|
|
71
|
+
grid = [[1 if rng.random() < 0.35 else 0 for _ in range(gw)] for _ in range(gh)]
|
|
72
|
+
frames = []
|
|
73
|
+
for _ in range(ctx.nframes):
|
|
74
|
+
f = canvas(ctx.W, ctx.H)
|
|
75
|
+
d = ImageDraw.Draw(f)
|
|
76
|
+
for r in range(gh):
|
|
77
|
+
for c in range(gw):
|
|
78
|
+
if grid[r][c]:
|
|
79
|
+
d.rectangle([c * cell, r * cell, c * cell + cell - 1,
|
|
80
|
+
r * cell + cell - 1], fill=255)
|
|
81
|
+
frames.append(f)
|
|
82
|
+
nxt = [[0] * gw for _ in range(gh)]
|
|
83
|
+
alive = 0
|
|
84
|
+
for r in range(gh):
|
|
85
|
+
for c in range(gw):
|
|
86
|
+
nb = sum(grid[(r + dr) % gh][(c + dc) % gw]
|
|
87
|
+
for dr in (-1, 0, 1) for dc in (-1, 0, 1)
|
|
88
|
+
if (dr, dc) != (0, 0))
|
|
89
|
+
nxt[r][c] = 1 if (nb == 3 or (grid[r][c] and nb == 2)) else 0
|
|
90
|
+
alive += nxt[r][c]
|
|
91
|
+
grid = nxt
|
|
92
|
+
if alive < gw * gh * 0.03: # la soupe est morte : on ressème
|
|
93
|
+
grid = [[1 if rng.random() < 0.35 else 0 for _ in range(gw)]
|
|
94
|
+
for _ in range(gh)]
|
|
95
|
+
return frames
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@effect("eq", "égaliseur audio : barres et pics qui retombent (sans texte)")
|
|
99
|
+
def eq(ctx: Ctx):
|
|
100
|
+
bar_w, gap = 4, 2
|
|
101
|
+
nb = max(3, (ctx.W + gap) // (bar_w + gap))
|
|
102
|
+
rng = ctx.rng
|
|
103
|
+
freqs = [(rng.randint(1, 3), rng.randint(2, 5), rng.uniform(0, math.tau))
|
|
104
|
+
for _ in range(nb)]
|
|
105
|
+
peaks = [0.0] * nb
|
|
106
|
+
frames = []
|
|
107
|
+
for t in range(ctx.nframes):
|
|
108
|
+
ph = 2 * math.pi * t / ctx.nframes
|
|
109
|
+
f = canvas(ctx.W, ctx.H)
|
|
110
|
+
d = ImageDraw.Draw(f)
|
|
111
|
+
for i, (f1, f2, p0) in enumerate(freqs):
|
|
112
|
+
v = abs(0.65 * math.sin(f1 * ph + p0) + 0.35 * math.sin(f2 * ph + 2 * p0))
|
|
113
|
+
h = max(1, int(v * (ctx.H - 3)))
|
|
114
|
+
peaks[i] = max(peaks[i] - ctx.H / (ctx.fps * 1.2), h + 2)
|
|
115
|
+
x = i * (bar_w + gap)
|
|
116
|
+
d.rectangle([x, ctx.H - h, x + bar_w - 1, ctx.H - 1], fill=255)
|
|
117
|
+
py = ctx.H - 1 - int(min(peaks[i], ctx.H - 1))
|
|
118
|
+
d.line([x, py, x + bar_w - 1, py], fill=255)
|
|
119
|
+
frames.append(f)
|
|
120
|
+
return frames
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@effect("scope", "oscilloscope : onde sinusoïdale modulée (sans texte)")
|
|
124
|
+
def scope(ctx: Ctx):
|
|
125
|
+
cy = ctx.H / 2
|
|
126
|
+
amp = ctx.H * 0.42
|
|
127
|
+
frames = []
|
|
128
|
+
for t in range(ctx.nframes):
|
|
129
|
+
ph = 2 * math.pi * t / ctx.nframes
|
|
130
|
+
f = canvas(ctx.W, ctx.H)
|
|
131
|
+
d = ImageDraw.Draw(f)
|
|
132
|
+
for gx in range(0, ctx.W, 8): # graduations discrètes
|
|
133
|
+
f.putpixel((gx, int(cy)), 255)
|
|
134
|
+
pts = []
|
|
135
|
+
for x in range(ctx.W):
|
|
136
|
+
env = 0.5 + 0.5 * math.sin(2 * math.pi * x / ctx.W - 2 * ph)
|
|
137
|
+
y = cy + amp * env * math.sin(2 * math.pi * 3 * x / ctx.W + 4 * ph)
|
|
138
|
+
pts.append((x, y))
|
|
139
|
+
d.line(pts, fill=255)
|
|
140
|
+
frames.append(f)
|
|
141
|
+
return frames
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@effect("radar", "balayage radar avec échos qui s'effacent (sans texte)")
|
|
145
|
+
def radar(ctx: Ctx):
|
|
146
|
+
cx, cy = ctx.W // 2, ctx.H // 2
|
|
147
|
+
R = min(cx, cy) - 1
|
|
148
|
+
rng = ctx.rng
|
|
149
|
+
blips = [(rng.uniform(0, math.tau), rng.uniform(0.3, 0.95)) for _ in range(6)]
|
|
150
|
+
rotations = max(1, round(ctx.seconds / 2))
|
|
151
|
+
frames = []
|
|
152
|
+
for t in range(ctx.nframes):
|
|
153
|
+
sweep = (2 * math.pi * rotations * t / ctx.nframes) % math.tau
|
|
154
|
+
f = canvas(ctx.W, ctx.H)
|
|
155
|
+
d = ImageDraw.Draw(f)
|
|
156
|
+
d.ellipse([cx - R, cy - R, cx + R, cy + R], outline=255)
|
|
157
|
+
d.line([cx, cy,
|
|
158
|
+
cx + R * math.cos(sweep), cy + R * math.sin(sweep)], fill=255)
|
|
159
|
+
for ang, dist in blips:
|
|
160
|
+
age = (sweep - ang) % math.tau
|
|
161
|
+
if age < 1.4: # l'écho reste visible ~1/4 de tour
|
|
162
|
+
bx, by = cx + R * dist * math.cos(ang), cy + R * dist * math.sin(ang)
|
|
163
|
+
d.rectangle([bx - 1, by - 1, bx + 1, by + 1], fill=255)
|
|
164
|
+
frames.append(f)
|
|
165
|
+
return frames
|
oledgif/presets.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Presets d'écrans OLED répandus sur le marché."""
|
|
2
|
+
|
|
3
|
+
PRESETS = {
|
|
4
|
+
# SteelSeries (GameSense / SteelSeries GG)
|
|
5
|
+
"apex": (128, 40, "SteelSeries Apex 5 / 7 / Pro — OLED clavier"),
|
|
6
|
+
"rival": (128, 36, "SteelSeries Rival 700 / 710 — OLED souris"),
|
|
7
|
+
# Modules OLED I2C/SPI courants (Arduino, Raspberry Pi, macropads QMK...)
|
|
8
|
+
"oled-128x64": (128, 64, "SSD1306 / SH1106 / SSD1309 128x64"),
|
|
9
|
+
"oled-128x32": (128, 32, "SSD1306 128x32"),
|
|
10
|
+
"oled-96x16": (96, 16, "SSD1306 96x16"),
|
|
11
|
+
"oled-256x64": (256, 64, "SSD1322 256x64"),
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
DEFAULT_PRESET = "apex"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def resolve_size(preset: str | None, size: str | None) -> tuple[int, int]:
|
|
18
|
+
"""--size WxH prime sur --preset ; sinon preset (défaut: apex)."""
|
|
19
|
+
if size:
|
|
20
|
+
try:
|
|
21
|
+
w, h = size.lower().split("x")
|
|
22
|
+
return int(w), int(h)
|
|
23
|
+
except ValueError:
|
|
24
|
+
raise SystemExit(f"--size invalide: {size!r} (attendu: LARGEURxHAUTEUR, ex. 128x40)")
|
|
25
|
+
name = preset or DEFAULT_PRESET
|
|
26
|
+
if name not in PRESETS:
|
|
27
|
+
raise SystemExit(f"Preset inconnu: {name!r}. Presets: {', '.join(PRESETS)}")
|
|
28
|
+
w, h, _ = PRESETS[name]
|
|
29
|
+
return w, h
|
oledgif/render.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Rendu bas niveau : texte/image -> bitmap, binarisation 1 bit, écriture GIF."""
|
|
2
|
+
|
|
3
|
+
from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageOps, ImageSequence
|
|
4
|
+
|
|
5
|
+
THRESHOLD = 128
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def canvas(w: int, h: int) -> Image.Image:
|
|
9
|
+
return Image.new("L", (w, h), 0)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def text_image(text: str, font) -> Image.Image:
|
|
13
|
+
"""Rend `text` (blanc sur noir) dans une image ajustée au plus près."""
|
|
14
|
+
probe = ImageDraw.Draw(Image.new("L", (1, 1)))
|
|
15
|
+
bbox = probe.textbbox((0, 0), text, font=font)
|
|
16
|
+
w = max(1, bbox[2] - bbox[0])
|
|
17
|
+
h = max(1, bbox[3] - bbox[1])
|
|
18
|
+
img = Image.new("L", (w, h), 0)
|
|
19
|
+
ImageDraw.Draw(img).text((-bbox[0], -bbox[1]), text, font=font, fill=255)
|
|
20
|
+
return img
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def char_images(text: str, font):
|
|
24
|
+
"""Liste (image, avance_x) par caractère — pour les effets par lettre."""
|
|
25
|
+
out = []
|
|
26
|
+
for ch in text:
|
|
27
|
+
try:
|
|
28
|
+
adv = font.getlength(ch)
|
|
29
|
+
except AttributeError:
|
|
30
|
+
adv = text_image(ch, font).width + 1
|
|
31
|
+
out.append((None if ch == " " else text_image(ch, font), int(round(adv))))
|
|
32
|
+
return out
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _fit(im: Image.Image, max_w: int, max_h: int, fit: str) -> Image.Image:
|
|
36
|
+
if fit == "cover":
|
|
37
|
+
return ImageOps.fit(im, (max_w, max_h), Image.LANCZOS)
|
|
38
|
+
scale = min(max_w / im.width, max_h / im.height)
|
|
39
|
+
return im.resize((max(1, round(im.width * scale)), max(1, round(im.height * scale))),
|
|
40
|
+
Image.LANCZOS)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _despeckle(im: Image.Image) -> Image.Image:
|
|
44
|
+
"""Éteint tout pixel 0/255 dont aucun des 8 voisins n'a sa couleur.
|
|
45
|
+
|
|
46
|
+
Élimine le « poivre et sel » (reflets, sources de lumière ponctuelles)
|
|
47
|
+
sans toucher à la trame Floyd-Steinberg, dont les pixels ont toujours
|
|
48
|
+
des voisins diagonaux de même couleur.
|
|
49
|
+
"""
|
|
50
|
+
w, h = im.size
|
|
51
|
+
px = im.load()
|
|
52
|
+
out = im.copy()
|
|
53
|
+
po = out.load()
|
|
54
|
+
for y in range(h):
|
|
55
|
+
for x in range(w):
|
|
56
|
+
v = px[x, y]
|
|
57
|
+
alone = True
|
|
58
|
+
for dy in (-1, 0, 1):
|
|
59
|
+
for dx in (-1, 0, 1):
|
|
60
|
+
if dx == 0 and dy == 0:
|
|
61
|
+
continue
|
|
62
|
+
nx, ny = x + dx, y + dy
|
|
63
|
+
if 0 <= nx < w and 0 <= ny < h and px[nx, ny] == v:
|
|
64
|
+
alone = False
|
|
65
|
+
break
|
|
66
|
+
if not alone:
|
|
67
|
+
break
|
|
68
|
+
if alone:
|
|
69
|
+
po[x, y] = 255 - v
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _otsu(hist) -> int | None:
|
|
74
|
+
"""Seuil d'Otsu (variance inter-classes maximale) sur un histogramme 256 bins."""
|
|
75
|
+
total = sum(hist)
|
|
76
|
+
if total == 0:
|
|
77
|
+
return None
|
|
78
|
+
s_all = sum(i * h for i, h in enumerate(hist))
|
|
79
|
+
w0 = s0 = 0
|
|
80
|
+
best, thr = 0.0, None
|
|
81
|
+
for t in range(256):
|
|
82
|
+
w0 += hist[t]
|
|
83
|
+
if w0 == 0:
|
|
84
|
+
continue
|
|
85
|
+
w1 = total - w0
|
|
86
|
+
if w1 == 0:
|
|
87
|
+
break
|
|
88
|
+
s0 += t * hist[t]
|
|
89
|
+
m0, m1 = s0 / w0, (s_all - s0) / w1
|
|
90
|
+
var = w0 * w1 * (m0 - m1) ** 2
|
|
91
|
+
if var > best:
|
|
92
|
+
best, thr = var, t
|
|
93
|
+
return thr
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _prepare(im: Image.Image, max_w: int, max_h: int,
|
|
97
|
+
style: str = "photo", fit: str = "contain",
|
|
98
|
+
denoise: bool = True) -> Image.Image:
|
|
99
|
+
"""Niveaux de gris + redimensionnement, puis conversion 1 bit selon `style`.
|
|
100
|
+
|
|
101
|
+
photo : auto-contraste + netteté + trame Floyd-Steinberg (photos)
|
|
102
|
+
solid : seuillage net à l'écriture (logos, dessins au trait)
|
|
103
|
+
comic : solid + les formes noyées dans le noir retracées en contour blanc
|
|
104
|
+
edges : détection de contours -> traits blancs sur noir (photos très petites)
|
|
105
|
+
denoise: filtre médian avant conversion + suppression des pixels isolés après
|
|
106
|
+
"""
|
|
107
|
+
im = ImageOps.exif_transpose(im).convert("L")
|
|
108
|
+
if style == "edges":
|
|
109
|
+
# contours calculés en 2x puis épaissis, sinon ils disparaissent à la réduction
|
|
110
|
+
big = _fit(im, max_w * 2, max_h * 2, fit)
|
|
111
|
+
big = ImageOps.autocontrast(big, cutoff=1)
|
|
112
|
+
if denoise:
|
|
113
|
+
big = big.filter(ImageFilter.MedianFilter(3))
|
|
114
|
+
big = big.filter(ImageFilter.GaussianBlur(1)).filter(ImageFilter.FIND_EDGES)
|
|
115
|
+
ImageDraw.Draw(big).rectangle([0, 0, big.width - 1, big.height - 1],
|
|
116
|
+
outline=0, width=2) # cadre = artefact du bord
|
|
117
|
+
big = ImageOps.autocontrast(big).filter(ImageFilter.MaxFilter(3))
|
|
118
|
+
small = big.resize((max(1, big.width // 2), max(1, big.height // 2)), Image.LANCZOS)
|
|
119
|
+
# seuil adaptatif : on garde ~10 % des pixels (les contours les plus forts)
|
|
120
|
+
hist, acc, thr = small.histogram(), 0, 255
|
|
121
|
+
budget = small.width * small.height * 0.10
|
|
122
|
+
for lvl in range(255, -1, -1):
|
|
123
|
+
acc += hist[lvl]
|
|
124
|
+
if acc >= budget:
|
|
125
|
+
thr = lvl
|
|
126
|
+
break
|
|
127
|
+
thr = max(24, thr)
|
|
128
|
+
small = small.point(lambda p: 255 if p >= thr else 0)
|
|
129
|
+
return _despeckle(small) if denoise else small
|
|
130
|
+
im = _fit(im, max_w, max_h, fit)
|
|
131
|
+
im = ImageOps.autocontrast(im, cutoff=1)
|
|
132
|
+
if style == "comic":
|
|
133
|
+
if denoise:
|
|
134
|
+
im = im.filter(ImageFilter.MedianFilter(3))
|
|
135
|
+
solid = im.point(lambda p: 255 if p >= THRESHOLD else 0)
|
|
136
|
+
dark = ImageChops.invert(solid) # blanc = zones noires du seuillage
|
|
137
|
+
hist = im.histogram(dark.convert("1"))
|
|
138
|
+
thr = _otsu(hist)
|
|
139
|
+
total = sum(hist)
|
|
140
|
+
if thr is None or total == 0:
|
|
141
|
+
return solid
|
|
142
|
+
lo = sum(hist[:thr + 1])
|
|
143
|
+
if min(lo, total - lo) / total < 0.05: # zone noire quasi unie : rien à tracer
|
|
144
|
+
return solid
|
|
145
|
+
# frontière entre les deux populations sombres (ex. ciel / silhouette),
|
|
146
|
+
# tenue à 1 px des zones blanches pour ne pas doubler leurs bords
|
|
147
|
+
mid = ImageChops.multiply(im.point(lambda p, t=thr: 255 if p > t else 0), dark)
|
|
148
|
+
contour = ImageChops.difference(mid, mid.filter(ImageFilter.MinFilter(3)))
|
|
149
|
+
contour = ImageChops.multiply(contour, dark.filter(ImageFilter.MinFilter(3)))
|
|
150
|
+
out = ImageChops.lighter(solid, contour)
|
|
151
|
+
return _despeckle(out) if denoise else out
|
|
152
|
+
if style == "photo":
|
|
153
|
+
if denoise: # pas en solid : le médian arrondirait les logos nets
|
|
154
|
+
im = im.filter(ImageFilter.MedianFilter(3))
|
|
155
|
+
im = im.filter(ImageFilter.UnsharpMask(radius=1.2, percent=160, threshold=2))
|
|
156
|
+
im = im.convert("1", dither=Image.FLOYDSTEINBERG).convert("L")
|
|
157
|
+
if denoise:
|
|
158
|
+
im = _despeckle(im)
|
|
159
|
+
return im
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def load_source(path: str, max_w: int, max_h: int, style: str = "photo",
|
|
163
|
+
fit: str = "contain", denoise: bool = True) -> Image.Image:
|
|
164
|
+
"""Charge une image et la prépare comme 'sprite' 1 bit pour les effets."""
|
|
165
|
+
return _prepare(Image.open(path), max_w, max_h, style, fit, denoise)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def convert_animation(path: str, w: int, h: int, style: str = "photo",
|
|
169
|
+
fit: str = "contain", denoise: bool = True):
|
|
170
|
+
"""Convertit un GIF animé existant : frames centrées + durées d'origine."""
|
|
171
|
+
im = Image.open(path)
|
|
172
|
+
frames, durations = [], []
|
|
173
|
+
for raw in ImageSequence.Iterator(im):
|
|
174
|
+
sprite = _prepare(raw.convert("L"), w, h, style, fit, denoise)
|
|
175
|
+
f = canvas(w, h)
|
|
176
|
+
f.paste(sprite, ((w - sprite.width) // 2, (h - sprite.height) // 2))
|
|
177
|
+
frames.append(f)
|
|
178
|
+
durations.append(max(20, int(raw.info.get("duration", 100))))
|
|
179
|
+
return frames, durations
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def binarize(frame: Image.Image, invert: bool = False) -> Image.Image:
|
|
183
|
+
if invert:
|
|
184
|
+
return frame.point(lambda p: 0 if p >= THRESHOLD else 255).convert("1")
|
|
185
|
+
return frame.point(lambda p: 255 if p >= THRESHOLD else 0).convert("1")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def save_gif(frames, path: str, fps: int, invert: bool = False, scale: int = 1,
|
|
189
|
+
loop: int = 0, durations=None):
|
|
190
|
+
"""Binarise, agrandit (nearest) si demandé, et écrit le GIF bouclé.
|
|
191
|
+
|
|
192
|
+
`durations` (liste de ms par frame) prime sur `fps` — utile en conversion.
|
|
193
|
+
"""
|
|
194
|
+
out = []
|
|
195
|
+
for f in frames:
|
|
196
|
+
b = binarize(f, invert)
|
|
197
|
+
if scale > 1:
|
|
198
|
+
b = b.resize((b.width * scale, b.height * scale), Image.NEAREST)
|
|
199
|
+
out.append(b.convert("P"))
|
|
200
|
+
duration = durations if durations else max(20, int(round(1000 / fps)))
|
|
201
|
+
out[0].save(
|
|
202
|
+
path,
|
|
203
|
+
save_all=True,
|
|
204
|
+
append_images=out[1:],
|
|
205
|
+
duration=duration,
|
|
206
|
+
loop=loop,
|
|
207
|
+
optimize=True,
|
|
208
|
+
)
|
|
209
|
+
return len(out), (duration if isinstance(duration, int) else duration[0])
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: oledgifstudio
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Générateur de GIFs animés 1 bit pour écrans OLED (SteelSeries, SSD1306, ...)
|
|
5
|
+
Author-email: MaticeMrll <marill.matice@gmail.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 MaticeMrll
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/MaticeMrll/oled-gif-studio
|
|
29
|
+
Project-URL: Repository, https://github.com/MaticeMrll/oled-gif-studio
|
|
30
|
+
Project-URL: Issues, https://github.com/MaticeMrll/oled-gif-studio/issues
|
|
31
|
+
Keywords: oled,gif,steelseries,ssd1306,keyboard,pillow
|
|
32
|
+
Classifier: Programming Language :: Python :: 3
|
|
33
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
34
|
+
Classifier: Operating System :: OS Independent
|
|
35
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
36
|
+
Classifier: Environment :: Console
|
|
37
|
+
Requires-Python: >=3.10
|
|
38
|
+
Description-Content-Type: text/markdown
|
|
39
|
+
License-File: LICENSE
|
|
40
|
+
Requires-Dist: Pillow>=9.0
|
|
41
|
+
Dynamic: license-file
|
|
42
|
+
|
|
43
|
+
# oled-gif-studio
|
|
44
|
+
|
|
45
|
+
<p align="center">
|
|
46
|
+
<img src="docs/hero.gif" width="384" alt="OLED GIF STUDIO qui défile sur un écran 128x40">
|
|
47
|
+
</p>
|
|
48
|
+
|
|
49
|
+
Générateur de GIFs animés **1 bit** pour les petits écrans OLED : claviers/souris
|
|
50
|
+
SteelSeries, modules SSD1306/SH1106 (Arduino, Raspberry Pi, macropads QMK)...
|
|
51
|
+
|
|
52
|
+
Pas de modèle IA, pas d'API payante : tout est **procédural** (Python + Pillow),
|
|
53
|
+
donc instantané, gratuit et pixel-perfect. Un mode « description en langage
|
|
54
|
+
naturel » (français ou anglais) choisit l'effet et les paramètres pour toi.
|
|
55
|
+
|
|
56
|
+
> Tous les aperçus de ce README sont agrandis ×3 — la taille réelle des GIFs
|
|
57
|
+
> est celle de l'écran cible (128×40 par défaut).
|
|
58
|
+
|
|
59
|
+
## Installation
|
|
60
|
+
|
|
61
|
+
Aucune, si Python (≥ 3.10) + Pillow sont déjà installés. Sinon :
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
pip install Pillow
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Optionnel, pour avoir la commande `oledgif` partout :
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
pip install -e .
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Usage rapide
|
|
74
|
+
|
|
75
|
+
```powershell
|
|
76
|
+
# Depuis le dossier du projet :
|
|
77
|
+
python -m oledgif "HELLO WORLD" # effet auto → hello.gif
|
|
78
|
+
python -m oledgif "GG" -e slot -o gg.gif # machine à sous
|
|
79
|
+
python -m oledgif "PWNED" -e glitch -p rival # pour l'OLED d'une souris
|
|
80
|
+
python -m oledgif "42" -e matrix --size 128x64 --fps 20 # taille custom
|
|
81
|
+
|
|
82
|
+
# En langage naturel :
|
|
83
|
+
python -m oledgif -d "le texte 'BONJOUR' défile lentement"
|
|
84
|
+
python -m oledgif -d "'GAME OVER' qui clignote vite pendant 3s"
|
|
85
|
+
python -m oledgif -d "un radar qui balaie l'écran"
|
|
86
|
+
|
|
87
|
+
# À partir d'une image :
|
|
88
|
+
python -m oledgif -i photo.jpg --fit cover # photo plein écran, effet vhs
|
|
89
|
+
python -m oledgif -i comic.jpg --fit cover --style comic # illustration/BD
|
|
90
|
+
python -m oledgif -i logo.png -e bounce # le logo rebondit
|
|
91
|
+
python -m oledgif -i meme.gif # GIF animé converti tel quel
|
|
92
|
+
|
|
93
|
+
# Motifs sans texte (écrans de veille) :
|
|
94
|
+
python -m oledgif -e starfield
|
|
95
|
+
python -m oledgif -e plasma --seconds 6
|
|
96
|
+
|
|
97
|
+
# Un exemple de chaque effet dans ./samples :
|
|
98
|
+
python -m oledgif --demo
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Effets texte / image (`--list-effects`)
|
|
102
|
+
|
|
103
|
+
<table>
|
|
104
|
+
<tr>
|
|
105
|
+
<td align="center"><img src="docs/scroll.gif" alt="scroll"><br><code>scroll</code> — défilement en boucle parfaite</td>
|
|
106
|
+
<td align="center"><img src="docs/typewriter.gif" alt="typewriter"><br><code>typewriter</code> — machine à écrire avec curseur</td>
|
|
107
|
+
</tr>
|
|
108
|
+
<tr>
|
|
109
|
+
<td align="center"><img src="docs/wave.gif" alt="wave"><br><code>wave</code> — lettres en vague</td>
|
|
110
|
+
<td align="center"><img src="docs/blink.gif" alt="blink"><br><code>blink</code> — clignotement</td>
|
|
111
|
+
</tr>
|
|
112
|
+
<tr>
|
|
113
|
+
<td align="center"><img src="docs/bounce.gif" alt="bounce"><br><code>bounce</code> — rebond façon logo DVD</td>
|
|
114
|
+
<td align="center"><img src="docs/matrix.gif" alt="matrix"><br><code>matrix</code> — pluie de caractères qui révèle le texte</td>
|
|
115
|
+
</tr>
|
|
116
|
+
<tr>
|
|
117
|
+
<td align="center"><img src="docs/slot.gif" alt="slot"><br><code>slot</code> — chaque lettre cycle puis se fige</td>
|
|
118
|
+
<td align="center"><img src="docs/glitch.gif" alt="glitch"><br><code>glitch</code> — bandes décalées + bruit</td>
|
|
119
|
+
</tr>
|
|
120
|
+
<tr>
|
|
121
|
+
<td align="center"><img src="docs/pulse.gif" alt="pulse"><br><code>pulse</code> — battement de cœur</td>
|
|
122
|
+
<td align="center"><img src="docs/vhs.gif" alt="vhs"><br><code>vhs</code> — tracking façon cassette vidéo</td>
|
|
123
|
+
</tr>
|
|
124
|
+
<tr>
|
|
125
|
+
<td align="center"><img src="docs/slide.gif" alt="slide"><br><code>slide</code> — générique bas → haut</td>
|
|
126
|
+
<td></td>
|
|
127
|
+
</tr>
|
|
128
|
+
</table>
|
|
129
|
+
|
|
130
|
+
`typewriter`, `wave`, `matrix` et `slot` sont réservés au texte ; les autres
|
|
131
|
+
acceptent aussi une image (`--image`).
|
|
132
|
+
|
|
133
|
+
`--effect auto` (défaut) : `scroll` si le texte est trop large pour l'écran,
|
|
134
|
+
sinon `wave` ; `vhs` pour une image ; conversion directe pour un GIF animé.
|
|
135
|
+
|
|
136
|
+
## Motifs sans texte (écrans de veille)
|
|
137
|
+
|
|
138
|
+
<table>
|
|
139
|
+
<tr>
|
|
140
|
+
<td align="center"><img src="docs/starfield.gif" alt="starfield"><br><code>starfield</code> — hyperespace</td>
|
|
141
|
+
<td align="center"><img src="docs/plasma.gif" alt="plasma"><br><code>plasma</code> — plasma rétro tramé</td>
|
|
142
|
+
</tr>
|
|
143
|
+
<tr>
|
|
144
|
+
<td align="center"><img src="docs/life.gif" alt="life"><br><code>life</code> — jeu de la vie de Conway</td>
|
|
145
|
+
<td align="center"><img src="docs/eq.gif" alt="eq"><br><code>eq</code> — égaliseur audio</td>
|
|
146
|
+
</tr>
|
|
147
|
+
<tr>
|
|
148
|
+
<td align="center"><img src="docs/scope.gif" alt="scope"><br><code>scope</code> — oscilloscope</td>
|
|
149
|
+
<td align="center"><img src="docs/radar.gif" alt="radar"><br><code>radar</code> — balayage avec échos</td>
|
|
150
|
+
</tr>
|
|
151
|
+
</table>
|
|
152
|
+
|
|
153
|
+
## Images : les 4 styles de rendu
|
|
154
|
+
|
|
155
|
+
Convertir une image couleur en 1 bit sur 5120 pixels est un exercice de
|
|
156
|
+
sacrifice — le bon `--style` dépend de la source. Démonstration sur la même
|
|
157
|
+
scène (lune, silhouettes sur ciel en dégradé, lumières de ville) :
|
|
158
|
+
|
|
159
|
+
| Rendu | Style |
|
|
160
|
+
|---|---|
|
|
161
|
+
| <img src="docs/style_source.png" alt="source" width="288"> | **Source** (niveaux de gris) |
|
|
162
|
+
| <img src="docs/style_photo.png" alt="photo" width="288"> | `--style photo` (défaut) — auto-contraste + netteté + trame Floyd-Steinberg. Le bon choix pour les **photos réelles** : la trame simule les niveaux de gris. Sur une illustration, elle devient du bruit. |
|
|
163
|
+
| <img src="docs/style_solid.png" alt="solid" width="288"> | `--style solid` — seuillage net (alias `--no-dither`). Parfait pour les **logos** et dessins au trait… mais tout ce qui est sombre-sur-sombre disparaît dans le noir. |
|
|
164
|
+
| <img src="docs/style_comic.png" alt="comic" width="288"> | `--style comic` — comme `solid`, mais un 2ᵉ seuil automatique (Otsu) sépare les tons sombres et retrace en **contour blanc** les formes noyées dans le noir. Idéal pour les **illustrations/BD**. |
|
|
165
|
+
| <img src="docs/style_edges.png" alt="edges" width="288"> | `--style edges` — détection de contours, traits blancs sur fond noir. Look néon/filaire, souvent le plus lisible pour un **paysage ou un visage**. |
|
|
166
|
+
|
|
167
|
+
Autres options d'image :
|
|
168
|
+
|
|
169
|
+
- `--fit cover` — l'image remplit tout l'écran (recadrée) au lieu d'être
|
|
170
|
+
réduite à un timbre-poste au milieu. Recommandé pour les photos paysage.
|
|
171
|
+
- **Dé-bruitage** (actif par défaut) : un filtre médian + une suppression des
|
|
172
|
+
pixels isolés éliminent le « poivre et sel » (reflets, lampadaires, étoiles).
|
|
173
|
+
`--no-denoise` le désactive si tu veux justement garder ces points.
|
|
174
|
+
- Un **GIF animé** en entrée est converti frame par frame en conservant les
|
|
175
|
+
durées d'origine (avec `--effect auto`).
|
|
176
|
+
|
|
177
|
+
## Langage naturel (`--describe`)
|
|
178
|
+
|
|
179
|
+
```powershell
|
|
180
|
+
python -m oledgif -d "le texte 'BRB' qui rebondit pendant 5 secondes"
|
|
181
|
+
python -m oledgif -d "'REC' en mode vhs vintage"
|
|
182
|
+
python -m oledgif -d "a fast blinking 'GO' for 3 seconds"
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Le parseur (FR/EN, insensible aux accents) reconnaît l'effet par mots-clés
|
|
186
|
+
(« défile », « clignote », « tape », « radar », « vintage »…), le texte entre
|
|
187
|
+
guillemets, la durée (« pendant 3s ») et la vitesse (« lentement », « vite »).
|
|
188
|
+
|
|
189
|
+
## Écrans préconfigurés (`--list-presets`)
|
|
190
|
+
|
|
191
|
+
| Preset | Taille | Matériel |
|
|
192
|
+
|---------------|---------|----------|
|
|
193
|
+
| `apex` (défaut) | 128×40 | SteelSeries Apex 5 / 7 / Pro (OLED clavier) |
|
|
194
|
+
| `rival` | 128×36 | SteelSeries Rival 700 / 710 (OLED souris) |
|
|
195
|
+
| `oled-128x64` | 128×64 | SSD1306 / SH1106 / SSD1309 |
|
|
196
|
+
| `oled-128x32` | 128×32 | SSD1306 |
|
|
197
|
+
| `oled-96x16` | 96×16 | SSD1306 |
|
|
198
|
+
| `oled-256x64` | 256×64 | SSD1322 |
|
|
199
|
+
|
|
200
|
+
N'importe quelle autre taille : `--size LARGEURxHAUTEUR`.
|
|
201
|
+
|
|
202
|
+
## Options utiles
|
|
203
|
+
|
|
204
|
+
- `--charset alnum|digits|upper|letters|ascii` ou une chaîne littérale — le
|
|
205
|
+
jeu de caractères utilisé par `matrix` et `slot` (défaut : les 62
|
|
206
|
+
alphanumériques `0-9A-Za-z`).
|
|
207
|
+
- `--font chemin.ttf --font-size N` — police custom (défaut : Consolas,
|
|
208
|
+
taille ajustée à l'écran).
|
|
209
|
+
- `--invert` — noir sur blanc.
|
|
210
|
+
- `--scale 4` — GIF agrandi ×4 (pour prévisualiser confortablement).
|
|
211
|
+
- `--seed 42` — rendu reproductible pour les effets aléatoires.
|
|
212
|
+
- `--fps`, `--seconds`, `--speed` (px/s pour scroll).
|
|
213
|
+
|
|
214
|
+
## Envoyer le GIF sur l'écran SteelSeries
|
|
215
|
+
|
|
216
|
+
Deux options :
|
|
217
|
+
|
|
218
|
+
1. **SteelSeries GG** : dans les réglages de ton clavier (section OLED),
|
|
219
|
+
tu peux importer une image/GIF 128×40 — les GIFs générés ici sont au bon
|
|
220
|
+
format (1 bit, taille exacte).
|
|
221
|
+
2. **GameSense API** (programmatique) : comme le fait
|
|
222
|
+
[SteelseriesAnimGif](https://github.com/bolner/SteelseriesAnimGif), on peut
|
|
223
|
+
streamer les frames vers l'écran via l'API locale de SteelSeries GG.
|
|
224
|
+
Les GIFs produits ici sont directement exploitables frame par frame.
|
|
225
|
+
|
|
226
|
+
## Structure du projet
|
|
227
|
+
|
|
228
|
+
```
|
|
229
|
+
oledgif/
|
|
230
|
+
cli.py # ligne de commande + make_gif()
|
|
231
|
+
effects.py # les 11 effets texte/image (registre @effect)
|
|
232
|
+
patterns.py # les 6 motifs sans texte
|
|
233
|
+
render.py # préparation d'images, binarisation, écriture GIF
|
|
234
|
+
describe.py # parseur langage naturel FR/EN
|
|
235
|
+
presets.py # tailles d'écrans du marché
|
|
236
|
+
fonts.py # chargement de police
|
|
237
|
+
samples/ # un GIF d'exemple par effet (taille réelle, --demo)
|
|
238
|
+
docs/ # illustrations du README (agrandies ×3)
|
|
239
|
+
```
|
|
240
|
+
---
|
|
241
|
+
## Mon choix
|
|
242
|
+
|
|
243
|
+
<img src="docs/keyboard.jpg" alt="source" width="288">
|
|
244
|
+
|
|
245
|
+
Ninja Turtles !
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
oledgif/__init__.py,sha256=CfdGypEDKbwS5Gajw3xULiynQ5pKZSM1JzOtMF1jRNE,117
|
|
2
|
+
oledgif/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
|
|
3
|
+
oledgif/cli.py,sha256=m6Td-wPLArm7Kheph-oil0fEwiSOmheV4RsxZP4XKYA,8178
|
|
4
|
+
oledgif/describe.py,sha256=xwxe5l2OYCb5t-BlhHJC598i4y1AQ-4AyntJE9_hYTI,2521
|
|
5
|
+
oledgif/effects.py,sha256=r-x7ZtU6hJZth3H_eWCxWaT6Azhzv6G_3HzWPEiz1GY,11688
|
|
6
|
+
oledgif/fonts.py,sha256=Bnc5Y9IPYF2mZOp4UATuCeLr2kujuWwMj6nokoQ1mnU,1153
|
|
7
|
+
oledgif/patterns.py,sha256=T5T8jF_TU4jvZQdiR-dSFFUtZ1Y9w4shgVOWJdT8458,6072
|
|
8
|
+
oledgif/presets.py,sha256=YuVCfLX1kbJO7wG2viomByPkKHKUPlG2zX-XG3QbhwA,1161
|
|
9
|
+
oledgif/render.py,sha256=XLgZWQAabtJLIbrJu3XJT6JczhuenLIoCbVUcdAtvUE,8366
|
|
10
|
+
oledgifstudio-0.1.0.dist-info/licenses/LICENSE,sha256=uWzjux6kyECC8L0SRzSZPFIprrqBS5jHM7An9oyNQiw,1067
|
|
11
|
+
oledgifstudio-0.1.0.dist-info/METADATA,sha256=sMZBjxqQpTG-WH7mqp0uKOYsdOPZkhVJk_VB9fq2bFo,11104
|
|
12
|
+
oledgifstudio-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
13
|
+
oledgifstudio-0.1.0.dist-info/entry_points.txt,sha256=XKSU_9joU04QuaLpsI-St3pbiIWIYA6s8lWOEErxN80,45
|
|
14
|
+
oledgifstudio-0.1.0.dist-info/top_level.txt,sha256=17bnjJbfg_S4lecEKvGv3669PfigTYQxQntr4FXQcAQ,8
|
|
15
|
+
oledgifstudio-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MaticeMrll
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
oledgif
|