switchbot-e6-optimizer 1.0.0__tar.gz

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.
@@ -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.
@@ -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
+ [![CI](https://github.com/hamzic/switchbot-e6-optimizer/actions/workflows/ci.yml/badge.svg?branch=main)](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,178 @@
1
+ # switchbot-e6-optimizer
2
+
3
+ [![CI](https://github.com/hamzic/switchbot-e6-optimizer/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/hamzic/switchbot-e6-optimizer/actions/workflows/ci.yml)
4
+
5
+ Make photos look better on the **SwitchBot 13.3" AI Art Frame** and other
6
+ **E Ink Spectra 6 ("E6")** panels.
7
+
8
+ E6 panels show only six muted inks and render dark, flat, and low-contrast to
9
+ mimic paper. Uploaded straight from your phone, most photos come out dull and
10
+ muddy. This tool applies the continuous-tone preparation the panel needs -
11
+ boosted saturation and contrast, opened shadows, a warm nudge, and sharpening -
12
+ then leaves the dithering to the SwitchBot app.
13
+
14
+ > **Do not pre-dither for the SwitchBot app.** The app does its own colour
15
+ > mapping and dithering on upload. If you dither first you get double-dithering:
16
+ > visible noise, muddy colour.
17
+
18
+ ## Why the panel needs this
19
+
20
+ | Adjustment | Compensates for |
21
+ | --- | --- |
22
+ | Saturation (biggest lever) | Muted six-ink gamut |
23
+ | Contrast | Compressed (~26:1) dynamic range |
24
+ | Brightness + shadow lift | Panel renders dark and crushes shadows |
25
+ | Warmth | Paper-grey white, weak blues |
26
+ | Sharpen | Detail lost to dithering and sub-native resolution |
27
+ | **No dithering** | The app dithers; doing it twice = mud |
28
+
29
+ ## Requirements
30
+
31
+ - Python **3.14+**
32
+ - [uv](https://docs.astral.sh/uv/) (manages the environment, tests, and linting)
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ git clone https://github.com/hamzic/switchbot-e6-optimizer
38
+ cd switchbot-e6-optimizer
39
+ uv sync # create the venv and install everything
40
+ ```
41
+
42
+ Run it with `uv run`:
43
+
44
+ ```bash
45
+ uv run switchbot-e6 photo.jpg
46
+ ```
47
+
48
+ Or install the CLI as a standalone tool:
49
+
50
+ ```bash
51
+ uv tool install .
52
+ switchbot-e6 photo.jpg
53
+ ```
54
+
55
+ To uninstall the tool later:
56
+
57
+ ```bash
58
+ uv tool uninstall switchbot-e6-optimizer
59
+ ```
60
+
61
+ ## Usage
62
+
63
+ ```bash
64
+ # one photo, default "medium" intensity -> photo_e6.png next to it
65
+ switchbot-e6 photo.jpg
66
+
67
+ # a whole folder into ./out, portrait mount
68
+ switchbot-e6 ~/Pictures/art --orientation portrait --out ./out
69
+
70
+ # punchier preset for flat, boldly-coloured art
71
+ switchbot-e6 poster.png --intensity high
72
+
73
+ # ride the high preset but pin saturation to exactly +80%
74
+ switchbot-e6 poster.png --intensity high --saturation 80
75
+
76
+ # crop + resize only, no tonal edits (A/B against the app's own processing)
77
+ switchbot-e6 photo.jpg --raw
78
+ ```
79
+
80
+ Inputs can be files or whole folders. Running with no arguments prints the full
81
+ help. You can also run it as a module: `python -m switchbot_e6_optimizer`.
82
+
83
+ ### Supported formats
84
+
85
+ | Direction | Formats |
86
+ | --- | --- |
87
+ | Input | JPEG, PNG, WebP, BMP, TIFF, iPhone HEIC/HEIF |
88
+ | Output | PNG (default) or JPEG (`--format jpg`) |
89
+
90
+ When a folder is expanded, files already ending in the output suffix (`*_e6`)
91
+ are skipped with a notice so re-runs don't double-process previous output; pass
92
+ such a file explicitly to force it.
93
+
94
+ The exported file looks a little too vivid and too bright on your monitor;
95
+ **that is correct for E6.** After exporting, upload the `*_e6` file to the
96
+ SwitchBot app **as-is** and fine-tune with the app's own saturation/contrast
97
+ sliders once you see it on the panel.
98
+
99
+ ### Intensity presets
100
+
101
+ Each preset sets every knob at once. `medium` is the default. Any single tuning
102
+ flag overrides just that value on top of the chosen preset.
103
+
104
+ | Preset | Saturation | Contrast | Brightness | Shadow lift | Warmth | Sharpen |
105
+ | --- | --- | --- | --- | --- | --- | --- |
106
+ | `none` / `--raw` | - | - | - | - | - | - |
107
+ | `low` | +40% | +18% | +5% | 10 | 3 | 60 |
108
+ | `medium` | +60% | +25% | +8% | 18 | 5 | 80 |
109
+ | `high` | +100% | +30% | +12% | 26 | 8 | 100 |
110
+
111
+ `none` (or `--raw`) does crop + resize only, with no tonal changes.
112
+
113
+ ### Options
114
+
115
+ | Option | Default | Description |
116
+ | --- | --- | --- |
117
+ | `-o, --output DIR` (or `--out`) | next to source | Output directory, created if missing |
118
+ | `--suffix S` | `_e6` | Output filename suffix |
119
+ | `--format {png,jpg}` | `png` | Output format |
120
+ | `--quality N` | `100` | JPEG quality (1-100), used with `--format jpg` |
121
+ | `--orientation {landscape,portrait}` | `landscape` | Panel mount |
122
+ | `--intensity {none,low,medium,high}` | `medium` | Overall strength preset |
123
+ | `--raw` | off | Alias for `--intensity none` |
124
+ | `--saturation` … `--sharpen` | from preset | Override an individual knob (values must be ≥ 0) |
125
+ | `--no-crop` | off | Letterbox instead of crop-to-fill 4:3 |
126
+ | `--size WxH` | `1600x1200` | Override panel size |
127
+
128
+ The panel is 1600×1200 (4:3). By default the tool crops-to-fill (centred) so it
129
+ fills the frame; use `--no-crop` to contain the whole image with borders instead
130
+ (white with `--raw`, tinted slightly warm once a tonal preset is applied).
131
+
132
+ ## Development
133
+
134
+ Everything runs through uv:
135
+
136
+ ```bash
137
+ uv sync # install deps + dev tools (pytest, ruff)
138
+ uv run pytest # run the test suite
139
+ uv run ruff check . # lint (PEP 8 + import sort + pyupgrade + bugbear)
140
+ uv run ruff format . # format
141
+ uv build # build sdist + wheel into dist/
142
+ ```
143
+
144
+ ### Project structure
145
+
146
+ ```
147
+ switchbot-e6-optimizer/
148
+ ├── .github/
149
+ │ ├── dependabot.yml # weekly action-SHA + uv dep updates
150
+ │ ├── ruleset-main-protection.json # branch protection, applied via API
151
+ │ └── workflows/
152
+ │ ├── ci.yml # lint + tests on Linux/macOS/Windows
153
+ │ └── release.yml # gated build/publish (OIDC)
154
+ ├── src/switchbot_e6_optimizer/
155
+ │ ├── __init__.py # version + public re-exports
156
+ │ ├── __main__.py # python -m switchbot_e6_optimizer
157
+ │ ├── optimizer.py # the pipeline + CLI
158
+ │ └── py.typed
159
+ ├── tests/
160
+ │ └── test_optimizer.py
161
+ ├── AGENTS.md # guidance for AI coding agents
162
+ ├── CHANGELOG.md
163
+ ├── LICENSE
164
+ ├── README.md
165
+ ├── pyproject.toml
166
+ └── uv.lock
167
+ ```
168
+
169
+ ## Trademarks
170
+
171
+ "SwitchBot" and "E Ink" / "Spectra" are trademarks of their respective owners.
172
+ This is an independent, unofficial tool - not affiliated with, endorsed by, or
173
+ sponsored by SwitchBot or E Ink.
174
+
175
+ ## License
176
+
177
+ [MIT](LICENSE) - free to use, modify, and distribute; just keep the license
178
+ notice so use traces back to this project.
@@ -0,0 +1,58 @@
1
+ [project]
2
+ name = "switchbot-e6-optimizer"
3
+ version = "1.0.0"
4
+ description = "Optimise photos for the SwitchBot 13.3\" AI Art Frame (E Ink Spectra 6)."
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [{ name = "switchbot-e6-optimizer contributors" }]
10
+ keywords = ["switchbot", "e-ink", "eink", "spectra6", "epaper", "image", "pillow"]
11
+ classifiers = [
12
+ "Development Status :: 5 - Production/Stable",
13
+ "Environment :: Console",
14
+ "Intended Audience :: End Users/Desktop",
15
+ "Operating System :: OS Independent",
16
+ "Programming Language :: Python :: 3.14",
17
+ "Topic :: Multimedia :: Graphics",
18
+ ]
19
+ dependencies = ["pillow>=12", "pillow-heif>=1"]
20
+
21
+ [project.scripts]
22
+ switchbot-e6 = "switchbot_e6_optimizer.optimizer:main"
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/hamzic/switchbot-e6-optimizer"
26
+ Repository = "https://github.com/hamzic/switchbot-e6-optimizer"
27
+ Issues = "https://github.com/hamzic/switchbot-e6-optimizer/issues"
28
+ Changelog = "https://github.com/hamzic/switchbot-e6-optimizer/blob/main/CHANGELOG.md"
29
+
30
+ [build-system]
31
+ requires = ["uv_build>=0.11.28,<0.12"]
32
+ build-backend = "uv_build"
33
+
34
+ [dependency-groups]
35
+ dev = ["pytest>=8", "ruff>=0.14"]
36
+
37
+ [tool.ruff]
38
+ line-length = 88
39
+ target-version = "py314"
40
+
41
+ [tool.ruff.lint]
42
+ select = [
43
+ "E", # pycodestyle errors (PEP 8)
44
+ "F", # Pyflakes
45
+ "I", # isort (import sorting)
46
+ "UP", # pyupgrade (modernise syntax for the target version)
47
+ "B", # flake8-bugbear
48
+ "SIM", # flake8-simplify
49
+ "C4", # flake8-comprehensions
50
+ ]
51
+
52
+ [tool.ruff.format]
53
+ docstring-code-format = true
54
+
55
+ [tool.pytest.ini_options]
56
+ testpaths = ["tests"]
57
+ addopts = "-q"
58
+ filterwarnings = ["error::DeprecationWarning"]
@@ -0,0 +1,5 @@
1
+ """Image optimiser for the SwitchBot 13.3" AI Art Frame (E Ink Spectra 6)."""
2
+
3
+ from switchbot_e6_optimizer.optimizer import __version__, main, optimize
4
+
5
+ __all__ = ["__version__", "main", "optimize"]
@@ -0,0 +1,6 @@
1
+ """Enable ``python -m switchbot_e6_optimizer``."""
2
+
3
+ from switchbot_e6_optimizer.optimizer import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -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