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
2,861 changes: 1,877 additions & 984 deletions faststack/app.py

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion faststack/check_scipy.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@

try:
import scipy.ndimage

print("scipy available")
except ImportError:
print("scipy NOT available")
27 changes: 14 additions & 13 deletions faststack/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import glob
import os
import re
from pathlib import Path, PureWindowsPath
from pathlib import PureWindowsPath

from faststack.logging_setup import get_app_data_dir

Expand All @@ -23,22 +23,22 @@ def detect_rawtherapee_path():
# Finds paths like C:\Program Files\RawTherapee\5.9\rawtherapee-cli.exe
base_patterns = [
r"C:\Program Files\RawTherapee*\**\rawtherapee-cli.exe",
r"C:\Program Files (x86)\RawTherapee*\**\rawtherapee-cli.exe"
r"C:\Program Files (x86)\RawTherapee*\**\rawtherapee-cli.exe",
]

try:
matches = []
for pattern in base_patterns:
matches.extend(glob.glob(pattern, recursive=True))

if not matches:
return None

# Helper to extract version numbers for natural sorting
# e.g., "5.10" -> [5, 10]
def version_sort_key(path):
for part in reversed(PureWindowsPath(path).parts):
if re.fullmatch(r'\d+(?:\.\d+)*', part):
if re.fullmatch(r"\d+(?:\.\d+)*", part):
return [int(n) for n in part.split(".")]
return [0]

Expand Down Expand Up @@ -67,7 +67,6 @@ def version_sort_key(path):
"theme": "dark",
"default_directory": "",
"optimize_for": "speed", # "speed" or "quality"

# --- Auto Levels Configuration ---
#
# Behavior:
Expand All @@ -80,7 +79,7 @@ def version_sort_key(path):
# 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
# the system will automatically reduce the applied strength if the computed
# transform would cause excessive clipping or color instability.
#
# Practical Tuning:
Expand All @@ -89,7 +88,6 @@ def version_sort_key(path):
# 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",
Expand All @@ -108,7 +106,7 @@ def version_sort_key(path):
"monitor_icc_path": "", # For 'icc' mode: path to monitor ICC profile
},
"awb": {
"mode": "lab", # "lab" or "rgb"
"mode": "lab", # "lab" or "rgb"
"strength": "0.7",
"warm_bias": "6",
"tint_bias": "0",
Expand All @@ -124,9 +122,10 @@ def version_sort_key(path):
"raw": {
"source_dir": "C:\\Users\\alanr\\pictures\\olympus.stack.input.photos",
"mirror_base": "C:\\Users\\alanr\\Pictures\\Lightroom",
}
},
}


class AppConfig:
def __init__(self):
self.config_path = get_app_data_dir() / "faststack.ini"
Expand All @@ -149,20 +148,21 @@ def load(self):
for key, value in keys.items():
if not self.config.has_option(section, key):
self.config.set(section, key, value)
self.save() # Save to add any missing keys
self.save() # Save to add any missing keys

# Validate RawTherapee path (re-detect if missing)
if sys.platform == "win32":
current_rt_path = self.get("rawtherapee", "exe")
if not os.path.exists(current_rt_path):
log.warning(f"Configured RawTherapee path not found: {current_rt_path}. Attempting re-detection...")
log.warning(
f"Configured RawTherapee path not found: {current_rt_path}. Attempting re-detection..."
)
new_path = detect_rawtherapee_path()
if new_path and new_path != current_rt_path:
log.info(f"Found new RawTherapee path: {new_path}")
self.set("rawtherapee", "exe", new_path)
self.save()


def save(self):
"""Saves the current configuration to the INI file."""
try:
Expand Down Expand Up @@ -190,5 +190,6 @@ def set(self, section, key, value):
self.config.add_section(section)
self.config.set(section, key, str(value))


# Global config instance
config = AppConfig()
8 changes: 4 additions & 4 deletions faststack/imaging/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,7 @@ def __setitem__(self, key, value):
# Before adding a new item, we might need to evict others
# This is handled by the parent class, which will call popitem if needed
super().__setitem__(key, value)
log.debug(
f"Cached item '{key}'. Cache size: {self.currsize / 1024**2:.2f} MB"
)
log.debug(f"Cached item '{key}'. Cache size: {self.currsize / 1024**2:.2f} MB")

def popitem(self):
"""Extend popitem to log eviction."""
Expand Down Expand Up @@ -86,7 +84,9 @@ def get_decoded_image_size(item) -> int:
bytes_per_pixel = getattr(item, "channels", 4) # Default to RGBA
return item.width * item.height * bytes_per_pixel

