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
63 changes: 56 additions & 7 deletions faststack/imaging/turbo.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@

import logging
import os
import sys
from functools import lru_cache
from pathlib import Path
from typing import Optional, Tuple

log = logging.getLogger(__name__)
_fallback_warnings_emitted: set[str] = set()

try:
from turbojpeg import TJPF_RGB, TurboJPEG
Expand Down Expand Up @@ -72,16 +75,53 @@ def _candidate_library_paths() -> list[Optional[str]]:
return unique


def create_turbojpeg() -> Tuple[Optional["TurboJPEG"], bool]:
"""Create a TurboJPEG decoder if possible."""
def _install_hint() -> str:
"""Return a concise, platform-specific libjpeg-turbo install hint."""
if os.name == "nt":
return (
"Windows: install the x64 libjpeg-turbo package so "
r"C:\libjpeg-turbo64\bin\turbojpeg.dll exists, or set "
"FASTSTACK_TURBOJPEG_LIB to the full turbojpeg.dll path."
)
if sys.platform == "darwin":
return (
"macOS: install libjpeg-turbo with `brew install jpeg-turbo`, "
"or set FASTSTACK_TURBOJPEG_LIB to the full libturbojpeg.dylib path."
)
return (
"Linux: install the TurboJPEG shared library, for example "
"`sudo apt install libturbojpeg` on Debian/Ubuntu, "
"`sudo dnf install turbojpeg` on Fedora, or "
"`sudo pacman -S libjpeg-turbo` on Arch. You can also set "
"FASTSTACK_TURBOJPEG_LIB to the full libturbojpeg.so path."
)


def _warn_fallback_once(message: str, *args: object) -> None:
"""Emit only one user-facing TurboJPEG fallback warning per process."""
if message in _fallback_warnings_emitted:
return
_fallback_warnings_emitted.add(message)
log.warning(message, *args)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


