2 Commits

Author SHA1 Message Date
f4da47ca2b fix: rename GITEA_TOKEN to DEPLOY_TOKEN in CI workflow
All checks were successful
Lint & Test / test (push) Successful in 2m39s
Build Release / create-release (push) Successful in 2s
Build Release / build-linux (push) Successful in 2m11s
Build Release / build-docker (push) Successful in 2m52s
Build Release / build-windows (push) Successful in 3m30s
GITEA_TOKEN is a reserved name in Gitea — the UI and API reject it
when creating action secrets.
2026-03-25 14:41:02 +03:00
7939322a7f feat: reduce build size — replace Pillow with cv2, refactor build scripts
All checks were successful
Build Release / create-release (push) Successful in 1s
Build Release / build-docker (push) Successful in 42s
Lint & Test / test (push) Successful in 2m50s
Build Release / build-windows (push) Successful in 3m27s
Build Release / build-linux (push) Successful in 1m59s
- Create utils/image_codec.py with cv2-based image helpers
- Replace PIL usage across all routes, filters, and engines with cv2
- Move Pillow from core deps to [tray] optional in pyproject.toml
- Extract shared build logic into build-common.sh (detect_version, cleanup, etc.)
- Strip unused NumPy/PIL/zeroconf/debug files in build scripts
2026-03-25 14:18:16 +03:00
19 changed files with 448 additions and 381 deletions

View File

@@ -15,7 +15,7 @@ jobs:
- name: Create Gitea release - name: Create Gitea release
id: create id: create
env: env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: | run: |
TAG="${{ gitea.ref_name }}" TAG="${{ gitea.ref_name }}"
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}" BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
@@ -126,7 +126,7 @@ jobs:
- name: Attach assets to release - name: Attach assets to release
env: env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: | run: |
RELEASE_ID="${{ needs.create-release.outputs.release_id }}" RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}" BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
@@ -196,7 +196,7 @@ jobs:
- name: Attach tarball to release - name: Attach tarball to release
env: env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: | run: |
RELEASE_ID="${{ needs.create-release.outputs.release_id }}" RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}" BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
@@ -248,7 +248,7 @@ jobs:
id: docker-login id: docker-login
continue-on-error: true continue-on-error: true
run: | run: |
echo "${{ secrets.GITEA_TOKEN }}" | docker login \ echo "${{ secrets.DEPLOY_TOKEN }}" | docker login \
"${{ steps.meta.outputs.server_host }}" \ "${{ steps.meta.outputs.server_host }}" \
-u "${{ gitea.actor }}" --password-stdin -u "${{ gitea.actor }}" --password-stdin

55
TODO.md
View File

@@ -1,26 +1,37 @@
# Auto-Update Phase 1: Check & Notify # Build Size Reduction
## Backend ## Phase 1: Quick Wins (build scripts)
- [ ] Add `packaging` to pyproject.toml dependencies
- [ ] Create `core/update/__init__.py`
- [ ] Create `core/update/release_provider.py` — ABC + data models
- [ ] Create `core/update/gitea_provider.py` — Gitea REST API implementation
- [ ] Create `core/update/version_check.py` — semver normalization + comparison
- [ ] Create `core/update/update_service.py` — background service + state machine
- [ ] Create `api/schemas/update.py` — Pydantic request/response models
- [ ] Create `api/routes/update.py` — REST endpoints
- [ ] Wire into `api/__init__.py`, `dependencies.py`, `main.py`
## Frontend - [x] Strip unused NumPy submodules (polynomial, linalg, ma, lib, distutils)
- [ ] Add update banner HTML to `index.html` - [x] Strip debug symbols from .pyd/.dll/.so files
- [ ] Add Updates tab to `settings.html` - [x] Remove zeroconf service database
- [ ] Add `has-update` CSS styles for version badge in `layout.css` - [x] Remove .py source from site-packages after compiling to .pyc
- [ ] Add update banner CSS styles in `components.css` - [x] Strip unused PIL image plugins (keep JPEG/PNG/ICO/BMP for tray)
- [ ] Create `features/update.ts` — update check/settings/banner logic
- [ ] Wire exports in `app.ts` ## Phase 2: Replace Pillow with cv2
- [ ] Add i18n keys to `en.json`, `ru.json`, `zh.json`
- [x] Create `utils/image_codec.py` with cv2-based image helpers
- [x] Replace PIL in `_preview_helpers.py`
- [x] Replace PIL in `picture_sources.py`
- [x] Replace PIL in `color_strip_sources.py`
- [x] Replace PIL in `templates.py`
- [x] Replace PIL in `postprocessing.py`
- [x] Replace PIL in `output_targets_keycolors.py`
- [x] Replace PIL in `kc_target_processor.py`
- [x] Replace PIL in `pixelate.py` filter
- [x] Replace PIL in `downscaler.py` filter
- [x] Replace PIL in `scrcpy_engine.py`
- [x] Replace PIL in `live_stream_manager.py`
- [x] Move Pillow from core deps to [tray] optional in pyproject.toml
- [x] Make PIL import conditional in `tray.py`
- [x] Move opencv-python-headless to core dependencies
## Phase 4: OpenCV stripping (build scripts)
- [x] Strip ffmpeg DLL, Haar cascades, dev files (already existed)
- [x] Strip typing stubs (already existed)
## Verification ## Verification
- [ ] Lint check: `ruff check src/ tests/ --fix`
- [ ] TypeScript check: `npx tsc --noEmit && npm run build` - [x] Lint: `ruff check src/ tests/ --fix`
- [ ] Tests pass: `py -3.13 -m pytest tests/ --no-cov -q` - [x] Tests: 341 passed

140
build-common.sh Normal file
View File

