Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions backend/core/image_processing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""Reusable image fitting and e-ink quantization helpers."""
from __future__ import annotations

from PIL import Image, ImageEnhance, ImageFilter, ImageOps

from . import native_dither


def _aligned_offset(container: int, content: int, align: str) -> int:
if align in ("left", "top", "start"):
return 0
if align in ("right", "bottom", "end"):
return container - content
return (container - content) // 2


def fit_image_to_box(
src: Image.Image,
width: int,
height: int,
*,
fit: str = "fill",
align_x: str = "center",
align_y: str = "center",
) -> Image.Image:
"""Fit an image into an RGB box using JSON image-block semantics."""
src_rgba = ImageOps.exif_transpose(src).convert("RGBA")
fit_mode = str(fit or "fill").lower()
if fit_mode in ("fill", "stretch"):
base = Image.new("RGBA", (width, height), (255, 255, 255, 255))
base.alpha_composite(src_rgba.resize((width, height), Image.LANCZOS))
return base.convert("RGB")

src_w = max(1, src_rgba.size[0])
src_h = max(1, src_rgba.size[1])
scale_x = width / src_w
scale_y = height / src_h
scale = min(scale_x, scale_y) if fit_mode == "contain" else max(scale_x, scale_y)
resized_w = max(1, int(round(src_w * scale)))
resized_h = max(1, int(round(src_h * scale)))
resized = src_rgba.resize((resized_w, resized_h), Image.LANCZOS)
base = Image.new("RGBA", (width, height), (255, 255, 255, 255))
paste_x = _aligned_offset(width, resized_w, align_x)
paste_y = _aligned_offset(height, resized_h, align_y)
base.alpha_composite(resized, (paste_x, paste_y))
return base.convert("RGB")


def enhance_photo_for_eink(rgb: Image.Image) -> Image.Image:
"""Conservative photo preparation before e-ink quantization."""
img = ImageOps.autocontrast(rgb.convert("RGB"), cutoff=1)
img = ImageEnhance.Contrast(img).enhance(1.12)
img = ImageEnhance.Sharpness(img).enhance(1.25)
return img.filter(ImageFilter.UnsharpMask(radius=0.8, percent=80, threshold=3))


def quantize_image_for_eink(
rgb: Image.Image,
*,
colors: int,
photo_enhance: bool = False,
) -> Image.Image:
"""Quantize RGB image data for 2-, 3-, or 4-color e-ink output with Atkinson dithering."""
prepared = enhance_photo_for_eink(rgb) if photo_enhance else rgb.convert("RGB")

if colors < 3:
gray = ImageOps.autocontrast(prepared.convert("L"), cutoff=1)
return native_dither.atkinson_bw(gray)

return native_dither.atkinson_palette(prepared, 3 if colors == 3 else 4)


def convert_image_block(
src: Image.Image,
width: int,
height: int,
colors: int,
*,
fit: str = "fill",
align_x: str = "center",
align_y: str = "center",
photo_enhance: bool = False,
) -> Image.Image:
"""Fit and quantize an image for a JSON image block."""
fitted = fit_image_to_box(src, width, height, fit=fit, align_x=align_x, align_y=align_y)
return quantize_image_for_eink(
fitted,
colors=colors,
photo_enhance=photo_enhance,
)
102 changes: 32 additions & 70 deletions backend/core/json_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
)
from .layout_presets import expand_layout_presets
from .mode_catalog import builtin_catalog_map
from .image_processing import convert_image_block

logger = logging.getLogger(__name__)

Expand All @@ -53,12 +54,6 @@

_BACKEND_ROOT = Path(__file__).resolve().parent.parent
_UPLOAD_DIR = _BACKEND_ROOT / "runtime_uploads"
_PALETTE_RGB = {
0: (0, 0, 0),
1: (255, 255, 255),
2: (232, 176, 0),
3: (200, 0, 0),
}

STATUS_BAR_BOTTOM_DEFAULT = 36 # Used when screen_h unknown (e.g. dataclass default)

Expand Down Expand Up @@ -156,73 +151,12 @@ def _replace(m: re.Match) -> str:
return re.sub(r"\{(\w+)\}", _replace, template)


def _aligned_offset(container: int, content: int, align: str) -> int:
if align in ("left", "top", "start"):
return 0
if align in ("right", "bottom", "end"):
return container - content
return (container - content) // 2


def _resolve_named_color(ctx: RenderContext, color_name: Any, default: int = EINK_FG) -> int:
if not isinstance(color_name, str) or not color_name:
return default
return ctx.color_index(color_name, default)


def _convert_image_block(
src: Image.Image,
width: int,
height: int,
colors: int,
fit: str = "fill",
align_x: str = "center",
align_y: str = "center",
) -> Image.Image:
src_rgba = src.convert("RGBA")
fit_mode = str(fit or "fill").lower()
if fit_mode in ("fill", "stretch"):
base = Image.new("RGBA", (width, height), (255, 255, 255, 255))
base.alpha_composite(src_rgba.resize((width, height), Image.LANCZOS))
else:
src_w = max(1, src_rgba.size[0])
src_h = max(1, src_rgba.size[1])
scale_x = width / src_w
scale_y = height / src_h
scale = min(scale_x, scale_y) if fit_mode == "contain" else max(scale_x, scale_y)
resized_w = max(1, int(round(src_w * scale)))
resized_h = max(1, int(round(src_h * scale)))
resized = src_rgba.resize((resized_w, resized_h), Image.LANCZOS)
base = Image.new("RGBA", (width, height), (255, 255, 255, 255))
paste_x = _aligned_offset(width, resized_w, align_x)
paste_y = _aligned_offset(height, resized_h, align_y)
base.alpha_composite(resized, (paste_x, paste_y))
rgb = base.convert("RGB")
if colors < 3:
return rgb.convert("L").convert("1")
out = Image.new("P", rgb.size, EINK_BG)
pal = EINK_4COLOR_PALETTE + [0] * (768 - len(EINK_4COLOR_PALETTE))
out.putpalette(pal)
allowed = (0, 1, 3) if colors == 3 else (0, 1, 2, 3)
cache: dict[tuple[int, int, int], int] = {}
mapped: list[int] = []
for pixel in rgb.getdata():
idx = cache.get(pixel)
if idx is None:
idx = min(
allowed,
key=lambda candidate: (
(pixel[0] - _PALETTE_RGB[candidate][0]) ** 2
+ (pixel[1] - _PALETTE_RGB[candidate][1]) ** 2
+ (pixel[2] - _PALETTE_RGB[candidate][2]) ** 2
),
)
cache[pixel] = idx
mapped.append(idx)
out.putdata(mapped)
return out


@dataclass
class RenderContext:
"""Mutable state threaded through block renderers."""
Expand Down Expand Up @@ -2521,12 +2455,22 @@ def _render_image(ctx: RenderContext, block: dict) -> None:
fit = str(block.get("fit", "fill") or "fill")
align_x = str(block.get("align_x", "center") or "center")
align_y = str(block.get("align_y", "center") or "center")
photo_enhance = bool(block.get("photo_enhance", False))
margin_bottom = int(block.get("margin_bottom", 6) * ctx.scale)
# Try pre-fetched data first (async download from json_content.py)
prefetched = ctx.content.get(f"_prefetched_{field_name}")
if prefetched:
from io import BytesIO
img = _convert_image_block(Image.open(BytesIO(prefetched)), width, height, ctx.colors, fit=fit, align_x=align_x, align_y=align_y)
img = convert_image_block(
Image.open(BytesIO(prefetched)),
width,
height,
ctx.colors,
fit=fit,
align_x=align_x,
align_y=align_y,
photo_enhance=photo_enhance,
)
if ctx.colors >= 3:
ctx.img.paste(img, (x, y))
else:
Expand All @@ -2536,7 +2480,16 @@ def _render_image(ctx: RenderContext, block: dict) -> None:
local_path = _resolve_local_asset(image_url)
if local_path:
try:
img = _convert_image_block(Image.open(local_path), width, height, ctx.colors, fit=fit, align_x=align_x, align_y=align_y)
img = convert_image_block(
Image.open(local_path),
width,
height,
ctx.colors,
fit=fit,
align_x=align_x,
align_y=align_y,
photo_enhance=photo_enhance,
)
if ctx.colors >= 3:
ctx.img.paste(img, (x, y))
else:
Expand Down Expand Up @@ -2569,7 +2522,16 @@ def _render_image(ctx: RenderContext, block: dict) -> None:
if resp is None:
raise last_error if last_error else ValueError("image fetch failed")
from io import BytesIO
img = _convert_image_block(Image.open(BytesIO(resp.content)), width, height, ctx.colors, fit=fit, align_x=align_x, align_y=align_y)
img = convert_image_block(
Image.open(BytesIO(resp.content)),
width,
height,
ctx.colors,
fit=fit,
align_x=align_x,
align_y=align_y,
photo_enhance=photo_enhance,
)
if ctx.colors >= 3:
ctx.img.paste(img, (x, y))
else:
Expand Down
3 changes: 3 additions & 0 deletions backend/core/modes/builtin/en/my_adaptive.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"x": 0,
"y": 36,
"fit": "cover",
"photo_enhance": true,
"align_x": "center",
"align_y": "center",
"margin_bottom": 0
Expand All @@ -37,6 +38,7 @@
"width": 322,
"height": 99,
"fit": "contain",
"photo_enhance": true,
"align_x": "center",
"align_y": "center",
"margin_bottom": 0
Expand All @@ -53,6 +55,7 @@
"x": 0,
"y": 57,
"fit": "cover",
"photo_enhance": true,
"align_x": "center",
"align_y": "center",
"margin_bottom": 0
Expand Down
3 changes: 3 additions & 0 deletions backend/core/modes/builtin/my_adaptive.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"x": 0,
"y": 36,
"fit": "cover",
"photo_enhance": true,
"align_x": "center",
"align_y": "center",
"margin_bottom": 0
Expand All @@ -45,6 +46,7 @@
"width": 322,
"height": 99,
"fit": "contain",
"photo_enhance": true,
"align_x": "center",
"align_y": "center",
"margin_bottom": 0
Expand All @@ -61,6 +63,7 @@
"x": 0,
"y": 57,
"fit": "cover",
"photo_enhance": true,
"align_x": "center",
"align_y": "center",
"margin_bottom": 0
Expand Down
6 changes: 5 additions & 1 deletion backend/core/modes/schema/mode_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,11 @@
"x": { "type": "integer", "default": 0 },
"y": { "type": "integer", "default": 0 },
"width": { "type": "integer" },
"height": { "type": "integer" }
"height": { "type": "integer" },
"fit": { "type": "string", "enum": ["fill", "stretch", "contain", "cover"], "default": "fill" },
"align_x": { "type": "string", "enum": ["left", "center", "right", "start", "end"], "default": "center" },
"align_y": { "type": "string", "enum": ["top", "center", "bottom", "start", "end"], "default": "center" },
"photo_enhance": { "type": "boolean", "default": false, "description": "Apply conservative photo contrast/sharpness enhancement before quantization." }
}
},
"progress_bar_block": {
Expand Down
12 changes: 12 additions & 0 deletions backend/core/native/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Native e-ink dithering

This directory contains the optional C++ implementation for Atkinson dithering.

Build from the repository root:

```bash
python3 backend/scripts/build_native_dither.py
```

The generated `libeink_dither.so` is ignored by git. Runtime code automatically
uses it when present and falls back to the Python implementation when it is not.
Loading
Loading