log.warning(f"Unexpected item type in cache: {type(item)}. Returning estimated size of 1.")
log.warning(
f"Unexpected item type in cache: {type(item)}. Returning estimated size of 1."
)
return 1 # Should not happen


Expand Down
86 changes: 54 additions & 32 deletions faststack/imaging/editor.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import logging
import os
import shutil
import glob
import re
import math
from pathlib import Path
from typing import Optional, Dict, Any, Tuple
import numpy as np
from PIL import Image, ImageEnhance, ImageFilter, ImageOps, ExifTags
from io import BytesIO
from PIL import Image, ImageFilter, ImageOps, ExifTags


from faststack.models import DecodedImage
Expand All @@ -29,7 +27,7 @@
except ImportError:
QImage = None

from faststack.imaging.optional_deps import cv2, HAS_OPENCV
from faststack.imaging.optional_deps import cv2

import threading

Expand Down Expand Up @@ -57,15 +55,14 @@ def sanitize_exif_orientation(exif_bytes: bytes | None) -> bytes | None:
exif = Image.Exif()
exif.load(exif_bytes)
# Pillow 9.1.0+ has ExifTags.Base.Orientation, fallback to 0x0112 if needed
orientation_tag = getattr(ExifTags.Base, 'Orientation', 0x0112)
orientation_tag = getattr(ExifTags.Base, "Orientation", 0x0112)
exif[orientation_tag] = 1
return exif.tobytes()
except Exception:
# If we can't parse/sanitize, safest is to drop EXIF to avoid rotation bugs
return None