@@ -0,0 +1,140 @@
#!/usr/bin/env bash
#
# Shared build functions for LedGrab distribution packaging.
# Sourced by build-dist.sh (Linux) and build-dist-windows.sh (Windows).
#
# Expected variables set by the caller before sourcing:
# SCRIPT_DIR, BUILD_DIR, DIST_DIR, SERVER_DIR, APP_DIR
# ── Version detection ────────────────────────────────────────
detect_version() {
# Usage: detect_version [explicit_version]
local version="${1:-}"
if [ -z "$version" ]; then
version=$(git describe --tags --exact-match 2>/dev/null || true)
fi
if [ -z "$version" ]; then
version="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
fi
if [ -z "$version" ]; then
version=$(grep -oP '^version\s*=\s*"\K[^"]+' "$SERVER_DIR/pyproject.toml" 2>/dev/null || echo "0.0.0")
fi
VERSION_CLEAN="${version#v}"
# Stamp the resolved version into pyproject.toml so that
# importlib.metadata reads the correct value at runtime.
sed -i "s/^version = .*/version = \"${VERSION_CLEAN}\"/" "$SERVER_DIR/pyproject.toml"
}
# ── Clean previous build ─────────────────────────────────────
clean_dist() {
if [ -d "$DIST_DIR" ]; then
echo " Cleaning previous build..."
rm -rf "$DIST_DIR"
fi
mkdir -p "$DIST_DIR"
}
# ── Build frontend ───────────────────────────────────────────
build_frontend() {
echo " Building frontend bundle..."
(cd "$SERVER_DIR" && npm ci --loglevel error && npm run build) 2>&1 | {
grep -v 'RemoteException' || true
}
}
# ── Copy application files ───────────────────────────────────
copy_app_files() {
echo " Copying application files..."
mkdir -p "$APP_DIR"
cp -r "$SERVER_DIR/src" "$APP_DIR/src"
cp -r "$SERVER_DIR/config" "$APP_DIR/config"
mkdir -p "$DIST_DIR/data" "$DIST_DIR/logs"
# Clean up source maps and __pycache__
find "$APP_DIR" -name "*.map" -delete 2>/dev/null || true
find "$APP_DIR" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
}
# ── Site-packages cleanup ────────────────────────────────────
#
# Strips tests, type stubs, unused submodules, and debug symbols
# from the installed site-packages directory.
#
# Args:
# $1 — path to site-packages directory
# $2 — native extension suffix: "pyd" (Windows) or "so" (Linux)
# $3 — native lib suffix for OpenCV ffmpeg: "dll" or "so"
cleanup_site_packages() {
local sp_dir="$1"
local ext_suffix="${2:-so}"
local lib_suffix="${3:-so}"
echo " Cleaning up site-packages to reduce size..."
# ── Generic cleanup ──────────────────────────────────────
find "$sp_dir" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find "$sp_dir" -type d -name tests -exec rm -rf {} + 2>/dev/null || true
find "$sp_dir" -type d -name test -exec rm -rf {} + 2>/dev/null || true
find "$sp_dir" -type d -name "*.dist-info" -exec rm -rf {} + 2>/dev/null || true
find "$sp_dir" -name "*.pyi" -delete 2>/dev/null || true
# ── pip / setuptools (not needed at runtime) ─────────────
rm -rf "$sp_dir"/pip "$sp_dir"/pip-* 2>/dev/null || true
rm -rf "$sp_dir"/setuptools "$sp_dir"/setuptools-* "$sp_dir"/pkg_resources 2>/dev/null || true
rm -rf "$sp_dir"/_distutils_hack 2>/dev/null || true
# ── OpenCV ───────────────────────────────────────────────
local cv2_dir="$sp_dir/cv2"
if [ -d "$cv2_dir" ]; then
# Remove ffmpeg (28 MB on Windows), Haar cascades, dev files
rm -f "$cv2_dir"/opencv_videoio_ffmpeg*."$lib_suffix" 2>/dev/null || true
rm -rf "$cv2_dir/data" "$cv2_dir/gapi" "$cv2_dir/misc" "$cv2_dir/utils" 2>/dev/null || true
rm -rf "$cv2_dir/typing_stubs" "$cv2_dir/typing" 2>/dev/null || true
fi
# ── NumPy ────────────────────────────────────────────────
# Remove unused submodules (only core, fft, random are used)
for mod in polynomial linalg ma lib distutils f2py typing _pyinstaller; do
rm -rf "$sp_dir/numpy/$mod" 2>/dev/null || true
done
rm -rf "$sp_dir/numpy/tests" "$sp_dir/numpy/*/tests" 2>/dev/null || true
# ── Pillow (only used for system tray icon) ──────────────
rm -rf "$sp_dir/PIL/tests" 2>/dev/null || true
# Remove unused image format plugins (keep JPEG, PNG, ICO, BMP)
for plugin in Eps Gif Tiff Webp Psd Pcx Xbm Xpm Dds Ftex Gbr Grib \
Icns Im Imt Iptc McIrdas Mpo Msp Pcd Pixar Ppm Sgi \
Spider Sun Tga Wal Wmf; do
rm -f "$sp_dir/PIL/${plugin}ImagePlugin.py" 2>/dev/null || true
rm -f "$sp_dir/PIL/${plugin}ImagePlugin.pyc" 2>/dev/null || true
done
# ── zeroconf ─────────────────────────────────────────────
rm -rf "$sp_dir/zeroconf/_services" 2>/dev/null || true
# ── Strip debug symbols ──────────────────────────────────
if command -v strip &>/dev/null; then
echo " Stripping debug symbols from .$ext_suffix files..."
find "$sp_dir" -name "*.$ext_suffix" -exec strip --strip-debug {} \; 2>/dev/null || true
fi
# ── Remove .py source (keep .pyc bytecode) ───────────────
echo " Removing .py source from site-packages (keeping .pyc)..."
find "$sp_dir" -name "*.py" ! -name "__init__.py" -delete 2>/dev/null || true
# ── Remove wled_controller if pip-installed ───────────────
rm -rf "$sp_dir"/wled_controller* "$sp_dir"/wled*.dist-info 2>/dev/null || true
local cleaned_size
cleaned_size=$(du -sh "$sp_dir" | cut -f1)
echo " Site-packages after cleanup: $cleaned_size"
}

View File

