Skip to content
Closed
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
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,51 @@ pip install .
faststack
```

### Windows Performance Note
On Windows, `PyTurboJPEG` also needs the native `libjpeg-turbo` library (`turbojpeg.dll`).

- If `turbojpeg.dll` is installed, FastStack uses it automatically for faster JPEG decode and thumbnail generation.
- If it is missing, FastStack still runs, but falls back to Pillow and may feel slower on large folders.

Recommended install location:

- `C:\libjpeg-turbo64\bin\turbojpeg.dll`

FastStack also checks these optional environment variables if you installed it elsewhere:

- `FASTSTACK_TURBOJPEG_LIB`
- `TURBOJPEG_LIB`

Example:

```cmd
set FASTSTACK_TURBOJPEG_LIB=C:\path\to\turbojpeg.dll
venv\Scripts\python.exe -m faststack.app "C:\path\to\photos"
```

### Troubleshooting on Windows
If startup logs mention:

```text
Unable to locate turbojpeg library automatically.
You may specify the turbojpeg library path manually.
```

that means the Python package is installed but the native `turbojpeg.dll` is not available in a location that `PyTurboJPEG` can find.

Fastest fixes:

1. Install `libjpeg-turbo` for Windows x64 so that this file exists:
`C:\libjpeg-turbo64\bin\turbojpeg.dll`
2. Or point FastStack to the dll explicitly:

```cmd
set FASTSTACK_TURBOJPEG_LIB=C:\path\to\turbojpeg.dll
venv\Scripts\python.exe -m faststack.app "C:\path\to\photos"
```

If you do nothing, FastStack will still run, but JPEG decoding and thumbnail generation will use Pillow instead of `libjpeg-turbo`, which is slower.

## Keyboard Shortcuts

- `J` / `Right Arrow`: Next Image
Expand Down
51 changes: 8 additions & 43 deletions faststack/imaging/jpeg.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,44 +7,26 @@
import numpy as np
from PIL import Image

log = logging.getLogger(__name__)
from faststack.imaging.turbo import TJPF_RGB, create_turbojpeg

# Attempt to import PyTurboJPEG
log = logging.getLogger(__name__)

try:
from turbojpeg import TurboJPEG, TJPF_RGB
except ImportError:
JPEG_DECODER = None
TURBO_AVAILABLE = False
log.warning("PyTurboJPEG not found. Falling back to Pillow for JPEG decoding.")
else:
try:
JPEG_DECODER = TurboJPEG()
except Exception:
JPEG_DECODER = None
TURBO_AVAILABLE = False
log.exception("PyTurboJPEG initialization failed. Falling back to Pillow.")
else:
TURBO_AVAILABLE = True
log.info("PyTurboJPEG is available. Using it for JPEG decoding.")
JPEG_DECODER, TURBO_AVAILABLE = create_turbojpeg()


def decode_jpeg_rgb(jpeg_bytes: bytes, fast_dct: bool = False) -> Optional[np.ndarray]:
"""Decodes JPEG bytes into an RGB numpy array."""
if TURBO_AVAILABLE and JPEG_DECODER:
try:
# Decode with proper color space handling (no TJFLAG_FASTDCT)
# This ensures proper YCbCr->RGB conversion with correct gamma
# Decode with proper color space handling (no TJFLAG_FASTDCT).
flags = 0
if fast_dct:
# TJFLAG_FASTDCT = 2048
flags |= 2048
return JPEG_DECODER.decode(jpeg_bytes, pixel_format=TJPF_RGB, flags=flags)
except Exception as e:
log.exception("PyTurboJPEG failed to decode image: %s. Trying Pillow.", e)
# Fall through to Pillow fallback

# Fallback to Pillow
try:
img = Image.open(BytesIO(jpeg_bytes)).convert("RGB")
return np.array(img)
Expand All @@ -59,17 +41,14 @@ def decode_jpeg_thumb_rgb(
"""Decodes a JPEG into a thumbnail-sized RGB numpy array."""
if TURBO_AVAILABLE and JPEG_DECODER:
try:
# Get image header to determine dimensions
width, height, _, _ = JPEG_DECODER.decode_header(jpeg_bytes)

# Find the best scaling factor
scaling_factor = _get_turbojpeg_scaling_factor(width, height, max_dim)

decoded = JPEG_DECODER.decode(
jpeg_bytes,
scaling_factor=scaling_factor,
pixel_format=TJPF_RGB,
flags=0, # Proper color space handling
flags=0,
)
if decoded.shape[0] > max_dim or decoded.shape[1] > max_dim:
img = Image.fromarray(decoded)
Expand All @@ -81,7 +60,6 @@ def decode_jpeg_thumb_rgb(
"PyTurboJPEG failed to decode thumbnail: %s. Trying Pillow.", e
)

# Fallback to Pillow
try:
img = Image.open(BytesIO(jpeg_bytes))
img.thumbnail((max_dim, max_dim))
Expand All @@ -98,7 +76,6 @@ def _get_turbojpeg_scaling_factor(
if not TURBO_AVAILABLE or not JPEG_DECODER:
return None

# PyTurboJPEG provides a set of supported scaling factors
supported_factors = sorted(
JPEG_DECODER.scaling_factors,
key=lambda x: x[0] / x[1],
Expand All @@ -109,7 +86,6 @@ def _get_turbojpeg_scaling_factor(
if (width * num / den) <= max_dim and (height * num / den) <= max_dim:
return (num, den)

# If no suitable factor is found, return the smallest one
return supported_factors[-1] if supported_factors else None


Expand All @@ -122,43 +98,35 @@ def decode_jpeg_resized(

if TURBO_AVAILABLE and JPEG_DECODER:
try:
# Get image header to determine dimensions
img_width, img_height, _, _ = JPEG_DECODER.decode_header(jpeg_bytes)

# Determine which dimension is the limiting factor
if img_width * height > img_height * width:
# Image is wider relative to target box; width is the constraint
max_dim = width
else:
# Image is taller relative to target box; height is the constraint
max_dim = height

scale_factor = _get_turbojpeg_scaling_factor(img_width, img_height, max_dim)

if scale_factor:
flags = 0
if fast_dct:
# TJFLAG_FASTDCT = 2048
flags |= 2048

decoded = JPEG_DECODER.decode(
jpeg_bytes,
scaling_factor=scale_factor,
pixel_format=TJPF_RGB,
flags=flags, # Proper color space handling
flags=flags,
)

# Only use Pillow for final resize if needed
if decoded.shape[0] > height or decoded.shape[1] > width:
img = Image.fromarray(decoded)
# Use BILINEAR for speed
img.thumbnail((width, height), Image.Resampling.BILINEAR)
return np.array(img)
return decoded
except Exception as e:
log.exception("PyTurboJPEG failed: %s", e)

# Fallback to Pillow (existing code)
try:
img = Image.open(BytesIO(jpeg_bytes))

Expand All @@ -167,13 +135,10 @@ def decode_jpeg_resized(

scale_factor_ratio = min(img.width / width, img.height / height)

# Use faster BILINEAR for large downscales, LANCZOS for smaller
if scale_factor_ratio > 4:
resampling = Image.Resampling.BILINEAR # Much faster
resampling = Image.Resampling.BILINEAR
else:
resampling = (
Image.Resampling.LANCZOS
) # Higher quality for smaller downscales
resampling = Image.Resampling.LANCZOS

img.thumbnail((width, height), resampling)
return np.array(img.convert("RGB"))
Expand Down
101 changes: 101 additions & 0 deletions faststack/imaging/turbo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""TurboJPEG discovery helpers with Windows DLL fallbacks."""

from __future__ import annotations

import logging
import os
from pathlib import Path
from typing import Optional, Tuple

log = logging.getLogger(__name__)

try:
from turbojpeg import TurboJPEG, TJPF_RGB
except ImportError: # pragma: no cover - exercised via create_turbojpeg
TurboJPEG = None
TJPF_RGB = None


def _candidate_library_paths() -> list[Optional[str]]:
"""Return candidate libjpeg-turbo library paths to try in priority order."""
candidates: list[Optional[str]] = []

explicit = os.getenv("FASTSTACK_TURBOJPEG_LIB") or os.getenv("TURBOJPEG_LIB")
if explicit:
candidates.append(explicit)
candidates.append(None)

if os.name == "nt":
common_roots = [
os.getenv("FASTSTACK_TURBOJPEG_ROOT"),
os.getenv("ProgramFiles"),
os.getenv("ProgramFiles(x86)"),
]
suffixes = [
("libjpeg-turbo", "bin", "turbojpeg.dll"),
("libjpeg-turbo64", "bin", "turbojpeg.dll"),
("libjpeg-turbo-gcc64", "bin", "turbojpeg.dll"),
("TurboJPEG", "bin", "turbojpeg.dll"),
("bin", "turbojpeg.dll"),
]
for root in common_roots:
if not root:
continue
for suffix in suffixes:
candidates.append(str(Path(root).joinpath(*suffix)))

local_app_data = os.getenv("LOCALAPPDATA")
if local_app_data:
candidates.append(
str(
Path(local_app_data)
/ "Programs"
/ "libjpeg-turbo"
/ "bin"
/ "turbojpeg.dll"
)
)

for path_dir in os.getenv("PATH", "").split(os.pathsep):
if path_dir:
candidates.append(str(Path(path_dir) / "turbojpeg.dll"))

unique: list[Optional[str]] = []
seen: set[str] = set()
for candidate in candidates:
key = "__default__" if candidate is None else os.path.normcase(candidate)
if key in seen:
continue
seen.add(key)
unique.append(candidate)
return unique


def create_turbojpeg() -> Tuple[Optional["TurboJPEG"], bool]:
"""Create a TurboJPEG decoder if possible."""
if TurboJPEG is None:
log.warning("PyTurboJPEG not found. Falling back to Pillow for JPEG decoding.")
return None, False

failures: list[str] = []
for candidate in _candidate_library_paths():
try:
decoder = TurboJPEG() if candidate is None else TurboJPEG(candidate)
except Exception as exc:
source = "default loader" if candidate is None else candidate
failures.append(f"{source}: {exc}")
continue

if candidate is None:
log.info("PyTurboJPEG is available. Using it for JPEG decoding.")
else:
log.info("Loaded TurboJPEG library from %s", candidate)
return decoder, True

for failure in failures:
log.warning("TurboJPEG load attempt failed: %s", failure)
log.warning(
"TurboJPEG initialization failed for all attempted locations. "
"Falling back to Pillow."
)
return None, False
Loading