switchbot-e6-optimizer 1.0.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.
- switchbot_e6_optimizer/__init__.py +5 -0
- switchbot_e6_optimizer/__main__.py +6 -0
- switchbot_e6_optimizer/optimizer.py +519 -0
- switchbot_e6_optimizer/py.typed +0 -0
- switchbot_e6_optimizer-1.0.0.dist-info/METADATA +201 -0
- switchbot_e6_optimizer-1.0.0.dist-info/RECORD +9 -0
- switchbot_e6_optimizer-1.0.0.dist-info/WHEEL +4 -0
- switchbot_e6_optimizer-1.0.0.dist-info/entry_points.txt +3 -0
- switchbot_e6_optimizer-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
"""Prepare photos for the SwitchBot 13.3" AI Art Frame (E Ink Spectra 6, "E6").
|
|
2
|
+
|
|
3
|
+
The E6 panel shows only six physical inks (black/white/red/yellow/blue/green)
|
|
4
|
+
and renders dark, flat, and muted to mimic paper. The SwitchBot app then does
|
|
5
|
+
its own colour-mapping and dithering on upload, so the right preparation is
|
|
6
|
+
continuous-tone only: crop to the panel's 4:3, boost saturation and contrast,
|
|
7
|
+
open the shadows, nudge warm, and sharpen -- then let the app do the single,
|
|
8
|
+
final quantisation. Do **not** pre-dither: the app re-dithers and the result is
|
|
9
|
+
muddy, noisy double-dithered output.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import contextlib
|
|
14
|
+
import math
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from PIL import Image, ImageEnhance, ImageFilter, ImageOps
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
from pillow_heif import register_heif_opener
|
|
24
|
+
except ImportError: # default dependency, but degrade gracefully if absent
|
|
25
|
+
register_heif_opener = None
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
__version__ = version("switchbot-e6-optimizer")
|
|
29
|
+
except PackageNotFoundError: # running from a source checkout, not installed
|
|
30
|
+
__version__ = "0+unknown"
|
|
31
|
+
|
|
32
|
+
# Native resolution of the 13.3" SwitchBot / Spectra 6 panel (4:3).
|
|
33
|
+
PANEL_LONG = 1600
|
|
34
|
+
PANEL_SHORT = 1200
|
|
35
|
+
|
|
36
|
+
# Recognised extensions when a folder is passed. HEIC/HEIF (iPhone photos) work
|
|
37
|
+
# via pillow-heif (a default dependency); the guard below keeps the tool working
|
|
38
|
+
# even if that plugin is somehow unavailable.
|
|
39
|
+
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tif", ".tiff"}
|
|
40
|
+
if register_heif_opener is not None:
|
|
41
|
+
register_heif_opener()
|
|
42
|
+
_IMAGE_EXTS |= {".heic", ".heif"}
|
|
43
|
+
IMAGE_EXTS = frozenset(_IMAGE_EXTS)
|
|
44
|
+
|
|
45
|
+
# Pillow's default decompression-bomb ceiling (~89 MP) is too low for
|
|
46
|
+
# high-resolution art scans and modern camera output. Raise it to a high but
|
|
47
|
+
# BOUNDED value -- never None: a bounded limit still stops a malicious few-MB
|
|
48
|
+
# file that declares a multi-gigapixel raster from decoding into many GB of RAM
|
|
49
|
+
# before we ever downscale it. Pillow warns above this and raises
|
|
50
|
+
# DecompressionBombError above 2x it (caught per file by the batch loop, so one
|
|
51
|
+
# hostile image can't OOM the whole run).
|
|
52
|
+
Image.MAX_IMAGE_PIXELS = 500_000_000 # ~500 MP
|
|
53
|
+
|
|
54
|
+
# The tunable pipeline knobs, in pipeline order.
|
|
55
|
+
TUNING_KEYS = (
|
|
56
|
+
"saturation",
|
|
57
|
+
"contrast",
|
|
58
|
+
"brightness",
|
|
59
|
+
"shadow_lift",
|
|
60
|
+
"warmth",
|
|
61
|
+
"sharpen",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Intensity presets, one value per TUNING_KEYS entry. "medium" is the sourced
|
|
65
|
+
# default; "none" disables every tonal step (crop + resize only). Values stay
|
|
66
|
+
# within the research-backed ranges (saturation +40..+100 %, contrast +20..+30 %).
|
|
67
|
+
# sat con bri shadow warm sharpen
|
|
68
|
+
_PRESET_VALUES = {
|
|
69
|
+
"none": (0, 0, 0, 0, 0, 0),
|
|
70
|
+
"low": (40, 18, 5, 10, 3, 60),
|
|
71
|
+
"medium": (60, 25, 8, 18, 5, 80),
|
|
72
|
+
"high": (100, 30, 12, 26, 8, 100),
|
|
73
|
+
}
|
|
74
|
+
PRESETS: dict[str, dict[str, float]] = {
|
|
75
|
+
name: dict(zip(TUNING_KEYS, values, strict=True))
|
|
76
|
+
for name, values in _PRESET_VALUES.items()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _tone_curve_lut(brightness: float, shadow_lift: float) -> list[int]:
|
|
81
|
+
"""Build a 256-entry curve that lifts midtones and opens shadows.
|
|
82
|
+
|
|
83
|
+
The curve holds pure white fixed (255 stays 255) and is applied *after*
|
|
84
|
+
contrast, so it has the final say on the black point -- which is how you get
|
|
85
|
+
"more contrast *and* lifted shadows" at once. ``shadow_lift`` raises pure
|
|
86
|
+
black to a charcoal floor, matching the panel's real (non-black) darkest tone.
|
|
87
|
+
"""
|
|
88
|
+
brightness = max(0.0, brightness)
|
|
89
|
+
shadow_lift = max(0.0, shadow_lift)
|
|
90
|
+
gamma = 1.0 + brightness / 100.0 + shadow_lift / 200.0 # > 1 brightens midtones
|
|
91
|
+
floor = (shadow_lift / 40.0) * (40.0 / 255.0) # up to ~40/255 charcoal
|
|
92
|
+
lut: list[int] = []
|
|
93
|
+
for i in range(256):
|
|
94
|
+
x = i / 255.0
|
|
95
|
+
y = floor + (1.0 - floor) * (x ** (1.0 / gamma))
|
|
96
|
+
lut.append(max(0, min(255, round(y * 255.0))))
|
|
97
|
+
return lut
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _apply_warmth(img: Image.Image, warmth: float) -> Image.Image:
|
|
101
|
+
"""Nudge the white balance warm by scaling red up and blue down.
|
|
102
|
+
|
|
103
|
+
E6 white is paper-grey and its blues render weakly, so cool images look
|
|
104
|
+
muddy. ``warmth`` is a gentle 0..20 amount.
|
|
105
|
+
"""
|
|
106
|
+
if warmth <= 0:
|
|
107
|
+
return img
|
|
108
|
+
red_factor = 1.0 + warmth / 200.0
|
|
109
|
+
blue_factor = 1.0 - warmth / 200.0
|
|
110
|
+
red, green, blue = img.split()
|
|
111
|
+
red = red.point(lambda v: min(255, round(v * red_factor)))
|
|
112
|
+
blue = blue.point(lambda v: min(255, round(v * blue_factor)))
|
|
113
|
+
return Image.merge("RGB", (red, green, blue))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _fit_to_panel(
|
|
117
|
+
img: Image.Image, size: tuple[int, int], *, crop: bool
|
|
118
|
+
) -> Image.Image:
|
|
119
|
+
"""Fit the image to ``size``.
|
|
120
|
+
|
|
121
|
+
With ``crop=True`` this is a centred crop-to-fill (cover), matching the app's
|
|
122
|
+
auto-crop-to-4:3 but under your control. With ``crop=False`` the whole image
|
|
123
|
+
is contained and letterboxed onto a white canvas.
|
|
124
|
+
"""
|
|
125
|
+
if crop:
|
|
126
|
+
return ImageOps.fit(
|
|
127
|
+
img, size, method=Image.Resampling.LANCZOS, centering=(0.5, 0.5)
|
|
128
|
+
)
|
|
129
|
+
fitted = ImageOps.contain(img, size, method=Image.Resampling.LANCZOS)
|
|
130
|
+
canvas = Image.new("RGB", size, (255, 255, 255))
|
|
131
|
+
offset = ((size[0] - fitted.width) // 2, (size[1] - fitted.height) // 2)
|
|
132
|
+
canvas.paste(fitted, offset)
|
|
133
|
+
return canvas
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def optimize(img: Image.Image, args: argparse.Namespace) -> Image.Image:
|
|
137
|
+
"""Run the continuous-tone E6 preparation pipeline on a single image.
|
|
138
|
+
|
|
139
|
+
No dithering happens here -- the SwitchBot app performs the final
|
|
140
|
+
quantisation. The result is meant to look over-saturated and over-bright on a
|
|
141
|
+
normal monitor; that is correct for E6.
|
|
142
|
+
"""
|
|
143
|
+
img = ImageOps.exif_transpose(img) # honour camera rotation
|
|
144
|
+
|
|
145
|
+
# Flatten transparency onto white (JPEG has no alpha; keeps PNG consistent).
|
|
146
|
+
if img.mode in ("RGBA", "LA", "P"):
|
|
147
|
+
img = img.convert("RGBA")
|
|
148
|
+
background = Image.new("RGBA", img.size, (255, 255, 255, 255))
|
|
149
|
+
img = Image.alpha_composite(background, img)
|
|
150
|
+
img = img.convert("RGB")
|
|
151
|
+
|
|
152
|
+
img = _fit_to_panel(img, (args.width, args.height), crop=not args.no_crop)
|
|
153
|
+
|
|
154
|
+
# 1. Contrast -- compensate the panel's compressed (~26:1) dynamic range.
|
|
155
|
+
if args.contrast:
|
|
156
|
+
img = ImageEnhance.Contrast(img).enhance(1.0 + args.contrast / 100.0)
|
|
157
|
+
# 2. Saturation -- the single biggest lever for E6. Push hard.
|
|
158
|
+
if args.saturation:
|
|
159
|
+
img = ImageEnhance.Color(img).enhance(1.0 + args.saturation / 100.0)
|
|
160
|
+
# 3. Warmth.
|
|
161
|
+
img = _apply_warmth(img, args.warmth)
|
|
162
|
+
# 4. Brightness + shadow lift, applied last so shadows stay open after
|
|
163
|
+
# contrast (the same curve is applied to R, G and B).
|
|
164
|
+
if args.brightness > 0 or args.shadow_lift > 0:
|
|
165
|
+
img = img.point(_tone_curve_lut(args.brightness, args.shadow_lift) * 3)
|
|
166
|
+
# 5. Moderate output sharpening (dithering and low resolution soften detail).
|
|
167
|
+
if args.sharpen > 0:
|
|
168
|
+
img = img.filter(
|
|
169
|
+
ImageFilter.UnsharpMask(radius=2, percent=round(args.sharpen), threshold=2)
|
|
170
|
+
)
|
|
171
|
+
return img
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _style(text: str, *codes: str) -> str:
|
|
175
|
+
"""Wrap text in ANSI styles when writing to a terminal; plain text otherwise.
|
|
176
|
+
|
|
177
|
+
Honours the NO_COLOR convention and only styles a real TTY, so piped or
|
|
178
|
+
redirected output stays clean.
|
|
179
|
+
"""
|
|
180
|
+
if not codes or "NO_COLOR" in os.environ or not sys.stdout.isatty():
|
|
181
|
+
return text
|
|
182
|
+
return f"\033[{';'.join(codes)}m{text}\033[0m"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _out_path(src: Path, out_dir: Path | None, suffix: str, ext: str) -> Path:
|
|
186
|
+
dest_dir = out_dir if out_dir else src.parent
|
|
187
|
+
return dest_dir / f"{src.stem}{suffix}.{ext}"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def process_file(src: Path, args: argparse.Namespace) -> Path:
|
|
191
|
+
"""Optimise one image file and save it, returning the output path."""
|
|
192
|
+
out_dir = Path(args.out).expanduser() if args.out else None
|
|
193
|
+
ext = "png" if args.format == "png" else "jpg"
|
|
194
|
+
dest = _out_path(src, out_dir, args.suffix, ext)
|
|
195
|
+
|
|
196
|
+
# Never write over the source (e.g. --suffix "" with a matching format).
|
|
197
|
+
# The path compare alone is defeated by case-insensitive filesystems (the
|
|
198
|
+
# macOS/Windows default: Photo.PNG vs Photo.png), so also check file
|
|
199
|
+
# identity via samefile() whenever dest already exists.
|
|
200
|
+
if dest.resolve() == src.resolve() or (dest.exists() and dest.samefile(src)):
|
|
201
|
+
raise ValueError(
|
|
202
|
+
f"refusing to overwrite the source file {src.name}; set --suffix or --out"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
with Image.open(src) as image:
|
|
206
|
+
result = optimize(image, args)
|
|
207
|
+
|
|
208
|
+
if out_dir:
|
|
209
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
210
|
+
if args.format == "png":
|
|
211
|
+
result.save(dest, "PNG", optimize=True)
|
|
212
|
+
else:
|
|
213
|
+
result.save(dest, "JPEG", quality=args.quality, subsampling=0, optimize=True)
|
|
214
|
+
print(
|
|
215
|
+
f" {_style('✓', '32')} {src.name} → {dest} ({result.width}×{result.height})"
|
|
216
|
+
)
|
|
217
|
+
return dest
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def gather_inputs(paths: list[str], suffix: str = "") -> list[Path]:
|
|
221
|
+
"""Expand the positional inputs (files and/or folders) into a list of files.
|
|
222
|
+
|
|
223
|
+
When expanding a folder, files whose stem already ends with ``suffix`` look
|
|
224
|
+
like this tool's own output, so they are skipped -- LOUDLY, never silently --
|
|
225
|
+
to keep a re-run from double-processing ``*_e6`` files. Explicitly named
|
|
226
|
+
files are always honoured, which is also the escape hatch for a wanted file
|
|
227
|
+
that merely happens to end with the suffix.
|
|
228
|
+
"""
|
|
229
|
+
files: list[Path] = []
|
|
230
|
+
for raw_path in paths:
|
|
231
|
+
path = Path(raw_path).expanduser()
|
|
232
|
+
if path.is_dir():
|
|
233
|
+
for candidate in sorted(path.iterdir()):
|
|
234
|
+
if candidate.suffix.lower() not in IMAGE_EXTS:
|
|
235
|
+
continue
|
|
236
|
+
if suffix and candidate.stem.endswith(suffix):
|
|
237
|
+
print(
|
|
238
|
+
f" ↷ skipping {candidate.name} (already ends with "
|
|
239
|
+
f"{suffix!r}; pass the file explicitly to force)",
|
|
240
|
+
file=sys.stderr,
|
|
241
|
+
)
|
|
242
|
+
continue
|
|
243
|
+
files.append(candidate)
|
|
244
|
+
elif path.is_file():
|
|
245
|
+
files.append(path)
|
|
246
|
+
else:
|
|
247
|
+
print(f" ! skipping (not found): {path}", file=sys.stderr)
|
|
248
|
+
return files
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
_EPILOG = """\
|
|
252
|
+
how it works:
|
|
253
|
+
The E6 panel shows only 6 muted inks and renders dark and flat, and the
|
|
254
|
+
SwitchBot app dithers your image on upload. This tool does the continuous-tone
|
|
255
|
+
prep the panel needs, then leaves the dithering to the app. The output should
|
|
256
|
+
look a little too vivid and too bright on your monitor -- that is correct for
|
|
257
|
+
E6. Do NOT pre-dither: the app re-dithers and you get muddy, noisy results.
|
|
258
|
+
|
|
259
|
+
intensity presets (set every knob at once; medium = sourced default):
|
|
260
|
+
none crop + resize only, no tonal changes (A/B against the app)
|
|
261
|
+
low sat +40 con +18 bri +5 shadows 10 warmth 3 sharpen 60
|
|
262
|
+
medium sat +60 con +25 bri +8 shadows 18 warmth 5 sharpen 80
|
|
263
|
+
high sat +100 con +30 bri +12 shadows 26 warmth 8 sharpen 100
|
|
264
|
+
Any single tuning flag overrides just that value on top of the chosen preset.
|
|
265
|
+
|
|
266
|
+
examples:
|
|
267
|
+
# one photo, default medium intensity -> photo_e6.png next to it
|
|
268
|
+
switchbot-e6 photo.jpg
|
|
269
|
+
|
|
270
|
+
# a whole folder into ./out, portrait mount
|
|
271
|
+
switchbot-e6 ~/Pictures/art --orientation portrait --out ./out
|
|
272
|
+
|
|
273
|
+
# punchier preset for flat, boldly-coloured art
|
|
274
|
+
switchbot-e6 poster.png --intensity high
|
|
275
|
+
|
|
276
|
+
# ride the high preset but pin saturation to exactly +80%
|
|
277
|
+
switchbot-e6 poster.png --intensity high --saturation 80
|
|
278
|
+
|
|
279
|
+
# crop + resize only, no tonal edits
|
|
280
|
+
switchbot-e6 photo.jpg --raw
|
|
281
|
+
|
|
282
|
+
After exporting, upload the *_e6 file to the SwitchBot app as-is and fine-tune
|
|
283
|
+
with the app's own saturation/contrast sliders once you see it on the panel.
|
|
284
|
+
"""
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
288
|
+
"""Construct the argument parser, with grouped options and a verbose epilog."""
|
|
289
|
+
parser = argparse.ArgumentParser(
|
|
290
|
+
prog="switchbot-e6",
|
|
291
|
+
description=(
|
|
292
|
+
'Prepare photos for the SwitchBot 13.3" AI Art Frame (E Ink Spectra 6). '
|
|
293
|
+
"Crops to the panel and applies the tonal prep it needs; the SwitchBot "
|
|
294
|
+
"app does the final dithering, so this tool never dithers."
|
|
295
|
+
),
|
|
296
|
+
epilog=_EPILOG,
|
|
297
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
298
|
+
)
|
|
299
|
+
parser.add_argument(
|
|
300
|
+
"--version", action="version", version=f"%(prog)s {__version__}"
|
|
301
|
+
)
|
|
302
|
+
parser.add_argument(
|
|
303
|
+
"inputs",
|
|
304
|
+
nargs="*",
|
|
305
|
+
metavar="INPUT",
|
|
306
|
+
help="image file(s) and/or folder(s) to optimise",
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
output = parser.add_argument_group("output options")
|
|
310
|
+
output.add_argument(
|
|
311
|
+
"-o",
|
|
312
|
+
"--output",
|
|
313
|
+
"--out",
|
|
314
|
+
dest="out",
|
|
315
|
+
metavar="DIR",
|
|
316
|
+
help="output directory, created if missing (default: next to each source)",
|
|
317
|
+
)
|
|
318
|
+
output.add_argument(
|
|
319
|
+
"--suffix", default="_e6", help="output filename suffix (default: _e6)"
|
|
320
|
+
)
|
|
321
|
+
output.add_argument(
|
|
322
|
+
"--format",
|
|
323
|
+
choices=("png", "jpg"),
|
|
324
|
+
default="png",
|
|
325
|
+
help="output format (default: png)",
|
|
326
|
+
)
|
|
327
|
+
output.add_argument(
|
|
328
|
+
"--quality", type=int, default=100, help="JPEG quality 1-100 (default: 100)"
|
|
329
|
+
)
|
|
330
|
+
output.add_argument(
|
|
331
|
+
"--orientation",
|
|
332
|
+
choices=("landscape", "portrait"),
|
|
333
|
+
default="landscape",
|
|
334
|
+
help="panel mount (default: landscape)",
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
tuning = parser.add_argument_group(
|
|
338
|
+
"tuning (the E6 look)",
|
|
339
|
+
"Pick an --intensity preset, or override individual knobs. An explicit "
|
|
340
|
+
"value below wins over the preset for that one knob.",
|
|
341
|
+
)
|
|
342
|
+
tuning.add_argument(
|
|
343
|
+
"--intensity",
|
|
344
|
+
choices=("none", "low", "medium", "high"),
|
|
345
|
+
default="medium",
|
|
346
|
+
help="overall strength preset (default: medium = original tuning)",
|
|
347
|
+
)
|
|
348
|
+
tuning.add_argument(
|
|
349
|
+
"--raw",
|
|
350
|
+
action="store_true",
|
|
351
|
+
help="alias for --intensity none: crop + resize only, no tonal edits",
|
|
352
|
+
)
|
|
353
|
+
tuning.add_argument(
|
|
354
|
+
"--saturation",
|
|
355
|
+
type=float,
|
|
356
|
+
default=None,
|
|
357
|
+
help="saturation boost %% (preset: low 40 / med 60 / high 100)",
|
|
358
|
+
)
|
|
359
|
+
tuning.add_argument(
|
|
360
|
+
"--contrast",
|
|
361
|
+
type=float,
|
|
362
|
+
default=None,
|
|
363
|
+
help="contrast boost %% (preset: low 18 / med 25 / high 30)",
|
|
364
|
+
)
|
|
365
|
+
tuning.add_argument(
|
|
366
|
+
"--brightness",
|
|
367
|
+
type=float,
|
|
368
|
+
default=None,
|
|
369
|
+
help="midtone brightness lift %% (preset: low 5 / med 8 / high 12)",
|
|
370
|
+
)
|
|
371
|
+
tuning.add_argument(
|
|
372
|
+
"--shadow-lift",
|
|
373
|
+
dest="shadow_lift",
|
|
374
|
+
type=float,
|
|
375
|
+
default=None,
|
|
376
|
+
help="open shadows / lift blacks, 0-40 (preset: low 10 / med 18 / high 26)",
|
|
377
|
+
)
|
|
378
|
+
tuning.add_argument(
|
|
379
|
+
"--warmth",
|
|
380
|
+
type=float,
|
|
381
|
+
default=None,
|
|
382
|
+
help="warm white-balance nudge, 0-20 (preset: low 3 / med 5 / high 8)",
|
|
383
|
+
)
|
|
384
|
+
tuning.add_argument(
|
|
385
|
+
"--sharpen",
|
|
386
|
+
type=float,
|
|
387
|
+
default=None,
|
|
388
|
+
help="output sharpen amount %%, 0 = off (preset: low 60 / med 80 / high 100)",
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
sizing = parser.add_argument_group("sizing")
|
|
392
|
+
sizing.add_argument(
|
|
393
|
+
"--no-crop", action="store_true", help="letterbox instead of crop-to-fill 4:3"
|
|
394
|
+
)
|
|
395
|
+
sizing.add_argument(
|
|
396
|
+
"--size", metavar="WxH", help="override panel size, e.g. 1600x1200"
|
|
397
|
+
)
|
|
398
|
+
return parser
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def apply_preset(args: argparse.Namespace) -> None:
|
|
402
|
+
"""Fill any tuning value left unset (None) from the chosen --intensity preset.
|
|
403
|
+
|
|
404
|
+
Explicitly passed values are left untouched, so you can ride a preset and
|
|
405
|
+
override just one knob. ``--raw`` is treated as ``--intensity none``.
|
|
406
|
+
"""
|
|
407
|
+
if args.raw:
|
|
408
|
+
args.intensity = "none"
|
|
409
|
+
preset = PRESETS[args.intensity]
|
|
410
|
+
for key in TUNING_KEYS:
|
|
411
|
+
if getattr(args, key) is None:
|
|
412
|
+
setattr(args, key, preset[key])
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def resolve_size(args: argparse.Namespace) -> None:
|
|
416
|
+
"""Set ``args.width`` and ``args.height`` from --size and --orientation.
|
|
417
|
+
|
|
418
|
+
An explicit --size is used exactly as typed. --orientation only selects the
|
|
419
|
+
default panel size (1600x1200 landscape vs 1200x1600 portrait).
|
|
420
|
+
"""
|
|
421
|
+
if args.size:
|
|
422
|
+
try:
|
|
423
|
+
width, height = (int(x) for x in args.size.lower().split("x"))
|
|
424
|
+
except ValueError:
|
|
425
|
+
sys.exit(f"--size must look like 1600x1200, got: {args.size!r}")
|
|
426
|
+
if width <= 0 or height <= 0:
|
|
427
|
+
sys.exit(f"--size dimensions must be positive, got: {args.size!r}")
|
|
428
|
+
elif args.orientation == "portrait":
|
|
429
|
+
width, height = PANEL_SHORT, PANEL_LONG
|
|
430
|
+
else:
|
|
431
|
+
width, height = PANEL_LONG, PANEL_SHORT
|
|
432
|
+
args.width, args.height = width, height
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def validate_args(args: argparse.Namespace) -> None:
|
|
436
|
+
"""Reject nonsensical numeric input (negatives, NaN/inf, out-of-range quality)."""
|
|
437
|
+
for key in TUNING_KEYS:
|
|
438
|
+
value = float(getattr(args, key))
|
|
439
|
+
if not math.isfinite(value) or value < 0:
|
|
440
|
+
flag = "--" + key.replace("_", "-")
|
|
441
|
+
sys.exit(f"{flag} must be a finite number >= 0, got: {getattr(args, key)}")
|
|
442
|
+
if not 1 <= args.quality <= 100:
|
|
443
|
+
sys.exit(f"--quality must be between 1 and 100, got: {args.quality}")
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _enable_ansi_on_windows() -> None:
|
|
447
|
+
"""Enable ANSI escape processing on Windows consoles (a no-op elsewhere)."""
|
|
448
|
+
if sys.platform != "win32":
|
|
449
|
+
return
|
|
450
|
+
with contextlib.suppress(Exception):
|
|
451
|
+
import ctypes
|
|
452
|
+
|
|
453
|
+
kernel32 = ctypes.windll.kernel32
|
|
454
|
+
handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE
|
|
455
|
+
mode = ctypes.c_uint()
|
|
456
|
+
if kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
|
|
457
|
+
# ENABLE_VIRTUAL_TERMINAL_PROCESSING
|
|
458
|
+
kernel32.SetConsoleMode(handle, mode.value | 0x0004)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def main(argv: list[str] | None = None) -> int:
|
|
462
|
+
"""Command-line entry point. Returns a process exit code."""
|
|
463
|
+
_enable_ansi_on_windows()
|
|
464
|
+
for stream in (sys.stdout, sys.stderr):
|
|
465
|
+
# tolerate non-UTF-8 consoles instead of crashing on the '✓' glyph
|
|
466
|
+
with contextlib.suppress(AttributeError, ValueError):
|
|
467
|
+
stream.reconfigure(errors="replace")
|
|
468
|
+
|
|
469
|
+
argv = sys.argv[1:] if argv is None else list(argv)
|
|
470
|
+
parser = build_parser()
|
|
471
|
+
args = parser.parse_args(argv)
|
|
472
|
+
|
|
473
|
+
if not args.inputs: # no images given -> show the full help, not a terse error
|
|
474
|
+
parser.print_help()
|
|
475
|
+
return 0
|
|
476
|
+
|
|
477
|
+
apply_preset(args)
|
|
478
|
+
resolve_size(args)
|
|
479
|
+
validate_args(args)
|
|
480
|
+
|
|
481
|
+
files = gather_inputs(args.inputs, args.suffix)
|
|
482
|
+
if not files:
|
|
483
|
+
print("No input images found.", file=sys.stderr)
|
|
484
|
+
return 1
|
|
485
|
+
|
|
486
|
+
print(
|
|
487
|
+
_style(
|
|
488
|
+
f"🎨 Optimising {len(files)} image(s) for E6 "
|
|
489
|
+
f"@ {args.width}×{args.height} · intensity: {args.intensity}",
|
|
490
|
+
"1",
|
|
491
|
+
)
|
|
492
|
+
)
|
|
493
|
+
print(
|
|
494
|
+
_style(
|
|
495
|
+
f" sat +{args.saturation:g}% · con +{args.contrast:g}% · "
|
|
496
|
+
f"bri +{args.brightness:g}% · shadows {args.shadow_lift:g} · "
|
|
497
|
+
f"warmth {args.warmth:g}",
|
|
498
|
+
"2",
|
|
499
|
+
)
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
succeeded = 0
|
|
503
|
+
for image_file in files:
|
|
504
|
+
try:
|
|
505
|
+
process_file(image_file, args)
|
|
506
|
+
succeeded += 1
|
|
507
|
+
except Exception as error: # keep the batch going if one file is bad
|
|
508
|
+
mark = _style("✗", "31")
|
|
509
|
+
print(f" {mark} {image_file.name} — {error}", file=sys.stderr)
|
|
510
|
+
|
|
511
|
+
ok = succeeded == len(files)
|
|
512
|
+
print(
|
|
513
|
+
_style(
|
|
514
|
+
f"{'✅' if ok else '⚠️'} Done: {succeeded}/{len(files)} succeeded.",
|
|
515
|
+
"1",
|
|
516
|
+
"32" if ok else "33",
|
|
517
|
+
)
|
|
518
|
+
)
|
|
519
|
+
return 0 if ok else 1 # non-zero when any file failed, for scripting
|
|
File without changes
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: switchbot-e6-optimizer
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Optimise photos for the SwitchBot 13.3" AI Art Frame (E Ink Spectra 6).
|
|
5
|
+
Keywords: switchbot,e-ink,eink,spectra6,epaper,image,pillow
|
|
6
|
+
Author: switchbot-e6-optimizer contributors
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
15
|
+
Requires-Dist: pillow>=12
|
|
16
|
+
Requires-Dist: pillow-heif>=1
|
|
17
|
+
Requires-Python: >=3.14
|
|
18
|
+
Project-URL: Homepage, https://github.com/hamzic/switchbot-e6-optimizer
|
|
19
|
+
Project-URL: Repository, https://github.com/hamzic/switchbot-e6-optimizer
|
|
20
|
+
Project-URL: Issues, https://github.com/hamzic/switchbot-e6-optimizer/issues
|
|
21
|
+
Project-URL: Changelog, https://github.com/hamzic/switchbot-e6-optimizer/blob/main/CHANGELOG.md
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# switchbot-e6-optimizer
|
|
25
|
+
|
|
26
|
+
[](https://github.com/hamzic/switchbot-e6-optimizer/actions/workflows/ci.yml)
|
|
27
|
+
|
|
28
|
+
Make photos look better on the **SwitchBot 13.3" AI Art Frame** and other
|
|
29
|
+
**E Ink Spectra 6 ("E6")** panels.
|
|
30
|
+
|
|
31
|
+
E6 panels show only six muted inks and render dark, flat, and low-contrast to
|
|
32
|
+
mimic paper. Uploaded straight from your phone, most photos come out dull and
|
|
33
|
+
muddy. This tool applies the continuous-tone preparation the panel needs -
|
|
34
|
+
boosted saturation and contrast, opened shadows, a warm nudge, and sharpening -
|
|
35
|
+
then leaves the dithering to the SwitchBot app.
|
|
36
|
+
|
|
37
|
+
> **Do not pre-dither for the SwitchBot app.** The app does its own colour
|
|
38
|
+
> mapping and dithering on upload. If you dither first you get double-dithering:
|
|
39
|
+
> visible noise, muddy colour.
|
|
40
|
+
|
|
41
|
+
## Why the panel needs this
|
|
42
|
+
|
|
43
|
+
| Adjustment | Compensates for |
|
|
44
|
+
| --- | --- |
|
|
45
|
+
| Saturation (biggest lever) | Muted six-ink gamut |
|
|
46
|
+
| Contrast | Compressed (~26:1) dynamic range |
|
|
47
|
+
| Brightness + shadow lift | Panel renders dark and crushes shadows |
|
|
48
|
+
| Warmth | Paper-grey white, weak blues |
|
|
49
|
+
| Sharpen | Detail lost to dithering and sub-native resolution |
|
|
50
|
+
| **No dithering** | The app dithers; doing it twice = mud |
|
|
51
|
+
|
|
52
|
+
## Requirements
|
|
53
|
+
|
|
54
|
+
- Python **3.14+**
|
|
55
|
+
- [uv](https://docs.astral.sh/uv/) (manages the environment, tests, and linting)
|
|
56
|
+
|
|
57
|
+
## Install
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
git clone https://github.com/hamzic/switchbot-e6-optimizer
|
|
61
|
+
cd switchbot-e6-optimizer
|
|
62
|
+
uv sync # create the venv and install everything
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Run it with `uv run`:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
uv run switchbot-e6 photo.jpg
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Or install the CLI as a standalone tool:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
uv tool install .
|
|
75
|
+
switchbot-e6 photo.jpg
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
To uninstall the tool later:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
uv tool uninstall switchbot-e6-optimizer
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Usage
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
# one photo, default "medium" intensity -> photo_e6.png next to it
|
|
88
|
+
switchbot-e6 photo.jpg
|
|
89
|
+
|
|
90
|
+
# a whole folder into ./out, portrait mount
|
|
91
|
+
switchbot-e6 ~/Pictures/art --orientation portrait --out ./out
|
|
92
|
+
|
|
93
|
+
# punchier preset for flat, boldly-coloured art
|
|
94
|
+
switchbot-e6 poster.png --intensity high
|
|
95
|
+
|
|
96
|
+
# ride the high preset but pin saturation to exactly +80%
|
|
97
|
+
switchbot-e6 poster.png --intensity high --saturation 80
|
|
98
|
+
|
|
99
|
+
# crop + resize only, no tonal edits (A/B against the app's own processing)
|
|
100
|
+
switchbot-e6 photo.jpg --raw
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Inputs can be files or whole folders. Running with no arguments prints the full
|
|
104
|
+
help. You can also run it as a module: `python -m switchbot_e6_optimizer`.
|
|
105
|
+
|
|
106
|
+
### Supported formats
|
|
107
|
+
|
|
108
|
+
| Direction | Formats |
|
|
109
|
+
| --- | --- |
|
|
110
|
+
| Input | JPEG, PNG, WebP, BMP, TIFF, iPhone HEIC/HEIF |
|
|
111
|
+
| Output | PNG (default) or JPEG (`--format jpg`) |
|
|
112
|
+
|
|
113
|
+
When a folder is expanded, files already ending in the output suffix (`*_e6`)
|
|
114
|
+
are skipped with a notice so re-runs don't double-process previous output; pass
|
|
115
|
+
such a file explicitly to force it.
|
|
116
|
+
|
|
117
|
+
The exported file looks a little too vivid and too bright on your monitor;
|
|
118
|
+
**that is correct for E6.** After exporting, upload the `*_e6` file to the
|
|
119
|
+
SwitchBot app **as-is** and fine-tune with the app's own saturation/contrast
|
|
120
|
+
sliders once you see it on the panel.
|
|
121
|
+
|
|
122
|
+
### Intensity presets
|
|
123
|
+
|
|
124
|
+
Each preset sets every knob at once. `medium` is the default. Any single tuning
|
|
125
|
+
flag overrides just that value on top of the chosen preset.
|
|
126
|
+
|
|
127
|
+
| Preset | Saturation | Contrast | Brightness | Shadow lift | Warmth | Sharpen |
|
|
128
|
+
| --- | --- | --- | --- | --- | --- | --- |
|
|
129
|
+
| `none` / `--raw` | - | - | - | - | - | - |
|
|
130
|
+
| `low` | +40% | +18% | +5% | 10 | 3 | 60 |
|
|
131
|
+
| `medium` | +60% | +25% | +8% | 18 | 5 | 80 |
|
|
132
|
+
| `high` | +100% | +30% | +12% | 26 | 8 | 100 |
|
|
133
|
+
|
|
134
|
+
`none` (or `--raw`) does crop + resize only, with no tonal changes.
|
|
135
|
+
|
|
136
|
+
### Options
|
|
137
|
+
|
|
138
|
+
| Option | Default | Description |
|
|
139
|
+
| --- | --- | --- |
|
|
140
|
+
| `-o, --output DIR` (or `--out`) | next to source | Output directory, created if missing |
|
|
141
|
+
| `--suffix S` | `_e6` | Output filename suffix |
|
|
142
|
+
| `--format {png,jpg}` | `png` | Output format |
|
|
143
|
+
| `--quality N` | `100` | JPEG quality (1-100), used with `--format jpg` |
|
|
144
|
+
| `--orientation {landscape,portrait}` | `landscape` | Panel mount |
|
|
145
|
+
| `--intensity {none,low,medium,high}` | `medium` | Overall strength preset |
|
|
146
|
+
| `--raw` | off | Alias for `--intensity none` |
|
|
147
|
+
| `--saturation` … `--sharpen` | from preset | Override an individual knob (values must be ≥ 0) |
|
|
148
|
+
| `--no-crop` | off | Letterbox instead of crop-to-fill 4:3 |
|
|
149
|
+
| `--size WxH` | `1600x1200` | Override panel size |
|
|
150
|
+
|
|
151
|
+
The panel is 1600×1200 (4:3). By default the tool crops-to-fill (centred) so it
|
|
152
|
+
fills the frame; use `--no-crop` to contain the whole image with borders instead
|
|
153
|
+
(white with `--raw`, tinted slightly warm once a tonal preset is applied).
|
|
154
|
+
|
|
155
|
+
## Development
|
|
156
|
+
|
|
157
|
+
Everything runs through uv:
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
uv sync # install deps + dev tools (pytest, ruff)
|
|
161
|
+
uv run pytest # run the test suite
|
|
162
|
+
uv run ruff check . # lint (PEP 8 + import sort + pyupgrade + bugbear)
|
|
163
|
+
uv run ruff format . # format
|
|
164
|
+
uv build # build sdist + wheel into dist/
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Project structure
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
switchbot-e6-optimizer/
|
|
171
|
+
├── .github/
|
|
172
|
+
│ ├── dependabot.yml # weekly action-SHA + uv dep updates
|
|
173
|
+
│ ├── ruleset-main-protection.json # branch protection, applied via API
|
|
174
|
+
│ └── workflows/
|
|
175
|
+
│ ├── ci.yml # lint + tests on Linux/macOS/Windows
|
|
176
|
+
│ └── release.yml # gated build/publish (OIDC)
|
|
177
|
+
├── src/switchbot_e6_optimizer/
|
|
178
|
+
│ ├── __init__.py # version + public re-exports
|
|
179
|
+
│ ├── __main__.py # python -m switchbot_e6_optimizer
|
|
180
|
+
│ ├── optimizer.py # the pipeline + CLI
|
|
181
|
+
│ └── py.typed
|
|
182
|
+
├── tests/
|
|
183
|
+
│ └── test_optimizer.py
|
|
184
|
+
├── AGENTS.md # guidance for AI coding agents
|
|
185
|
+
├── CHANGELOG.md
|
|
186
|
+
├── LICENSE
|
|
187
|
+
├── README.md
|
|
188
|
+
├── pyproject.toml
|
|
189
|
+
└── uv.lock
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Trademarks
|
|
193
|
+
|
|
194
|
+
"SwitchBot" and "E Ink" / "Spectra" are trademarks of their respective owners.
|
|
195
|
+
This is an independent, unofficial tool - not affiliated with, endorsed by, or
|
|
196
|
+
sponsored by SwitchBot or E Ink.
|
|
197
|
+
|
|
198
|
+
## License
|
|
199
|
+
|
|
200
|
+
[MIT](LICENSE) - free to use, modify, and distribute; just keep the license
|
|
201
|
+
notice so use traces back to this project.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
switchbot_e6_optimizer/__init__.py,sha256=X4Ga1jmZT8YGlfwc9ODM5Qp9pQaI64zN82ONPjiyouc,199
|
|
2
|
+
switchbot_e6_optimizer/__main__.py,sha256=4oQHh0BSQbJ8Vhc3jbJy9VZD2V1KheDYo3K_EH2Yd8U,159
|
|
3
|
+
switchbot_e6_optimizer/optimizer.py,sha256=TEwyx_sExvNtbNoDYECDwUwGWAgz8m5la3aoRKnsnS8,19507
|
|
4
|
+
switchbot_e6_optimizer/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
switchbot_e6_optimizer-1.0.0.dist-info/licenses/LICENSE,sha256=PXBL_qryI19u3wka0xafOE9KI9OgnOL95_Kr5YW8Eag,1092
|
|
6
|
+
switchbot_e6_optimizer-1.0.0.dist-info/WHEEL,sha256=CoDSoyhtC_eO_tlxRYzsTraPv1fPJRXFx91k6ISeAvA,81
|
|
7
|
+
switchbot_e6_optimizer-1.0.0.dist-info/entry_points.txt,sha256=fwP63cd-Mg6VfYDCdc36pbLKvW59FHQ7Y7lQU_8R-4I,72
|
|
8
|
+
switchbot_e6_optimizer-1.0.0.dist-info/METADATA,sha256=lt2G36tLmZkLZFOqSAkRZmPTiLBqfb1YoJTsrm48Fdc,7319
|
|
9
|
+
switchbot_e6_optimizer-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 switchbot-e6-optimizer contributors
|
|
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.
|