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
396 changes: 242 additions & 154 deletions faststack/faststack/app.py

Large diffs are not rendered by default.

29 changes: 26 additions & 3 deletions faststack/faststack/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,32 @@
"theme": "dark",
"default_directory": "",
"optimize_for": "speed", # "speed" or "quality"
"auto_level_threshold": "0.1", # Threshold for auto-level detection (0.0-1.0)
"auto_level_strength": "1.0", # Strength of auto-level correction (0.0-1.0)
"auto_level_strength_auto": "False", # Automatically adjust auto-level strength

# --- Auto Levels Configuration ---
#
# Behavior:
# Auto Levels are triggered when the user explicitly clicks "Auto Levels" in the
# image editor or uses the "Quick Auto Levels" hotkey.
#
# Algorithm:
# 1. Compute black/white points by clipping `auto_level_threshold` fraction of pixels
# (0.0-1.0) at the dark and light ends of the histogram.
# 2. Construct a levels transform to map these points to 0 and 255.
# 3. Blend the transformed image with the original using `auto_level_strength`.
# 4. If `auto_level_strength_auto` is True, `auto_level_strength` acts as a maximum;
# the system will automatically reduce the applied strength if the computed
# transform would cause excessive clipping or color instability.
#
# Practical Tuning:
# - auto_level_threshold: A fraction (not percent).
# Higher values (e.g. 0.05 = 5%) increase contrast but risk hard clipping.
# Lower values (e.g. 0.001 = 0.1%) are gentler and preserve more dynamic range.
# - auto_level_strength: 1.0 applies the full mathematical correction. Lower values
# blend the result for a subtler effect.