@lru_cache(maxsize=8)
def _create_turbojpeg_cached(
_decoder_identity: int,
candidates: tuple[Optional[str], ...],
) -> Tuple[Optional["TurboJPEG"], bool]:
"""Probe TurboJPEG once per candidate set and cache the result."""
if TurboJPEG is None:
log.warning(
"PyTurboJPEG not found. Falling back to Pillow for JPEG decoding. Installing PyTurboJPEG will improve image navigation speed."
_warn_fallback_once(
"PyTurboJPEG is not installed. Falling back to Pillow for JPEG "
"decoding, which is slower for large folders. Install PyTurboJPEG "
"and libjpeg-turbo to enable faster image navigation. %s",
_install_hint(),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
return None, False

failures: list[str] = []
for candidate in _candidate_library_paths():
for candidate in candidates:
try:
decoder = TurboJPEG() if candidate is None else TurboJPEG(candidate)
except Exception as exc:
Expand All @@ -97,9 +137,18 @@ def create_turbojpeg() -> Tuple[Optional["TurboJPEG"], bool]:

for failure in failures:
log.debug("TurboJPEG load attempt failed: %s", failure)
log.warning(
_warn_fallback_once(
"TurboJPEG initialization failed (%d location(s) tried). "
"Falling back to Pillow for JPEG decoding.",
"PyTurboJPEG is installed, but the native libjpeg-turbo shared "
"library was not found or could not be loaded. Falling back to "
"Pillow for JPEG decoding, which is slower for large folders. %s",
len(failures),
_install_hint(),
)
return None, False


def create_turbojpeg() -> Tuple[Optional["TurboJPEG"], bool]:
"""Create a TurboJPEG decoder if possible."""
candidates = tuple(_candidate_library_paths())
return _create_turbojpeg_cached(id(TurboJPEG), candidates)
77 changes: 34 additions & 43 deletions faststack/qml/Components.qml
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,6 @@ Item {
return
}
mainImage.updateZoomState()
if (cropOverlay.visible) cropOverlay.updateCropRect()
}

// Fix B: Stable Logical Size
Expand Down Expand Up @@ -319,15 +318,13 @@ Item {
return
}
mainImage.updateHistogramWithZoom()
if (cropOverlay.visible) cropOverlay.updateCropRect()
}
onYChanged: {
if (imageRotator._lockedPanY > -1e8 && Math.abs(y - imageRotator._lockedPanY) > 0.01) {
y = imageRotator._lockedPanY
return
}
mainImage.updateHistogramWithZoom()
if (cropOverlay.visible) cropOverlay.updateCropRect()
}
}
]
Expand Down Expand Up @@ -361,54 +358,47 @@ Item {
// Crop overlay - anchored to mainImage to rotate with it
Item {
id: cropOverlay
property var cropBox: loupeView.uiStateRef ? loupeView.uiStateRef.currentCropBox : [0, 0, 1000, 1000]
property bool hasActiveCrop: cropBox && cropBox.length === 4 && !(cropBox[0]===0 && cropBox[1]===0 && cropBox[2]===1000 && cropBox[3]===1000)
property bool hasActiveCrop: {
var b = _liveCropBox()
return b && b.length === 4 && !(b[0]===0 && b[1]===0 && b[2]===1000 && b[3]===1000)
}
property bool hasDrawableCrop: {
var b = _liveCropBox()
return b && b.length === 4 && (b[2] - b[0]) > 0 && (b[3] - b[1]) > 0
&& !(b[0]===0 && b[1]===0 && b[2]===1000 && b[3]===1000)
}
// Show visual content only when there is an actual user-drawn crop or rotate mode.
// The overlay Item itself stays alive (visible: isCropping) so updateCropRect() always fires.
property bool showCropContent: hasActiveCrop || mainMouseArea.isRotating

visible: loupeView.uiStateRef && loupeView.uiStateRef.isCropping
anchors.fill: parent // Fills mainImage
z: 100

onCropBoxChanged: { if (parent.source) updateCropRect() }
Component.onCompleted: { if (parent.source) updateCropRect() }

property bool showCropContent: hasActiveCrop || mainMouseArea.isRotating || mainMouseArea.isCropDragging

property int _cropBoxRev: 0
Connections {
target: loupeView.uiStateRef
function onCurrentCropBoxChanged() { if (mainImage.source) cropOverlay.updateCropRect() }
}

Connections {
target: mainImage
function onWidthChanged() { cropOverlay.updateCropRect() }
function onHeightChanged() { cropOverlay.updateCropRect() }
function onCurrentCropBoxChanged() {
cropOverlay._cropBoxRev += 1
}
}

function updateCropRect() {
if (!loupeView.uiStateRef || !loupeView.uiStateRef.currentCropBox || loupeView.uiStateRef.currentCropBox.length !== 4) return
var box = loupeView.uiStateRef.currentCropBox

// Local coords in mainImage (Source Space)
var localLeft = (box[0] / 1000) * parent.width
var localTop = (box[1] / 1000) * parent.height
var localRight = (box[2] / 1000) * parent.width
var localBottom = (box[3] / 1000) * parent.height

cropRect.x = localLeft
cropRect.y = localTop
cropRect.width = localRight - localLeft
cropRect.height = localBottom - localTop

function _liveCropBox() {
var _ = cropOverlay._cropBoxRev
return loupeView.uiStateRef ? loupeView.uiStateRef.currentCropBox : null
}


visible: loupeView.uiStateRef && loupeView.uiStateRef.isCropping
anchors.fill: parent // Fills mainImage
z: 100

// Dimmer Rectangles — only render when a real crop is active/being drawn
Rectangle { visible: cropOverlay.showCropContent; x: 0; y: 0; width: parent.width; height: cropRect.y; color: "black"; opacity: 0.3 }
Rectangle { visible: cropOverlay.showCropContent; x: 0; y: cropRect.y + cropRect.height; width: parent.width; height: parent.height - (cropRect.y + cropRect.height); color: "black"; opacity: 0.3 }
Rectangle { visible: cropOverlay.showCropContent; x: 0; y: cropRect.y; width: cropRect.x; height: cropRect.height; color: "black"; opacity: 0.3 }
Rectangle { visible: cropOverlay.showCropContent; x: cropRect.x + cropRect.width; y: cropRect.y; width: parent.width - (cropRect.x + cropRect.width); height: cropRect.height; color: "black"; opacity: 0.3 }
Rectangle { visible: cropOverlay.hasDrawableCrop; x: 0; y: 0; width: parent.width; height: cropRect.y; color: "black"; opacity: 0.3 }
Rectangle { visible: cropOverlay.hasDrawableCrop; x: 0; y: cropRect.y + cropRect.height; width: parent.width; height: parent.height - (cropRect.y + cropRect.height); color: "black"; opacity: 0.3 }
Rectangle { visible: cropOverlay.hasDrawableCrop; x: 0; y: cropRect.y; width: cropRect.x; height: cropRect.height; color: "black"; opacity: 0.3 }
Rectangle { visible: cropOverlay.hasDrawableCrop; x: cropRect.x + cropRect.width; y: cropRect.y; width: parent.width - (cropRect.x + cropRect.width); height: cropRect.height; color: "black"; opacity: 0.3 }

Rectangle {
id: cropRect
x: { var b = cropOverlay._liveCropBox(); return (b && b.length === 4) ? (b[0] / 1000) * parent.width : 0 }
y: { var b = cropOverlay._liveCropBox(); return (b && b.length === 4) ? (b[1] / 1000) * parent.height : 0 }
width: { var b = cropOverlay._liveCropBox(); return (b && b.length === 4) ? ((b[2] - b[0]) / 1000) * parent.width : 0 }
height: { var b = cropOverlay._liveCropBox(); return (b && b.length === 4) ? ((b[3] - b[1]) / 1000) * parent.height : 0 }
visible: cropOverlay.showCropContent
color: "transparent"
border.color: "white"
Expand Down Expand Up @@ -439,8 +429,9 @@ Item {
anchors.horizontalCenter: handleLine.horizontalCenter
}
}

}

source: loupeView.displayedImageSource

function _currentDpr() {
Expand Down
2 changes: 1 addition & 1 deletion faststack/tests/test_turbo.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,5 +112,5 @@ def test_missing_turbojpeg_package_emits_one_warning(monkeypatch, caplog):

warning_records = [r for r in caplog.records if r.levelno == logging.WARNING]
assert len(warning_records) == 1
assert "PyTurboJPEG not found" in warning_records[0].message
assert "PyTurboJPEG is not installed" in warning_records[0].message
assert "Pillow" in warning_records[0].message
35 changes: 24 additions & 11 deletions faststack/ui/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ def __init__(self, app_controller, clock_func=None):
self._saturation = 0.0
self._white_balance_by = 0.0
self._white_balance_mg = 0.0
self._current_crop_box = [0, 0, 1000, 1000]
self._current_crop_box = (0, 0, 1000, 1000)
self._crop_rotation = 0.0
self._debug_mode = False
self._aspect_ratio_names = [
Expand Down Expand Up @@ -1089,25 +1089,34 @@ def reset_editor_state(self):
self.darkenMode = "assisted"
self.darkenBrushRadius = 0.03

@Property("QVariant", notify=histogram_data_changed)
def histogramData(self):
"""Returns histogram data as a dict with 'r', 'g', 'b' keys, each containing a list of 256 values."""
return self._histogram_data
@Property("QVariantMap", notify=histogram_data_changed)
def histogramData(self) -> dict:
"""Returns histogram data as a dict with 'r', 'g', 'b' keys, each containing a list of 256 values.

Note: declared as QVariantMap (not QVariant) so QML JavaScript receives a real
Object. Under PySide6 6.11+, returning a dict through Property("QVariant") produces
a QJSValue wrapper that JS cannot index by key.
"""
return self._histogram_data if self._histogram_data is not None else {}

@histogramData.setter
def histogramData(self, new_value):
if self._histogram_data != new_value:
self._histogram_data = new_value
self.histogram_data_changed.emit()

@Property("QVariant", notify=highlightStateChanged)
def highlightState(self):
@Property("QVariantMap", notify=highlightStateChanged)
def highlightState(self) -> dict:
"""Returns highlight analysis state for UI display.

Returns dict with:
- headroom_pct: Fraction of pixels with recoverable data above 1.0 (16-bit sources)
- source_clipped_pct: Fraction of pixels clipped in the SOURCE image (JPEG flat-top @ 254+)
- current_nearwhite_pct: Fraction of pixels currently near white in the processed state.

Note: declared as QVariantMap (not QVariant) so QML JavaScript receives a real
Object. Under PySide6 6.11+, returning a dict through Property("QVariant") produces
a QJSValue wrapper that JS cannot index by key.
"""
editor = self.app_controller.image_editor
state = {}
Expand Down Expand Up @@ -1216,10 +1225,14 @@ def currentAspectRatioIndex(self, new_value: int):
self._current_aspect_ratio_index = new_value
self.current_aspect_ratio_index_changed.emit(new_value)

@Property("QVariant", notify=current_crop_box_changed)
def currentCropBox(self) -> tuple:
# QML will receive this as a list
return self._current_crop_box
@Property("QVariantList", notify=current_crop_box_changed)
def currentCropBox(self) -> list:
# Return a plain list so QML JavaScript receives a real Array.
# Under PySide6 6.11+, returning a tuple through Property("QVariant")
# produces a QJSValue wrapper that JS cannot index.
return (
list(self._current_crop_box) if self._current_crop_box is not None else []
)

@currentCropBox.setter
def currentCropBox(self, new_value):
Expand Down