@@ -22,27 +22,13 @@ PYTHON_DIR="$DIST_DIR/python"
APP_DIR="$DIST_DIR/app" APP_DIR="$DIST_DIR/app"
PYTHON_VERSION="${PYTHON_VERSION:-3.11.9}" PYTHON_VERSION="${PYTHON_VERSION:-3.11.9}"
source "$SCRIPT_DIR/build-common.sh"
# ── Version detection ──────────────────────────────────────── # ── Version detection ────────────────────────────────────────
VERSION="${1:-}" detect_version "${1:-}"
if [ -z "$VERSION" ]; then
VERSION=$(git describe --tags --exact-match 2>/dev/null || true)
fi
if [ -z "$VERSION" ]; then
VERSION="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
fi
if [ -z "$VERSION" ]; then
VERSION=$(grep -oP '^version\s*=\s*"\K[^"]+' "$SERVER_DIR/pyproject.toml" 2>/dev/null || echo "0.0.0")
fi
VERSION_CLEAN="${VERSION#v}"
ZIP_NAME="LedGrab-v${VERSION_CLEAN}-win-x64.zip" ZIP_NAME="LedGrab-v${VERSION_CLEAN}-win-x64.zip"
# Stamp the resolved version into pyproject.toml so that
# importlib.metadata reads the correct value at runtime.
sed -i "s/^version = .*/version = \"${VERSION_CLEAN}\"/" "$SERVER_DIR/pyproject.toml"
echo "=== Cross-building LedGrab v${VERSION_CLEAN} (Windows from Linux) ===" echo "=== Cross-building LedGrab v${VERSION_CLEAN} (Windows from Linux) ==="
echo " Embedded Python: $PYTHON_VERSION" echo " Embedded Python: $PYTHON_VERSION"
echo " Output: build/$ZIP_NAME" echo " Output: build/$ZIP_NAME"
@@ -50,11 +36,8 @@ echo ""
# ── Clean ──────────────────────────────────────────────────── # ── Clean ────────────────────────────────────────────────────
if [ -d "$DIST_DIR" ]; then echo "[1/9] Cleaning..."
echo "[1/9] Cleaning previous build..." clean_dist
rm -rf "$DIST_DIR"
fi
mkdir -p "$DIST_DIR"
# ── Download Windows embedded Python ───────────────────────── # ── Download Windows embedded Python ─────────────────────────
@@ -195,15 +178,11 @@ WHEEL_DIR="$BUILD_DIR/win-wheels"
mkdir -p "$WHEEL_DIR" mkdir -p "$WHEEL_DIR"
# Core dependencies (cross-platform, should have win_amd64 wheels) # Core dependencies (cross-platform, should have win_amd64 wheels)
# We parse pyproject.toml deps and download win_amd64 wheels.
# For packages that are pure Python, --only-binary will fail,
# so we fall back to allowing source for those.
DEPS=( DEPS=(
"fastapi>=0.115.0" "fastapi>=0.115.0"
"uvicorn[standard]>=0.32.0" "uvicorn[standard]>=0.32.0"
"httpx>=0.27.2" "httpx>=0.27.2"
"mss>=9.0.2" "mss>=9.0.2"
"Pillow>=10.4.0"
"numpy>=2.1.3" "numpy>=2.1.3"
"pydantic>=2.9.2" "pydantic>=2.9.2"
"pydantic-settings>=2.6.0" "pydantic-settings>=2.6.0"
@@ -220,7 +199,6 @@ DEPS=(
"sounddevice>=0.5" "sounddevice>=0.5"
"aiomqtt>=2.0.0" "aiomqtt>=2.0.0"
"openrgb-python>=0.2.15" "openrgb-python>=0.2.15"
# camera extra
"opencv-python-headless>=4.8.0" "opencv-python-headless>=4.8.0"
) )
@@ -232,8 +210,9 @@ WIN_DEPS=(
"winrt-Windows.Foundation>=3.0.0" "winrt-Windows.Foundation>=3.0.0"
"winrt-Windows.Foundation.Collections>=3.0.0" "winrt-Windows.Foundation.Collections>=3.0.0"
"winrt-Windows.ApplicationModel>=3.0.0" "winrt-Windows.ApplicationModel>=3.0.0"
# System tray # System tray (Pillow needed by pystray for tray icon)
"pystray>=0.19.0" "pystray>=0.19.0"
"Pillow>=10.4.0"
) )
# Download cross-platform deps (prefer binary, allow source for pure Python) # Download cross-platform deps (prefer binary, allow source for pure Python)
@@ -286,73 +265,26 @@ for sdist in "$WHEEL_DIR"/*.tar.gz; do
done done
# ── Reduce package size ──────────────────────────────────────── # ── Reduce package size ────────────────────────────────────────
echo " Cleaning up to reduce size..."
# Remove caches, tests, docs, type stubs cleanup_site_packages "$SITE_PACKAGES" "pyd" "dll"
find "$SITE_PACKAGES" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find "$SITE_PACKAGES" -type d -name tests -exec rm -rf {} + 2>/dev/null || true
find "$SITE_PACKAGES" -type d -name test -exec rm -rf {} + 2>/dev/null || true
find "$SITE_PACKAGES" -type d -name "*.dist-info" -exec rm -rf {} + 2>/dev/null || true
find "$SITE_PACKAGES" -name "*.pyi" -delete 2>/dev/null || true
# Remove pip and setuptools (not needed at runtime) # Windows-specific cleanup
rm -rf "$SITE_PACKAGES"/pip "$SITE_PACKAGES"/pip-* 2>/dev/null || true
rm -rf "$SITE_PACKAGES"/setuptools "$SITE_PACKAGES"/setuptools-* "$SITE_PACKAGES"/pkg_resources 2>/dev/null || true
rm -rf "$SITE_PACKAGES"/_distutils_hack 2>/dev/null || true
# Remove pythonwin GUI IDE and help file (ships with pywin32 but not needed)
rm -rf "$SITE_PACKAGES"/pythonwin 2>/dev/null || true rm -rf "$SITE_PACKAGES"/pythonwin 2>/dev/null || true
rm -f "$SITE_PACKAGES"/PyWin32.chm 2>/dev/null || true rm -f "$SITE_PACKAGES"/PyWin32.chm 2>/dev/null || true
# OpenCV: remove ffmpeg DLL (28MB, only for video file I/O, not camera),
# Haar cascades (2.6MB), and misc dev files
CV2_DIR="$SITE_PACKAGES/cv2"
if [ -d "$CV2_DIR" ]; then
rm -f "$CV2_DIR"/opencv_videoio_ffmpeg*.dll 2>/dev/null || true
rm -rf "$CV2_DIR/data" "$CV2_DIR/gapi" "$CV2_DIR/misc" "$CV2_DIR/utils" 2>/dev/null || true
rm -rf "$CV2_DIR/typing_stubs" "$CV2_DIR/typing" 2>/dev/null || true
fi
# numpy: remove tests, f2py, typing stubs
rm -rf "$SITE_PACKAGES/numpy/tests" "$SITE_PACKAGES/numpy/*/tests" 2>/dev/null || true
rm -rf "$SITE_PACKAGES/numpy/f2py" 2>/dev/null || true
rm -rf "$SITE_PACKAGES/numpy/typing" 2>/dev/null || true
rm -rf "$SITE_PACKAGES/numpy/_pyinstaller" 2>/dev/null || true
# Pillow: remove unused image plugins' test data
rm -rf "$SITE_PACKAGES/PIL/tests" 2>/dev/null || true
# winrt: remove type stubs
find "$SITE_PACKAGES/winrt" -name "*.pyi" -delete 2>/dev/null || true find "$SITE_PACKAGES/winrt" -name "*.pyi" -delete 2>/dev/null || true
# Remove wled_controller if it got installed
rm -rf "$SITE_PACKAGES"/wled_controller* "$SITE_PACKAGES"/wled*.dist-info 2>/dev/null || true
CLEANED_SIZE=$(du -sh "$SITE_PACKAGES" | cut -f1)
echo " Site-packages after cleanup: $CLEANED_SIZE"
WHEEL_COUNT=$(ls "$WHEEL_DIR"/*.whl 2>/dev/null | wc -l) WHEEL_COUNT=$(ls "$WHEEL_DIR"/*.whl 2>/dev/null | wc -l)
echo " Installed $WHEEL_COUNT packages" echo " Installed $WHEEL_COUNT packages"
# ── Build frontend ─────────────────────────────────────────── # ── Build frontend ───────────────────────────────────────────
echo "[7/9] Building frontend bundle..." echo "[7/9] Building frontend..."
(cd "$SERVER_DIR" && npm ci --loglevel error && npm run build) 2>&1 | { build_frontend
grep -v 'RemoteException' || true
}
# ── Copy application files ─────────────────────────────────── # ── Copy application files ───────────────────────────────────
echo "[8/9] Copying application files..." echo "[8/9] Copying application files..."
mkdir -p "$APP_DIR" copy_app_files
cp -r "$SERVER_DIR/src" "$APP_DIR/src"
cp -r "$SERVER_DIR/config" "$APP_DIR/config"
mkdir -p "$DIST_DIR/data" "$DIST_DIR/logs"
# Clean up source maps and __pycache__
find "$APP_DIR" -name "*.map" -delete 2>/dev/null || true
find "$APP_DIR" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
# Pre-compile Python bytecode for faster startup # Pre-compile Python bytecode for faster startup
echo " Pre-compiling Python bytecode..." echo " Pre-compiling Python bytecode..."

View File

@@ -17,38 +17,21 @@ SERVER_DIR="$SCRIPT_DIR/server"
VENV_DIR="$DIST_DIR/venv" VENV_DIR="$DIST_DIR/venv"
APP_DIR="$DIST_DIR/app" APP_DIR="$DIST_DIR/app"
source "$SCRIPT_DIR/build-common.sh"
# ── Version detection ──────────────────────────────────────── # ── Version detection ────────────────────────────────────────
VERSION="${1:-}" detect_version "${1:-}"
if [ -z "$VERSION" ]; then
VERSION=$(git describe --tags --exact-match 2>/dev/null || true)
fi
if [ -z "$VERSION" ]; then
VERSION="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
fi
if [ -z "$VERSION" ]; then
VERSION=$(grep -oP '^version\s*=\s*"\K[^"]+' "$SERVER_DIR/pyproject.toml" 2>/dev/null || echo "0.0.0")
fi
VERSION_CLEAN="${VERSION#v}"
TAR_NAME="LedGrab-v${VERSION_CLEAN}-linux-x64.tar.gz" TAR_NAME="LedGrab-v${VERSION_CLEAN}-linux-x64.tar.gz"
# Stamp the resolved version into pyproject.toml so that
# importlib.metadata reads the correct value at runtime.
sed -i "s/^version = .*/version = \"${VERSION_CLEAN}\"/" "$SERVER_DIR/pyproject.toml"
echo "=== Building LedGrab v${VERSION_CLEAN} (Linux) ===" echo "=== Building LedGrab v${VERSION_CLEAN} (Linux) ==="
echo " Output: build/$TAR_NAME" echo " Output: build/$TAR_NAME"
echo "" echo ""
# ── Clean ──────────────────────────────────────────────────── # ── Clean ────────────────────────────────────────────────────
if [ -d "$DIST_DIR" ]; then echo "[1/7] Cleaning..."
echo "[1/7] Cleaning previous build..." clean_dist
rm -rf "$DIST_DIR"
fi
mkdir -p "$DIST_DIR"
# ── Create virtualenv ──────────────────────────────────────── # ── Create virtualenv ────────────────────────────────────────
@@ -60,38 +43,25 @@ pip install --upgrade pip --quiet
# ── Install dependencies ───────────────────────────────────── # ── Install dependencies ─────────────────────────────────────
echo "[3/7] Installing dependencies..." echo "[3/7] Installing dependencies..."
pip install --quiet "${SERVER_DIR}[camera,notifications]" 2>&1 | { pip install --quiet "${SERVER_DIR}[notifications]" 2>&1 | {
grep -i 'error\|failed' || true grep -i 'error\|failed' || true
} }
# Remove the installed wled_controller package (PYTHONPATH handles app code) # Resolve site-packages path (glob expand)
SITE_PACKAGES="$VENV_DIR/lib/python*/site-packages" SITE_PACKAGES=$(echo "$VENV_DIR"/lib/python*/site-packages)
rm -rf $SITE_PACKAGES/wled_controller* $SITE_PACKAGES/wled*.dist-info 2>/dev/null || true
# Clean up caches # Clean up with shared function
find "$VENV_DIR" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true cleanup_site_packages "$SITE_PACKAGES" "so" "so"
find "$VENV_DIR" -type d -name tests -exec rm -rf {} + 2>/dev/null || true
find "$VENV_DIR" -type d -name test -exec rm -rf {} + 2>/dev/null || true
# ── Build frontend ─────────────────────────────────────────── # ── Build frontend ───────────────────────────────────────────
echo "[4/7] Building frontend bundle..." echo "[4/7] Building frontend..."
(cd "$SERVER_DIR" && npm ci --loglevel error && npm run build) 2>&1 | { build_frontend
grep -v 'RemoteException' || true
}
# ── Copy application files ─────────────────────────────────── # ── Copy application files ───────────────────────────────────
echo "[5/7] Copying application files..." echo "[5/7] Copying application files..."
mkdir -p "$APP_DIR" copy_app_files
cp -r "$SERVER_DIR/src" "$APP_DIR/src"
cp -r "$SERVER_DIR/config" "$APP_DIR/config"
mkdir -p "$DIST_DIR/data" "$DIST_DIR/logs"
# Clean up source maps and __pycache__
find "$APP_DIR" -name "*.map" -delete 2>/dev/null || true
find "$APP_DIR" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
# ── Create launcher ────────────────────────────────────────── # ── Create launcher ──────────────────────────────────────────

View File

@@ -28,7 +28,6 @@ dependencies = [
"httpx>=0.27.2", "httpx>=0.27.2",
"packaging>=23.0", "packaging>=23.0",
"mss>=9.0.2", "mss>=9.0.2",
"Pillow>=10.4.0",
"numpy>=2.1.3", "numpy>=2.1.3",
"pydantic>=2.9.2", "pydantic>=2.9.2",
"pydantic-settings>=2.6.0", "pydantic-settings>=2.6.0",
@@ -46,6 +45,7 @@ dependencies = [
"sounddevice>=0.5", "sounddevice>=0.5",
"aiomqtt>=2.0.0", "aiomqtt>=2.0.0",
"openrgb-python>=0.2.15", "openrgb-python>=0.2.15",
"opencv-python-headless>=4.8.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
@@ -57,9 +57,11 @@ dev = [
"black>=24.0.0", "black>=24.0.0",
"ruff>=0.6.0", "ruff>=0.6.0",
"opencv-python-headless>=4.8.0", "opencv-python-headless>=4.8.0",
"Pillow>=10.4.0",
] ]
camera = [ camera = [
"opencv-python-headless>=4.8.0", # opencv-python-headless is now a core dependency (used for image encoding)
# camera extra kept for backwards compatibility
] ]
# OS notification capture (winrt packages are ~2.5MB total vs winsdk's ~35MB) # OS notification capture (winrt packages are ~2.5MB total vs winsdk's ~35MB)
notifications = [ notifications = [
@@ -78,6 +80,7 @@ perf = [
] ]
tray = [ tray = [
"pystray>=0.19.0; sys_platform == 'win32'", "pystray>=0.19.0; sys_platform == 'win32'",
"Pillow>=10.4.0; sys_platform == 'win32'",
] ]
[project.urls] [project.urls]

View File

@@ -1,18 +1,21 @@
"""Shared helpers for WebSocket-based capture preview endpoints.""" """Shared helpers for WebSocket-based capture preview endpoints."""
import asyncio import asyncio
import base64
import io
import threading import threading
import time import time
from typing import Callable, Optional from typing import Callable, Optional
import numpy as np import numpy as np
from PIL import Image
from starlette.websockets import WebSocket from starlette.websockets import WebSocket
from wled_controller.core.filters import FilterRegistry, ImagePool from wled_controller.core.filters import FilterRegistry, ImagePool
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
from wled_controller.utils.image_codec import (
encode_jpeg,
encode_jpeg_data_uri,
resize_down,
thumbnail,
)
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -32,47 +35,35 @@ def authenticate_ws_token(token: str) -> bool:
return verify_ws_token(token) return verify_ws_token(token)
def _encode_jpeg(pil_image: Image.Image, quality: int = 85) -> str: def _encode_jpeg(image: np.ndarray, quality: int = 85) -> str:
"""Encode a PIL image as a JPEG base64 data URI.""" """Encode a numpy RGB image as a JPEG base64 data URI."""
buf = io.BytesIO() return encode_jpeg_data_uri(image, quality)
pil_image.save(buf, format="JPEG", quality=quality)
buf.seek(0)
b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
return f"data:image/jpeg;base64,{b64}"
def encode_preview_frame(image: np.ndarray, max_width: int = None, quality: int = 80) -> bytes: def encode_preview_frame(image: np.ndarray, max_width: int = None, quality: int = 80) -> bytes:
"""Encode a numpy RGB image to JPEG bytes, optionally downscaling.""" """Encode a numpy RGB image to JPEG bytes, optionally downscaling."""
pil_img = Image.fromarray(image) if max_width:
if max_width and image.shape[1] > max_width: image = resize_down(image, max_width)
scale = max_width / image.shape[1] return encode_jpeg(image, quality)
new_h = int(image.shape[0] * scale)
pil_img = pil_img.resize((max_width, new_h), Image.LANCZOS)
buf = io.BytesIO()
pil_img.save(buf, format="JPEG", quality=quality)
return buf.getvalue()
def _make_thumbnail(pil_image: Image.Image, max_width: int) -> Image.Image: def _make_thumbnail(image: np.ndarray, max_width: int) -> np.ndarray:
"""Create a thumbnail copy of the image, preserving aspect ratio.""" """Create a thumbnail copy of the image, preserving aspect ratio."""
thumb = pil_image.copy() return thumbnail(image, max_width)
aspect = pil_image.height / pil_image.width
thumb.thumbnail((max_width, int(max_width * aspect)), Image.Resampling.LANCZOS)
return thumb
def _apply_pp_filters(pil_image: Image.Image, flat_filters: list) -> Image.Image: def _apply_pp_filters(image: np.ndarray, flat_filters: list) -> np.ndarray:
"""Apply postprocessing filter instances to a PIL image.""" """Apply postprocessing filter instances to a numpy image."""
if not flat_filters: if not flat_filters:
return pil_image return image
pool = ImagePool() pool = ImagePool()
arr = np.array(pil_image) arr = image
for fi in flat_filters: for fi in flat_filters:
f = FilterRegistry.create_instance(fi.filter_id, fi.options) f = FilterRegistry.create_instance(fi.filter_id, fi.options)
result = f.process_image(arr, pool) result = f.process_image(arr, pool)
if result is not None: if result is not None:
arr = result arr = result
return Image.fromarray(arr) return arr
async def stream_capture_test( async def stream_capture_test(
@@ -98,7 +89,7 @@ async def stream_capture_test(
thumb_width = preview_width or PREVIEW_MAX_WIDTH thumb_width = preview_width or PREVIEW_MAX_WIDTH
# Shared state between capture thread and async loop # Shared state between capture thread and async loop
latest_frame = None # PIL Image (converted from numpy) latest_frame = None # numpy RGB array
frame_count = 0 frame_count = 0
total_capture_time = 0.0 total_capture_time = 0.0
stop_event = threading.Event() stop_event = threading.Event()
@@ -121,9 +112,8 @@ async def stream_capture_test(
continue continue
total_capture_time += t1 - t0 total_capture_time += t1 - t0
frame_count += 1 frame_count += 1
# Convert numpy -> PIL once in the capture thread
if isinstance(capture.image, np.ndarray): if isinstance(capture.image, np.ndarray):
latest_frame = Image.fromarray(capture.image) latest_frame = capture.image
else: else:
latest_frame = capture.image latest_frame = capture.image
except Exception as e: except Exception as e:
@@ -202,7 +192,7 @@ async def stream_capture_test(
if pp_filters: if pp_filters:
final_frame = _apply_pp_filters(final_frame, pp_filters) final_frame = _apply_pp_filters(final_frame, pp_filters)
w, h = final_frame.size h, w = final_frame.shape[:2]
full_uri = _encode_jpeg(final_frame, FINAL_JPEG_QUALITY) full_uri = _encode_jpeg(final_frame, FINAL_JPEG_QUALITY)
thumb = _make_thumbnail(final_frame, FINAL_THUMBNAIL_WIDTH) thumb = _make_thumbnail(final_frame, FINAL_THUMBNAIL_WIDTH)

View File

@@ -1,7 +1,6 @@
"""Color strip source routes: CRUD, calibration test, preview, and API input push.""" """Color strip source routes: CRUD, calibration test, preview, and API input push."""
import asyncio import asyncio
import io as _io
import json as _json import json as _json
import time as _time import time as _time
import uuid as _uuid import uuid as _uuid
@@ -989,7 +988,8 @@ async def test_color_strip_ws(
try: try:
frame = _frame_live.get_latest_frame() frame = _frame_live.get_latest_frame()
if frame is not None and frame.image is not None: if frame is not None and frame.image is not None:
from PIL import Image as _PIL_Image from wled_controller.utils.image_codec import encode_jpeg
import cv2 as _cv2
img = frame.image img = frame.image
# Ensure 3-channel RGB (some engines may produce BGRA) # Ensure 3-channel RGB (some engines may produce BGRA)
if img.ndim == 3 and img.shape[2] == 4: if img.ndim == 3 and img.shape[2] == 4:
@@ -1008,13 +1008,9 @@ async def test_color_strip_ws(
if scale < 1.0: if scale < 1.0:
new_w = max(1, int(w * scale)) new_w = max(1, int(w * scale))
new_h = max(1, int(h * scale)) new_h = max(1, int(h * scale))
pil = _PIL_Image.fromarray(img).resize((new_w, new_h), _PIL_Image.LANCZOS) img = _cv2.resize(img, (new_w, new_h), interpolation=_cv2.INTER_AREA)
else:
pil = _PIL_Image.fromarray(img)
buf = _io.BytesIO()
pil.save(buf, format='JPEG', quality=70)
# Wire format: [0xFD] [jpeg_bytes] # Wire format: [0xFD] [jpeg_bytes]
await websocket.send_bytes(b'\xfd' + buf.getvalue()) await websocket.send_bytes(b'\xfd' + encode_jpeg(img, quality=70))
except Exception as e: except Exception as e:
logger.warning(f"JPEG frame preview error: {e}") logger.warning(f"JPEG frame preview error: {e}")

View File

@@ -4,13 +4,10 @@ Extracted from output_targets.py to keep files under 800 lines.
""" """
import asyncio import asyncio
import base64
import io
import time import time
import numpy as np import numpy as np
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from PIL import Image
from wled_controller.api.auth import AuthRequired from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import ( from wled_controller.api.dependencies import (
@@ -133,19 +130,21 @@ async def test_kc_target(
raw_stream = chain["raw_stream"] raw_stream = chain["raw_stream"]
from wled_controller.utils.image_codec import load_image_bytes, load_image_file
if isinstance(raw_stream, StaticImagePictureSource): if isinstance(raw_stream, StaticImagePictureSource):
source = raw_stream.image_source source = raw_stream.image_source
if source.startswith(("http://", "https://")): if source.startswith(("http://", "https://")):
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
resp = await client.get(source) resp = await client.get(source)
resp.raise_for_status() resp.raise_for_status()
pil_image = Image.open(io.BytesIO(resp.content)).convert("RGB") image = load_image_bytes(resp.content)
else: else:
from pathlib import Path from pathlib import Path
path = Path(source) path = Path(source)
if not path.exists(): if not path.exists():
raise HTTPException(status_code=400, detail=f"Image file not found: {source}") raise HTTPException(status_code=400, detail=f"Image file not found: {source}")
pil_image = Image.open(path).convert("RGB") image = load_image_file(path)
elif isinstance(raw_stream, ScreenCapturePictureSource): elif isinstance(raw_stream, ScreenCapturePictureSource):
try: try:
@@ -186,17 +185,15 @@ async def test_kc_target(
if screen_capture is None: if screen_capture is None:
raise RuntimeError("No frame captured") raise RuntimeError("No frame captured")
if isinstance(screen_capture.image, np.ndarray): if not isinstance(screen_capture.image, np.ndarray):
pil_image = Image.fromarray(screen_capture.image)
else:
raise ValueError("Unexpected image format from engine") raise ValueError("Unexpected image format from engine")
image = screen_capture.image
else: else:
raise HTTPException(status_code=400, detail="Unsupported picture source type") raise HTTPException(status_code=400, detail="Unsupported picture source type")
# 3b. Apply postprocessing filters (if the picture source has a filter chain) # 3b. Apply postprocessing filters (if the picture source has a filter chain)
pp_template_ids = chain.get("postprocessing_template_ids", []) pp_template_ids = chain.get("postprocessing_template_ids", [])
if pp_template_ids and pp_template_store: if pp_template_ids and pp_template_store:
img_array = np.array(pil_image)
image_pool = ImagePool() image_pool = ImagePool()
for pp_id in pp_template_ids: for pp_id in pp_template_ids:
try: try:
@@ -208,15 +205,14 @@ async def test_kc_target(
for fi in flat_filters: for fi in flat_filters:
try: try:
f = FilterRegistry.create_instance(fi.filter_id, fi.options) f = FilterRegistry.create_instance(fi.filter_id, fi.options)
result = f.process_image(img_array, image_pool) result = f.process_image(image, image_pool)
if result is not None: if result is not None:
img_array = result image = result
except ValueError: except ValueError:
logger.warning(f"KC test: unknown filter '{fi.filter_id}', skipping") logger.warning(f"KC test: unknown filter '{fi.filter_id}', skipping")
pil_image = Image.fromarray(img_array)
# 4. Extract colors from each rectangle # 4. Extract colors from each rectangle
img_array = np.array(pil_image) img_array = image
h, w = img_array.shape[:2] h, w = img_array.shape[:2]
calc_fns = { calc_fns = {
@@ -250,11 +246,8 @@ async def test_kc_target(
)) ))
# 5. Encode frame as base64 JPEG # 5. Encode frame as base64 JPEG
full_buffer = io.BytesIO() from wled_controller.utils.image_codec import encode_jpeg_data_uri
pil_image.save(full_buffer, format='JPEG', quality=90) image_data_uri = encode_jpeg_data_uri(image, quality=90)
full_buffer.seek(0)
full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8')
image_data_uri = f"data:image/jpeg;base64,{full_b64}"
return KCTestResponse( return KCTestResponse(
image=image_data_uri, image=image_data_uri,
@@ -411,8 +404,11 @@ async def test_kc_target_ws(
continue continue
prev_frame_ref = capture prev_frame_ref = capture
pil_image = Image.fromarray(capture.image) if isinstance(capture.image, np.ndarray) else None if not isinstance(capture.image, np.ndarray):
if pil_image is None: await asyncio.sleep(frame_interval)
continue
cur_image = capture.image
if cur_image is None:
await asyncio.sleep(frame_interval) await asyncio.sleep(frame_interval)
continue continue
@@ -420,7 +416,6 @@ async def test_kc_target_ws(
chain = source_store_inst.resolve_stream_chain(target.picture_source_id) chain = source_store_inst.resolve_stream_chain(target.picture_source_id)
pp_template_ids = chain.get("postprocessing_template_ids", []) pp_template_ids = chain.get("postprocessing_template_ids", [])
if pp_template_ids and pp_template_store_inst: if pp_template_ids and pp_template_store_inst:
img_array = np.array(pil_image)
image_pool = ImagePool() image_pool = ImagePool()
for pp_id in pp_template_ids: for pp_id in pp_template_ids:
try: try:
@@ -431,15 +426,14 @@ async def test_kc_target_ws(
for fi in flat_filters: for fi in flat_filters:
try: try:
f = FilterRegistry.create_instance(fi.filter_id, fi.options) f = FilterRegistry.create_instance(fi.filter_id, fi.options)
result = f.process_image(img_array, image_pool) result = f.process_image(cur_image, image_pool)
if result is not None: if result is not None:
img_array = result cur_image = result
except ValueError: except ValueError:
pass pass
pil_image = Image.fromarray(img_array)
# Extract colors # Extract colors
img_array = np.array(pil_image) img_array = cur_image
h, w = img_array.shape[:2] h, w = img_array.shape[:2]
result_rects = [] result_rects = []
@@ -466,18 +460,13 @@ async def test_kc_target_ws(
}) })
# Encode frame as JPEG # Encode frame as JPEG
if preview_width and pil_image.width > preview_width: from wled_controller.utils.image_codec import encode_jpeg_data_uri, resize_down
ratio = preview_width / pil_image.width frame_to_encode = resize_down(cur_image, preview_width) if preview_width else cur_image
thumb = pil_image.resize((preview_width, int(pil_image.height * ratio)), Image.LANCZOS) frame_uri = encode_jpeg_data_uri(frame_to_encode, quality=85)
else:
thumb = pil_image
buf = io.BytesIO()
thumb.save(buf, format="JPEG", quality=85)
b64 = base64.b64encode(buf.getvalue()).decode()
await websocket.send_text(_json.dumps({ await websocket.send_text(_json.dumps({
"type": "frame", "type": "frame",
"image": f"data:image/jpeg;base64,{b64}", "image": frame_uri,
"rectangles": result_rects, "rectangles": result_rects,
"pattern_template_name": pattern_tmpl.name, "pattern_template_name": pattern_tmpl.name,
"interpolation_mode": settings.interpolation_mode, "interpolation_mode": settings.interpolation_mode,

View File

@@ -1,13 +1,10 @@
"""Picture source routes.""" """Picture source routes."""
import asyncio import asyncio
import base64
import io
import time import time
import httpx import httpx
import numpy as np import numpy as np
from PIL import Image
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from fastapi.responses import Response from fastapi.responses import Response
@@ -115,16 +112,20 @@ async def validate_image(
img_bytes = path img_bytes = path
def _process_image(src): def _process_image(src):
pil_image = Image.open(io.BytesIO(src) if isinstance(src, bytes) else src) from wled_controller.utils.image_codec import (
pil_image = pil_image.convert("RGB") encode_jpeg_data_uri,
width, height = pil_image.size load_image_bytes,
thumb = pil_image.copy() load_image_file,
thumb.thumbnail((320, 320), Image.Resampling.LANCZOS) thumbnail as make_thumbnail,
buf = io.BytesIO() )
thumb.save(buf, format="JPEG", quality=80) if isinstance(src, bytes):
buf.seek(0) image = load_image_bytes(src)
preview = f"data:image/jpeg;base64,{base64.b64encode(buf.getvalue()).decode()}" else:
return width, height, preview image = load_image_file(src)
h, w = image.shape[:2]
thumb = make_thumbnail(image, 320)
preview = encode_jpeg_data_uri(thumb, quality=80)
return w, h, preview
width, height, preview = await asyncio.to_thread(_process_image, img_bytes) width, height, preview = await asyncio.to_thread(_process_image, img_bytes)
@@ -161,11 +162,12 @@ async def get_full_image(
img_bytes = path img_bytes = path
def _encode_full(src): def _encode_full(src):
pil_image = Image.open(io.BytesIO(src) if isinstance(src, bytes) else src) from wled_controller.utils.image_codec import encode_jpeg, load_image_bytes, load_image_file
pil_image = pil_image.convert("RGB") if isinstance(src, bytes):
buf = io.BytesIO() image = load_image_bytes(src)
pil_image.save(buf, format="JPEG", quality=90) else:
return buf.getvalue() image = load_image_file(src)
return encode_jpeg(image, quality=90)
jpeg_bytes = await asyncio.to_thread(_encode_full, img_bytes) jpeg_bytes = await asyncio.to_thread(_encode_full, img_bytes)
return Response(content=jpeg_bytes, media_type="image/jpeg") return Response(content=jpeg_bytes, media_type="image/jpeg")
@@ -333,13 +335,9 @@ async def get_video_thumbnail(
store: PictureSourceStore = Depends(get_picture_source_store), store: PictureSourceStore = Depends(get_picture_source_store),
): ):
"""Get a thumbnail for a video picture source (first frame).""" """Get a thumbnail for a video picture source (first frame)."""
import base64
from io import BytesIO
from PIL import Image
from wled_controller.core.processing.video_stream import extract_thumbnail from wled_controller.core.processing.video_stream import extract_thumbnail
from wled_controller.storage.picture_source import VideoCaptureSource from wled_controller.storage.picture_source import VideoCaptureSource
from wled_controller.utils.image_codec import encode_jpeg_data_uri, resize_down
try: try:
source = store.get_stream(stream_id) source = store.get_stream(stream_id)
@@ -352,18 +350,12 @@ async def get_video_thumbnail(
if frame is None: if frame is None:
raise HTTPException(status_code=404, detail="Could not extract thumbnail") raise HTTPException(status_code=404, detail="Could not extract thumbnail")
# Encode as JPEG
pil_img = Image.fromarray(frame)
# Resize to max 320px wide for thumbnail # Resize to max 320px wide for thumbnail
if pil_img.width > 320: frame = resize_down(frame, 320)
ratio = 320 / pil_img.width h, w = frame.shape[:2]
pil_img = pil_img.resize((320, int(pil_img.height * ratio)), Image.LANCZOS) data_uri = encode_jpeg_data_uri(frame, quality=80)
buf = BytesIO() return {"thumbnail": data_uri, "width": w, "height": h}
pil_img.save(buf, format="JPEG", quality=80)
b64 = base64.b64encode(buf.getvalue()).decode()
return {"thumbnail": f"data:image/jpeg;base64,{b64}", "width": pil_img.width, "height": pil_img.height}
except HTTPException: except HTTPException:
raise raise
@@ -408,16 +400,18 @@ async def test_picture_source(
source = raw_stream.image_source source = raw_stream.image_source
start_time = time.perf_counter() start_time = time.perf_counter()
from wled_controller.utils.image_codec import load_image_bytes, load_image_file
if source.startswith(("http://", "https://")): if source.startswith(("http://", "https://")):
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
resp = await client.get(source) resp = await client.get(source)
resp.raise_for_status() resp.raise_for_status()
pil_image = Image.open(io.BytesIO(resp.content)).convert("RGB") image = load_image_bytes(resp.content)
else: else:
path = Path(source) path = Path(source)
if not path.exists(): if not path.exists():
raise HTTPException(status_code=400, detail=f"Image file not found: {source}") raise HTTPException(status_code=400, detail=f"Image file not found: {source}")
pil_image = await asyncio.to_thread(lambda: Image.open(path).convert("RGB")) image = await asyncio.to_thread(load_image_file, path)
actual_duration = time.perf_counter() - start_time actual_duration = time.perf_counter() - start_time
frame_count = 1 frame_count = 1
@@ -479,12 +473,13 @@ async def test_picture_source(
if last_frame is None: if last_frame is None:
raise RuntimeError("No frames captured during test") raise RuntimeError("No frames captured during test")
if isinstance(last_frame.image, np.ndarray): if not isinstance(last_frame.image, np.ndarray):
pil_image = Image.fromarray(last_frame.image)
else:
raise ValueError("Unexpected image format from engine") raise ValueError("Unexpected image format from engine")
image = last_frame.image
# Create thumbnail + encode (CPU-bound — run in thread) # Create thumbnail + encode (CPU-bound — run in thread)
from wled_controller.utils.image_codec import encode_jpeg_data_uri, thumbnail as make_thumbnail
pp_template_ids = chain["postprocessing_template_ids"] pp_template_ids = chain["postprocessing_template_ids"]
flat_filters = None flat_filters = None
if pp_template_ids: if pp_template_ids:
@@ -494,45 +489,33 @@ async def test_picture_source(
except ValueError: except ValueError:
logger.warning(f"PP template {pp_template_ids[0]} not found, skipping postprocessing preview") logger.warning(f"PP template {pp_template_ids[0]} not found, skipping postprocessing preview")
def _create_thumbnails_and_encode(pil_img, filters): def _create_thumbnails_and_encode(img, filters):
thumbnail_w = 640 thumb = make_thumbnail(img, 640)
aspect_ratio = pil_img.height / pil_img.width
thumbnail_h = int(thumbnail_w * aspect_ratio)
thumb = pil_img.copy()
thumb.thumbnail((thumbnail_w, thumbnail_h), Image.Resampling.LANCZOS)
if filters: if filters:
pool = ImagePool() pool = ImagePool()
def apply_filters(img): def apply_filters(arr):
arr = np.array(img)
for fi in filters: for fi in filters:
f = FilterRegistry.create_instance(fi.filter_id, fi.options) f = FilterRegistry.create_instance(fi.filter_id, fi.options)
result = f.process_image(arr, pool) result = f.process_image(arr, pool)
if result is not None: if result is not None:
arr = result arr = result
return Image.fromarray(arr) return arr
thumb = apply_filters(thumb) thumb = apply_filters(thumb)
pil_img = apply_filters(pil_img) img = apply_filters(img)
img_buffer = io.BytesIO() thumb_uri = encode_jpeg_data_uri(thumb, quality=85)
thumb.save(img_buffer, format='JPEG', quality=85) full_uri = encode_jpeg_data_uri(img, quality=90)
thumb_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8') th, tw = thumb.shape[:2]
return tw, th, thumb_uri, full_uri
full_buffer = io.BytesIO() thumbnail_width, thumbnail_height, thumbnail_data_uri, full_data_uri = await asyncio.to_thread(
pil_img.save(full_buffer, format='JPEG', quality=90) _create_thumbnails_and_encode, image, flat_filters
full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8')
return thumbnail_w, thumbnail_h, thumb_b64, full_b64
thumbnail_width, thumbnail_height, thumbnail_b64, full_b64 = await asyncio.to_thread(
_create_thumbnails_and_encode, pil_image, flat_filters
) )
thumbnail_data_uri = f"data:image/jpeg;base64,{thumbnail_b64}"
full_data_uri = f"data:image/jpeg;base64,{full_b64}"
actual_fps = frame_count / actual_duration if actual_duration > 0 else 0 actual_fps = frame_count / actual_duration if actual_duration > 0 else 0
avg_capture_time_ms = (total_capture_time / frame_count * 1000) if frame_count > 0 else 0 avg_capture_time_ms = (total_capture_time / frame_count * 1000) if frame_count > 0 else 0
width, height = pil_image.size height, width = image.shape[:2]
return TemplateTestResponse( return TemplateTestResponse(
full_capture=CaptureImage( full_capture=CaptureImage(
@@ -635,15 +618,11 @@ async def test_picture_source_ws(
def _encode_video_frame(image, pw): def _encode_video_frame(image, pw):
"""Encode numpy RGB image as JPEG base64 data URI.""" """Encode numpy RGB image as JPEG base64 data URI."""
from PIL import Image as PILImage from wled_controller.utils.image_codec import encode_jpeg_data_uri, resize_down
pil = PILImage.fromarray(image) if pw:
if pw and pil.width > pw: image = resize_down(image, pw)
ratio = pw / pil.width h, w = image.shape[:2]
pil = pil.resize((pw, int(pil.height * ratio)), PILImage.LANCZOS) return encode_jpeg_data_uri(image, quality=80), w, h
buf = io.BytesIO()
pil.save(buf, format="JPEG", quality=80)
b64 = base64.b64encode(buf.getvalue()).decode()
return f"data:image/jpeg;base64,{b64}", pil.width, pil.height
try: try:
await asyncio.get_event_loop().run_in_executor(None, video_stream.start) await asyncio.get_event_loop().run_in_executor(None, video_stream.start)

View File

@@ -1,12 +1,9 @@
"""Postprocessing template routes.""" """Postprocessing template routes."""
import base64
import io
import time import time
import httpx import httpx
import numpy as np import numpy as np
from PIL import Image
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired from wled_controller.api.auth import AuthRequired
@@ -198,6 +195,13 @@ async def test_pp_template(
raw_stream = chain["raw_stream"] raw_stream = chain["raw_stream"]
from wled_controller.utils.image_codec import (
encode_jpeg_data_uri,
load_image_bytes,
load_image_file,
thumbnail as make_thumbnail,
)
if isinstance(raw_stream, StaticImagePictureSource): if isinstance(raw_stream, StaticImagePictureSource):
# Static image: load directly # Static image: load directly
from pathlib import Path from pathlib import Path
@@ -209,12 +213,12 @@ async def test_pp_template(
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
resp = await client.get(source) resp = await client.get(source)
resp.raise_for_status() resp.raise_for_status()
pil_image = Image.open(io.BytesIO(resp.content)).convert("RGB") image = load_image_bytes(resp.content)
else: else:
path = Path(source) path = Path(source)
if not path.exists(): if not path.exists():
raise HTTPException(status_code=400, detail=f"Image file not found: {source}") raise HTTPException(status_code=400, detail=f"Image file not found: {source}")
pil_image = Image.open(path).convert("RGB") image = load_image_file(path)
actual_duration = time.perf_counter() - start_time actual_duration = time.perf_counter() - start_time
frame_count = 1 frame_count = 1
@@ -268,53 +272,37 @@ async def test_pp_template(
if last_frame is None: if last_frame is None:
raise RuntimeError("No frames captured during test") raise RuntimeError("No frames captured during test")
if isinstance(last_frame.image, np.ndarray): if not isinstance(last_frame.image, np.ndarray):
pil_image = Image.fromarray(last_frame.image)
else:
raise ValueError("Unexpected image format from engine") raise ValueError("Unexpected image format from engine")
image = last_frame.image
# Create thumbnail # Create thumbnail
thumbnail_width = 640 thumb = make_thumbnail(image, 640)
aspect_ratio = pil_image.height / pil_image.width
thumbnail_height = int(thumbnail_width * aspect_ratio)
thumbnail = pil_image.copy()
thumbnail.thumbnail((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS)
# Apply postprocessing filters (expand filter_template references) # Apply postprocessing filters (expand filter_template references)
flat_filters = pp_store.resolve_filter_instances(pp_template.filters) flat_filters = pp_store.resolve_filter_instances(pp_template.filters)
if flat_filters: if flat_filters:
pool = ImagePool() pool = ImagePool()
def apply_filters(img): def apply_filters(arr):
arr = np.array(img)
for fi in flat_filters: for fi in flat_filters:
f = FilterRegistry.create_instance(fi.filter_id, fi.options) f = FilterRegistry.create_instance(fi.filter_id, fi.options)
result = f.process_image(arr, pool) result = f.process_image(arr, pool)
if result is not None: if result is not None:
arr = result arr = result
return Image.fromarray(arr) return arr
thumbnail = apply_filters(thumbnail) thumb = apply_filters(thumb)
pil_image = apply_filters(pil_image) image = apply_filters(image)
# Encode thumbnail # Encode as JPEG
img_buffer = io.BytesIO() thumbnail_data_uri = encode_jpeg_data_uri(thumb, quality=85)
thumbnail.save(img_buffer, format='JPEG', quality=85) full_data_uri = encode_jpeg_data_uri(image, quality=90)
img_buffer.seek(0)
thumbnail_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
thumbnail_data_uri = f"data:image/jpeg;base64,{thumbnail_b64}"
# Encode full-resolution image
full_buffer = io.BytesIO()
pil_image.save(full_buffer, format='JPEG', quality=90)
full_buffer.seek(0)
full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8')
full_data_uri = f"data:image/jpeg;base64,{full_b64}"
actual_fps = frame_count / actual_duration if actual_duration > 0 else 0 actual_fps = frame_count / actual_duration if actual_duration > 0 else 0
avg_capture_time_ms = (total_capture_time / frame_count * 1000) if frame_count > 0 else 0 avg_capture_time_ms = (total_capture_time / frame_count * 1000) if frame_count > 0 else 0
width, height = pil_image.size height, width = image.shape[:2]
thumb_w, thumb_h = thumbnail.size thumb_h, thumb_w = thumb.shape[:2]
return TemplateTestResponse( return TemplateTestResponse(
full_capture=CaptureImage( full_capture=CaptureImage(

View File

@@ -1,11 +1,8 @@
"""Capture template, engine, and filter routes.""" """Capture template, engine, and filter routes."""
import base64
import io
import time import time
import numpy as np import numpy as np
from PIL import Image
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired from wled_controller.api.auth import AuthRequired
@@ -320,38 +317,28 @@ def test_template(
if last_frame is None: if last_frame is None:
raise RuntimeError("No frames captured during test") raise RuntimeError("No frames captured during test")
# Convert numpy array to PIL Image if not isinstance(last_frame.image, np.ndarray):
if isinstance(last_frame.image, np.ndarray):
pil_image = Image.fromarray(last_frame.image)
else:
raise ValueError("Unexpected image format from engine") raise ValueError("Unexpected image format from engine")
image = last_frame.image
from wled_controller.utils.image_codec import (
encode_jpeg_data_uri,
thumbnail as make_thumbnail,
)
# Create thumbnail (640px wide, maintain aspect ratio) # Create thumbnail (640px wide, maintain aspect ratio)
thumbnail_width = 640 thumb = make_thumbnail(image, 640)
aspect_ratio = pil_image.height / pil_image.width thumb_h, thumb_w = thumb.shape[:2]
thumbnail_height = int(thumbnail_width * aspect_ratio)
thumbnail = pil_image.copy()
thumbnail.thumbnail((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS)
# Encode thumbnail as JPEG # Encode as JPEG
img_buffer = io.BytesIO() thumbnail_data_uri = encode_jpeg_data_uri(thumb, quality=85)
thumbnail.save(img_buffer, format='JPEG', quality=85) full_data_uri = encode_jpeg_data_uri(image, quality=90)
img_buffer.seek(0)
thumbnail_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
thumbnail_data_uri = f"data:image/jpeg;base64,{thumbnail_b64}"
# Encode full-resolution image as JPEG
full_buffer = io.BytesIO()
pil_image.save(full_buffer, format='JPEG', quality=90)
full_buffer.seek(0)
full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8')
full_data_uri = f"data:image/jpeg;base64,{full_b64}"
# Calculate metrics # Calculate metrics
actual_fps = frame_count / actual_duration if actual_duration > 0 else 0 actual_fps = frame_count / actual_duration if actual_duration > 0 else 0
avg_capture_time_ms = (total_capture_time / frame_count * 1000) if frame_count > 0 else 0 avg_capture_time_ms = (total_capture_time / frame_count * 1000) if frame_count > 0 else 0
width, height = pil_image.size height, width = image.shape[:2]
return TemplateTestResponse( return TemplateTestResponse(
full_capture=CaptureImage( full_capture=CaptureImage(
@@ -359,8 +346,8 @@ def test_template(
full_image=full_data_uri, full_image=full_data_uri,
width=width, width=width,
height=height, height=height,
thumbnail_width=thumbnail_width, thumbnail_width=thumb_w,
thumbnail_height=thumbnail_height, thumbnail_height=thumb_h,
), ),
border_extraction=None, border_extraction=None,
performance=PerformanceMetrics( performance=PerformanceMetrics(

View File

@@ -12,7 +12,6 @@ Prerequisites (system binaries, NOT Python packages):
- adb (bundled with scrcpy, or Android SDK Platform-Tools) - adb (bundled with scrcpy, or Android SDK Platform-Tools)
""" """
import io
import os import os
import re import re
import shutil import shutil
@@ -22,7 +21,8 @@ import time
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import numpy as np import numpy as np
from PIL import Image
from wled_controller.utils.image_codec import load_image_bytes
from wled_controller.core.capture_engines.base import ( from wled_controller.core.capture_engines.base import (
CaptureEngine, CaptureEngine,
@@ -144,8 +144,7 @@ def _screencap_once(adb: str, serial: str) -> Optional[np.ndarray]:
if result.returncode != 0 or len(result.stdout) < 100: if result.returncode != 0 or len(result.stdout) < 100:
return None return None
img = Image.open(io.BytesIO(result.stdout)) return load_image_bytes(result.stdout)
return np.asarray(img.convert("RGB"))
except Exception as e: except Exception as e:
logger.debug(f"screencap failed for {serial}: {e}") logger.debug(f"screencap failed for {serial}: {e}")
return None return None

View File

@@ -2,8 +2,8 @@
from typing import List, Optional from typing import List, Optional
import cv2
import numpy as np import numpy as np
from PIL import Image
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
from wled_controller.core.filters.image_pool import ImagePool from wled_controller.core.filters.image_pool import ImagePool
@@ -44,8 +44,7 @@ class DownscalerFilter(PostprocessingFilter):
if new_h == h and new_w == w: if new_h == h and new_w == w:
return None return None
pil_img = Image.fromarray(image) downscaled = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_AREA)
downscaled = np.array(pil_img.resize((new_w, new_h), Image.LANCZOS))
result = image_pool.acquire(new_h, new_w, image.shape[2] if image.ndim == 3 else 3) result = image_pool.acquire(new_h, new_w, image.shape[2] if image.ndim == 3 else 3)
np.copyto(result, downscaled) np.copyto(result, downscaled)

View File

@@ -2,8 +2,8 @@
from typing import List, Optional from typing import List, Optional
import cv2
import numpy as np import numpy as np
from PIL import Image
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
from wled_controller.core.filters.image_pool import ImagePool from wled_controller.core.filters.image_pool import ImagePool
@@ -42,9 +42,8 @@ class PixelateFilter(PostprocessingFilter):
# vectorized C++ instead of per-block Python loop # vectorized C++ instead of per-block Python loop
small_w = max(1, w // block_size) small_w = max(1, w // block_size)
small_h = max(1, h // block_size) small_h = max(1, h // block_size)
pil_img = Image.fromarray(image) small = cv2.resize(image, (small_w, small_h), interpolation=cv2.INTER_AREA)
small = pil_img.resize((small_w, small_h), Image.LANCZOS) pixelated = cv2.resize(small, (w, h), interpolation=cv2.INTER_NEAREST)
pixelated = np.array(small.resize((w, h), Image.NEAREST))
np.copyto(image, pixelated) np.copyto(image, pixelated)
return None return None

View File

@@ -9,8 +9,8 @@ import time
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
import cv2
import numpy as np import numpy as np
from PIL import Image
from wled_controller.core.processing.live_stream import LiveStream from wled_controller.core.processing.live_stream import LiveStream
from wled_controller.core.capture.screen_capture import ( from wled_controller.core.capture.screen_capture import (
@@ -46,8 +46,7 @@ def _process_kc_frame(capture, rect_names, rect_bounds, calc_fn, prev_colors_arr
t0 = time.perf_counter() t0 = time.perf_counter()
# Downsample to working resolution — 144x fewer pixels at 1080p # Downsample to working resolution — 144x fewer pixels at 1080p
pil_img = Image.fromarray(capture.image) small = cv2.resize(capture.image, KC_WORK_SIZE, interpolation=cv2.INTER_AREA)
small = np.array(pil_img.resize(KC_WORK_SIZE, Image.LANCZOS))
# Extract colors for each rectangle from the small image # Extract colors for each rectangle from the small image
n = len(rect_names) n = len(rect_names)

View File

@@ -311,20 +311,16 @@ class LiveStreamManager:
This is acceptable because acquire() (the only caller chain) is always This is acceptable because acquire() (the only caller chain) is always
invoked from background worker threads, never from the async event loop. invoked from background worker threads, never from the async event loop.
""" """
from io import BytesIO
from pathlib import Path from pathlib import Path
from PIL import Image from wled_controller.utils.image_codec import load_image_bytes, load_image_file
if image_source.startswith(("http://", "https://")): if image_source.startswith(("http://", "https://")):
response = httpx.get(image_source, timeout=15.0, follow_redirects=True) response = httpx.get(image_source, timeout=15.0, follow_redirects=True)
response.raise_for_status() response.raise_for_status()
pil_image = Image.open(BytesIO(response.content)) return load_image_bytes(response.content)
else: else:
path = Path(image_source) path = Path(image_source)
if not path.exists(): if not path.exists():
raise FileNotFoundError(f"Image file not found: {image_source}") raise FileNotFoundError(f"Image file not found: {image_source}")
pil_image = Image.open(path) return load_image_file(path)
pil_image = pil_image.convert("RGB")
return np.array(pil_image)

View File

@@ -7,17 +7,16 @@ from pathlib import Path
from tkinter import messagebox from tkinter import messagebox
from typing import Callable from typing import Callable
from PIL import Image
try: try:
import pystray import pystray
from PIL import Image
PYSTRAY_AVAILABLE = True PYSTRAY_AVAILABLE = True
except ImportError: except ImportError:
PYSTRAY_AVAILABLE = False PYSTRAY_AVAILABLE = False
def _load_icon(icon_path: Path) -> Image.Image: def _load_icon(icon_path: Path) -> "Image.Image":
"""Load tray icon from PNG, with a solid-color fallback.""" """Load tray icon from PNG, with a solid-color fallback."""
if icon_path.exists(): if icon_path.exists():
return Image.open(icon_path) return Image.open(icon_path)

View File

@@ -0,0 +1,91 @@
"""Image encoding/decoding/resizing utilities using OpenCV.
Replaces PIL/Pillow for JPEG encoding, image loading, and resizing operations.
All functions work with numpy RGB arrays (H, W, 3) uint8.
"""
import base64
from pathlib import Path
from typing import Tuple, Union
import cv2
import numpy as np
def encode_jpeg(image: np.ndarray, quality: int = 85) -> bytes:
"""Encode an RGB numpy array as JPEG bytes."""
bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
ok, buf = cv2.imencode(".jpg", bgr, [cv2.IMWRITE_JPEG_QUALITY, quality])
if not ok:
raise RuntimeError("JPEG encoding failed")
return buf.tobytes()
def encode_jpeg_data_uri(image: np.ndarray, quality: int = 85) -> str:
"""Encode an RGB numpy array as a JPEG base64 data URI."""
raw = encode_jpeg(image, quality)
b64 = base64.b64encode(raw).decode("utf-8")
return f"data:image/jpeg;base64,{b64}"
def resize_image(image: np.ndarray, width: int, height: int) -> np.ndarray:
"""Resize an image to exact dimensions.
Uses INTER_AREA for downscaling (better quality, faster) and
INTER_LANCZOS4 for upscaling.
"""
h, w = image.shape[:2]
shrinking = (width * height) < (w * h)
interp = cv2.INTER_AREA if shrinking else cv2.INTER_LANCZOS4
return cv2.resize(image, (width, height), interpolation=interp)
def thumbnail(image: np.ndarray, max_width: int) -> np.ndarray:
"""Create a thumbnail that fits within max_width, preserving aspect ratio.
Uses INTER_AREA (optimal for downscaling).
"""
h, w = image.shape[:2]
if w <= max_width:
return image.copy()
scale = max_width / w
new_w = max_width
new_h = max(1, int(h * scale))
return cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_AREA)
def resize_down(image: np.ndarray, max_width: int) -> np.ndarray:
"""Downscale if wider than max_width; return as-is otherwise.
Uses INTER_AREA (optimal for downscaling).
"""
h, w = image.shape[:2]
if w <= max_width:
return image
scale = max_width / w
new_w = max_width
new_h = max(1, int(h * scale))
return cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_AREA)
def load_image_file(path: Union[str, Path]) -> np.ndarray:
"""Load an image file and return as RGB numpy array."""
path = str(path)
bgr = cv2.imread(path, cv2.IMREAD_COLOR)
if bgr is None:
raise FileNotFoundError(f"Cannot load image: {path}")
return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
def load_image_bytes(data: bytes) -> np.ndarray:
"""Decode image bytes (JPEG, PNG, etc.) and return as RGB numpy array."""
arr = np.frombuffer(data, dtype=np.uint8)
bgr = cv2.imdecode(arr, cv2.IMREAD_COLOR)
if bgr is None:
raise ValueError("Cannot decode image data")
return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
def image_size(image: np.ndarray) -> Tuple[int, int]:
"""Return (width, height) of an image array."""
return image.shape[1], image.shape[0]