"auto_level_threshold": "0.1",
"auto_level_strength": "1.0",
"auto_level_strength_auto": "False",
},
"helicon": {
"exe": "C:\\Program Files\\Helicon Software\\Helicon Focus 8\\HeliconFocus.exe",
Expand Down
246 changes: 168 additions & 78 deletions faststack/faststack/imaging/editor.py

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion faststack/faststack/imaging/jpeg.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ def decode_jpeg_resized(
jpeg_bytes: bytes, width: int, height: int, fast_dct: bool = False
) -> Optional[np.ndarray]:
"""Decodes and resizes a JPEG to fit within the given dimensions."""
if width == 0 or height == 0:
if width <= 0 or height <= 0:
return decode_jpeg_rgb(jpeg_bytes, fast_dct=fast_dct)

if TURBO_AVAILABLE and jpeg_decoder:
Expand Down
10 changes: 6 additions & 4 deletions faststack/faststack/imaging/prefetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -419,10 +419,11 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int,

if self.debug:
decoder = "TurboJPEG" if TURBO_AVAILABLE else "Pillow"
log.info("ICC fallback decode timing for index %d (%s): read=%.3fs, decode=%.3fs, total=%.3fs, size=%dx%d",
log.info("ICC fallback decode timing for index %d (%s): read=%.3fs, decode=%.3fs, copy=%.3fs, total=%.3fs, size=%dx%d",
index, decoder, t_after_fallback_read - t_before_fallback_read,
t_after_fallback_decode - t_after_fallback_read,
t_after_fallback_decode - t_start, w, h)
t_after_copy - t_after_fallback_decode,
t_after_copy - t_start, w, h)
else:
# Fall back to standard decode if ICC profile not available
log.warning("ICC mode selected but no monitor profile available, using standard decode")
Expand Down Expand Up @@ -452,9 +453,10 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int,

if self.debug:
decoder = "TurboJPEG" if TURBO_AVAILABLE else "Pillow"
log.info("Standard decode timing (no ICC profile) for index %d (%s): read=%.3fs, decode=%.3fs, total=%.3fs, size=%dx%d",
log.info("Standard decode timing (no ICC profile) for index %d (%s): read=%.3fs, decode=%.3fs, copy=%.3fs, total=%.3fs, size=%dx%d",
index, decoder, t_after_read - t_before_read, t_after_decode - t_after_read,
t_after_decode - t_start, w, h)
t_after_copy - t_after_decode,
t_after_copy - t_start, w, h)

else:
# Standard decode path (Option A or no color management)
Expand Down
13 changes: 10 additions & 3 deletions faststack/faststack/logging_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,26 @@ def setup_logging(debug: bool = False):
log_dir.mkdir(parents=True, exist_ok=True)
log_file = log_dir / "app.log"

handler = logging.handlers.RotatingFileHandler(
# File handler
file_handler = logging.handlers.RotatingFileHandler(
log_file, maxBytes=10*1024*1024, backupCount=5
)
formatter = logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
handler.setFormatter(formatter)
file_handler.setFormatter(formatter)

# Console handler (for seeing logs in terminal)
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)

root_logger = logging.getLogger()
# Set log level based on debug flag
root_logger.setLevel(logging.DEBUG if debug else logging.INFO)
root_logger.handlers.clear()
root_logger.addHandler(handler)
root_logger.addHandler(file_handler)
root_logger.addHandler(console_handler)

# Configure logging for key modules
if debug:
logging.getLogger("faststack.imaging.cache").setLevel(logging.DEBUG)
Expand Down
22 changes: 11 additions & 11 deletions faststack/faststack/qml/Components.qml
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,8 @@ Item {
if (uiState && uiState.isCropping) {
// Check if clicking on existing crop box - Using Image Space Hit Testing
var box = uiState.currentCropBox
if (box && box.length === 4) box = box.slice(0)

var isFullImage = box && box.length === 4 && box[0] === 0 && box[1] === 0 && box[2] === 1000 && box[3] === 1000

var coords = mapToImageCoordinates(Qt.point(mouse.x, mouse.y))
Expand Down Expand Up @@ -657,22 +659,20 @@ Item {

// Calculate start aspect ratio (in pixels)
if (mainImage.width > 0) {
var cb = uiState.currentCropBox
if (cb && cb.length === 4) {
var boxW = (cb[2] - cb[0]) / 1000 * mainImage.width
var boxH = (cb[3] - cb[1]) / 1000 * mainImage.height
cropStartAspect = boxW / boxH
if (box && box.length === 4) {
var boxW = (box[2] - box[0]) / 1000 * mainImage.width
var boxH = (box[3] - box[1]) / 1000 * mainImage.height
if (boxH > 0) cropStartAspect = boxW / boxH
}
}


// Seed cropBoxStart variables
var startBox = uiState.currentCropBox
if (startBox && startBox.length === 4) {
cropBoxStartLeft = startBox[0]
cropBoxStartTop = startBox[1]
cropBoxStartRight = startBox[2]
cropBoxStartBottom = startBox[3]
if (box && box.length === 4) {
cropBoxStartLeft = box[0]
cropBoxStartTop = box[1]
cropBoxStartRight = box[2]
cropBoxStartBottom = box[3]
}

isCropDragging = true
Expand Down
60 changes: 46 additions & 14 deletions faststack/faststack/qml/ImageEditorDialog.qml
Original file line number Diff line number Diff line change
Expand Up @@ -89,15 +89,12 @@ Window {
Component {
id: sectionHeader
Label {
text: headerText
font.bold: true
font.pixelSize: 15
font.letterSpacing: 1.0
color: imageEditorDialog.accentColorHover
Layout.topMargin: 5
Layout.bottomMargin: 10

property string headerText: ""
}
}

Expand All @@ -122,8 +119,8 @@ Window {
// --- Light Group ---
Loader {
sourceComponent: sectionHeader
property string headerText: "☀ Light"
Layout.topMargin: 0 // Remove top margin for the very first item
onLoaded: item.text = "☀ Light"
}
ListModel {
id: lightModel
Expand All @@ -140,7 +137,10 @@ Window {
Loader { sourceComponent: sectionSeparator }

// --- Detail Group ---
Loader { sourceComponent: sectionHeader; property string headerText: "🔍 Detail" }
Loader {
sourceComponent: sectionHeader
onLoaded: item.text = "🔍 Detail"
}
ListModel {
id: detailModel
ListElement { name: "Clarity"; key: "clarity" }
Expand Down Expand Up @@ -216,8 +216,8 @@ Window {
// --- Color Group ---
Loader {
sourceComponent: sectionHeader
property string headerText: "🎨 Color"
Layout.topMargin: 0 // Remove top margin for the very first item
onLoaded: item.text = "🎨 Color"
}
ListModel {
id: colorModel
Expand Down Expand Up @@ -254,7 +254,10 @@ Window {
Loader { sourceComponent: sectionSeparator }

// --- Effects Group ---
Loader { sourceComponent: sectionHeader; property string headerText: "✨ Effects" }
Loader {
sourceComponent: sectionHeader
onLoaded: item.text = "✨ Effects"
}
ListModel {
id: effectsModel
ListElement { name: "Vignette"; key: "vignette"; min: 0; max: 100 }
Expand All @@ -264,7 +267,10 @@ Window {
Loader { sourceComponent: sectionSeparator }

// --- Transform Group ---
Loader { sourceComponent: sectionHeader; property string headerText: "🔄 Transform" }
Loader {
sourceComponent: sectionHeader
onLoaded: item.text = "🔄 Transform"
}
RowLayout {
Layout.fillWidth: true
spacing: 15
Expand Down Expand Up @@ -391,7 +397,28 @@ Window {
return isReversed ? -val : val
}

value: backendValue
// Auto-sync visual slider with backend changes when not dragging
Binding {
target: slider
property: "value"
value: slider.backendValue
when: !slider.pressed
}

property real _pendingValue: 0
property real _lastSentValue: 0
Timer {
id: sendTimer
interval: 32 // ~30fps throttle
repeat: true
onTriggered: {
if (Math.abs(slider._pendingValue - slider._lastSentValue) > 0.001) {
var sendValue = slider.isReversed ? -slider._pendingValue : slider._pendingValue
controller.set_edit_parameter(model.key, sendValue / maxVal)
slider._lastSentValue = slider._pendingValue
}
}
}

Connections {
target: imageEditorDialog
Expand All @@ -403,10 +430,8 @@ Window {
}

onMoved: {
var sendValue = isReversed ? -value : value
controller.set_edit_parameter(model.key, sendValue / maxVal)
// Trigger live histogram update (throttled by Python backend)
if (controller) controller.update_histogram()
_pendingValue = value
if (!sendTimer.running) sendTimer.start()
}

property double lastPressTime: 0
Expand All @@ -429,9 +454,16 @@ Window {
lastPressValue = value

imageEditorDialog.slidersPressedCount++
_pendingValue = value
if (!sendTimer.running) sendTimer.start()
} else {
imageEditorDialog.slidersPressedCount--
// Update histogram on release

// Stop repeating sends, then send final value immediately
sendTimer.stop()
var sendValue = isReversed ? -value : value
controller.set_edit_parameter(model.key, sendValue / maxVal)

if (controller) controller.update_histogram()
}
}
Expand Down
34 changes: 30 additions & 4 deletions faststack/faststack/ui/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,41 @@ def requestImage(self, id: str, size: object, requestedSize: object) -> QImage:
try:
image_index_str = id.split('/')[0]
index = int(image_index_str)
image_data = self.app_controller.get_decoded_image(index)

# If editor is open, use the background-rendered preview buffer
# BUT only if the requested index matches the currently edited index!
# Otherwise we serve the editor preview for thumbnails/prefetch.
if self.app_controller.ui_state.isEditorOpen and index == self.app_controller.current_index:
image_data = self.app_controller._last_rendered_preview or self.app_controller.get_decoded_image(index)
else:
image_data = self.app_controller.get_decoded_image(index)

if image_data:
# Handle format being None (from prefetcher) or missing
fmt = getattr(image_data, 'format', None)
if fmt is None:
fmt = QImage.Format.Format_RGB888

qimg = QImage(
image_data.buffer,
image_data.width,
image_data.height,
image_data.bytes_per_line,
QImage.Format.Format_RGB888
fmt
)


# Detach from Python buffer to prevent ownership issues and force proper texture upload
# OPTIMIZATION: Only do this expensive copy when serving the live editor preview,
# where we need to detach from the shared memory buffer that might change.
# For standard browsing/prefetch, the buffer is stable enough.
if self.app_controller.ui_state.isEditorOpen and index == self.app_controller.current_index:
qimg = qimg.copy()
else:
# SAFETY: Keep a reference to the underlying buffer to prevent garbage collection
# while Qt holds the QImage. QImage created from bytes does NOT own the data.
qimg.original_buffer = image_data.buffer

# Set sRGB color space for proper color management (if available)
# Skip this when using ICC mode - pixels are already in monitor space
color_mode = config.get('color', 'mode', fallback="none").lower()
Expand All @@ -56,8 +81,9 @@ def requestImage(self, id: str, size: object, requestedSize: object) -> QImage:
log.warning(f"Failed to set color space: {e}")
elif color_mode == "icc":
log.debug("ICC mode: skipping Qt color space (pixels already in monitor space)")
# keep buffer alive
qimg.original_buffer = image_data.buffer

# Buffer is now safe to release (handled by copy), but original_buffer ref in Python object stays
# We don't need to manually attach original_buffer to qimg anymore since we copied.
return qimg

except (ValueError, IndexError) as e:
Expand Down