def create_backup_file(original_path: Path) -> Optional[Path]:
"""
Creates a backup of the original file with naming pattern:
Expand Down Expand Up @@ -145,7 +142,7 @@ def _gaussian_blur_float(arr: np.ndarray, radius: float) -> np.ndarray:
try:
h, w, c = arr.shape
blurred_channels = []

# Process each channel independently
for i in range(c):
ch_data = arr[:, :, i]
Expand All @@ -154,21 +151,25 @@ def _gaussian_blur_float(arr: np.ndarray, radius: float) -> np.ndarray:
mx = max(1.0, float(ch_data.max()))
mn = min(0.0, float(ch_data.min()))
scale = mx - mn

if scale > 0:
ch_u8 = ((ch_data - mn) / scale * 255).astype(np.uint8)
ch_img = Image.fromarray(ch_u8, mode='L')
ch_img = Image.fromarray(ch_u8, mode="L")
# Pillow's GaussianBlur radius is roughly comparable to OpenCV sigma
blurred_ch_img = ch_img.filter(ImageFilter.GaussianBlur(radius=radius))
blurred_ch_img = ch_img.filter(
ImageFilter.GaussianBlur(radius=radius)
)
# Scale back to original float range
blurred_ch = np.array(blurred_ch_img).astype(np.float32) / 255.0 * scale + mn
blurred_ch = (
np.array(blurred_ch_img).astype(np.float32) / 255.0 * scale + mn
)
blurred_channels.append(blurred_ch)
else:
blurred_channels.append(ch_data.copy())

# Stack back into (H, W, C)
return np.stack(blurred_channels, axis=-1)

except Exception as e:
log.warning(f"Fallback blur failed: {e}")
return arr
Expand Down Expand Up @@ -526,7 +527,9 @@ def load_image(
# The cached_preview is also "cooked" (has Color Management / Saturation applied).
# We use it for the VERY FIRST frame for fast display, then immediately
# re-render from the master float_image in the background.
log.debug("Using cached preview (assumed orientation-correct from prefetcher)")
log.debug(
"Using cached preview (assumed orientation-correct from prefetcher)"
)

loaded_float_preview = preview_arr.astype(np.float32) / 255.0
else:
Expand Down Expand Up @@ -755,7 +758,7 @@ def _apply_edits(
# Capture pre-exposure linear state for "True Headroom" calculation
pre_exposure_linear_stride = None
if should_analyze:
pre_exposure_linear_stride = arr[::4, ::4, :]
pre_exposure_linear_stride = arr[::4, ::4, :]

# 6. Exposure (Linear Gain for True Headroom)
exposure = edits.get("exposure", 0.0)
Expand All @@ -772,12 +775,15 @@ def _apply_edits(
if should_analyze:
# Check cache for analysis state to avoid expensive re-computation on downstream edits
upstream_hash = self._get_upstream_edits_hash(edits)

cached_analysis = None
with self._lock:
if self._cached_highlight_analysis and self._cached_highlight_analysis['hash'] == upstream_hash:
cached_analysis = self._cached_highlight_analysis['state']

if (
self._cached_highlight_analysis
and self._cached_highlight_analysis["hash"] == upstream_hash
):
cached_analysis = self._cached_highlight_analysis["state"]

if cached_analysis:
analysis_state = cached_analysis
else:
Expand All @@ -787,15 +793,15 @@ def _apply_edits(
# Pass pre_exposure_linear_stride to measure "True Headroom" before exposure boost
# arr_linear_stride is "Current State" (Post-WB, Post-Exposure)
analysis_state = _analyze_highlight_state(
arr_linear_stride,
srgb_u8=srgb_u8_stride, # Source (Pre-Edit) State
pre_exposure_linear=pre_exposure_linear_stride
arr_linear_stride,
srgb_u8=srgb_u8_stride, # Source (Pre-Edit) State
pre_exposure_linear=pre_exposure_linear_stride,
)

with self._lock:
self._cached_highlight_analysis = {
'hash': upstream_hash,
'state': analysis_state
"hash": upstream_hash,
"state": analysis_state,
}

if not for_export:
Expand Down Expand Up @@ -853,7 +859,11 @@ def _apply_edits(
with self._lock:
cached = self._cached_detail_bands
# Verify both hash AND frozen values to avoid collisions
if cached and cached.get("hash") == detail_hash and cached.get("frozen") == detail_frozen:
if (
cached
and cached.get("hash") == detail_hash
and cached.get("frozen") == detail_frozen
):
Y20_cached = cached.get("Y20")
Y3_cached = cached.get("Y3")
Y1_cached = cached.get("Y1")
Expand All @@ -866,7 +876,11 @@ def _apply_edits(
# exposure and detail bands, this scaling is APPROXIMATE when h/s is active.
# The approximation is good enough for smooth 60fps dragging; exact render
# happens when upstream params (WB/crop/rotate) change and cache invalidates.
exp_scale = current_exp_gain / cached_exp_gain if cache_hit and abs(cached_exp_gain) > 1e-9 else 1.0
exp_scale = (
current_exp_gain / cached_exp_gain
if cache_hit and abs(cached_exp_gain) > 1e-9
else 1.0
)

# Safe extraction: use [..., 0] if 3D, else keep as-is (avoids squeeze() collapsing H/W)
def _extract_2d(blur_result):
Expand Down Expand Up @@ -913,7 +927,11 @@ def _extract_2d(blur_result):
"Y1": Y1_cached,
}
# Add newly computed blurs (they're at current_exp_gain, need to rescale to cached_exp_gain)
rescale_to_cached = cached_exp_gain / current_exp_gain if abs(current_exp_gain) > 1e-9 else 1.0
rescale_to_cached = (
cached_exp_gain / current_exp_gain
if abs(current_exp_gain) > 1e-9
else 1.0
)
for key, val in newly_computed.items():
if val is not None:
new_cache[key] = val * rescale_to_cached
Expand Down Expand Up @@ -1371,7 +1389,7 @@ def _apply_highlights_shadows(
# Re-compute locally if not provided
# We assume srgb_u8_stride is ALREADY STRIDED if passed (based on the name change)
arr_stride = arr[::4, ::4, :]
# If srgb_u8_stride was passed, use it directly (it's already small).
# If srgb_u8_stride was passed, use it directly (it's already small).
# If it wasn't passed, we can't easily recreate the source state here without the original source buffer.
# But the caller (_apply_edits) usually provides it.
state = _analyze_highlight_state(arr_stride, srgb_u8=srgb_u8_stride)
Expand Down Expand Up @@ -1555,7 +1573,7 @@ def _get_sanitized_exif_bytes(self) -> Optional[bytes]:
Prefers cached source EXIF (from paired JPEG) if available,
otherwise falls back to the current original_image's EXIF.

If sanitization or serialization fails, returns None (drops EXIF)
If sanitization or serialization fails, returns None (drops EXIF)
to prevent incorrect "double rotation" in viewers.

Returns:
Expand Down Expand Up @@ -1599,13 +1617,17 @@ def _get_sanitized_exif_bytes(self) -> Optional[bytes]:

# 5. Guard for tobytes()
if not hasattr(exif, "tobytes"):
log.warning("EXIF object has no tobytes() method, dropping EXIF to prevent rotation issues.")
log.warning(
"EXIF object has no tobytes() method, dropping EXIF to prevent rotation issues."
)
return None

try:
return exif.tobytes()
except Exception as e:
log.warning(f"Failed to serialize sanitized EXIF: {e}. Dropping EXIF to prevent rotation issues.")
log.warning(
f"Failed to serialize sanitized EXIF: {e}. Dropping EXIF to prevent rotation issues."
)
return None
except Exception as e:
log.warning(f"Failed to sanitize EXIF orientation: {e}. Dropping EXIF.")
Expand Down
Loading