Skip to content
Merged

Test #14

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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,6 @@ Thumbs.db

prompt.md
WARP.md
faststack/.mypy_cache/
.mypy_cache/

16 changes: 16 additions & 0 deletions faststack/ChangeLog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# ChangeLog

## [0.9.0] - 2025-11-20

### Performance Improvements
- **Zero-Copy JPEG Read:** Eliminated memory copy by passing mmap directly to decoders, reducing I/O time by 25-60% for large JPEGs.
- **Filter Performance:** Cached image list in memory to eliminate disk scans on every filter keystroke (100-1000x faster for large directories).
- **Smart Cache Management:** Removed unnecessary cache clearing on resize/zoom - LRU naturally evicts old entries while allowing instant reuse.
- **Generation Thrashing Fix:** Navigation no longer increments generation counter, preventing cache invalidation on every keystroke.
- **Directional Prefetching:** Asymmetric prefetch now biases 70% ahead and 30% behind in travel direction for faster sequential browsing.
- **ICC Transform Caching:** Cached ICC color transforms to eliminate repeated transform builds during color-managed viewing.
- **TurboJPEG for ICC:** ICC color path now uses TurboJPEG for decode+resize, then Pillow only for color conversion.

### Features
- **JPG Fallback for Helicon:** Helicon Focus stacking now works with JPG-only workflows when RAW files absent.
- **Comprehensive Timing Instrumentation:** Added detailed decode timing logs in debug mode for performance analysis.
- **Added a Jump to Photo feature that can be activated by pressing the G key

## [0.8.0] - 2025-11-20

### Added
Expand Down
2 changes: 1 addition & 1 deletion faststack/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# FastStack

# Version 0.8 - November 20, 2025
# Version 0.9 - November 20, 2025
# By Alan Rockefeller

Ultra-fast, caching JPG viewer designed for culling and selecting RAW files for focus stacking.
Expand Down
321 changes: 234 additions & 87 deletions faststack/faststack/app.py

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions faststack/faststack/benchmark_decode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import mmap
import time
from pathlib import Path
from faststack.imaging.jpeg import decode_jpeg_resized, TURBO_AVAILABLE

print(f"TurboJPEG available: {TURBO_AVAILABLE}")

test_image = Path(r"C:\Users\alanr\Pictures\Lightroom\2025\2025-11-14\20251114-PB140001-2.JPG")

# Match the real code path with mmap
iterations = 20
start = time.perf_counter()
for _ in range(iterations):
with open(test_image, "rb") as f:
with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped:
jpeg_bytes = mmapped[:]
decode_jpeg_resized(jpeg_bytes, 1920, 1080)
elapsed = time.perf_counter() - start

print(f"Average time (with mmap): {elapsed/iterations*1000:.1f}ms")
9 changes: 8 additions & 1 deletion faststack/faststack/imaging/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,12 @@ def get_decoded_image_size(item) -> int:
# In the full app, this would also account for the QImage/QTexture.
from faststack.models import DecodedImage
if isinstance(item, DecodedImage):
return item.buffer.nbytes
# Handle both numpy arrays and memoryview buffers
if hasattr(item.buffer, 'nbytes'):
return item.buffer.nbytes
elif hasattr(item.buffer, '__len__'):
return len(item.buffer)
else:
# Fallback: compute from dimensions
return item.width * item.height * 3
return 1 # Should not happen
289 changes: 228 additions & 61 deletions faststack/faststack/imaging/prefetch.py

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions faststack/faststack/io/helicon.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ def launch_helicon_focus(raw_files: List[Path]) -> Tuple[bool, Optional[Path]]:
tmp_path = Path(tmp.name)

log.info(f"Temporary file for Helicon Focus: {tmp_path}")
log.info(f"Input files: {[str(f) for f in raw_files]}")

# Build command list safely
args = [helicon_exe, "-i", str(tmp_path.resolve())]
Expand All @@ -69,8 +70,8 @@ def launch_helicon_focus(raw_files: List[Path]) -> Tuple[bool, Optional[Path]]:
log.exception(f"Invalid helicon args format: {e}")
return False, None

log.info(f"Launching Helicon Focus with {len(raw_files)} files.")
log.info(f"Helicon Focus command: {args}") # Log the full command
log.info(f"Launching Helicon Focus with {len(raw_files)} files")
log.info(f"Command: {' '.join(args)}")

# SECURITY: Explicitly disable shell execution
subprocess.Popen(
Expand Down
10 changes: 5 additions & 5 deletions faststack/faststack/io/indexer.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def find_images(directory: Path) -> List[ImageFile]:
raws[stem] = []
raws[stem].append((p, entry.stat()))
except OSError as e:
log.error("Error scanning directory %s: %s", directory, e)
log.exception("Error scanning directory %s", directory)
return []

# Sort JPGs by filename
Expand All @@ -55,10 +55,10 @@ def find_images(directory: Path) -> List[ImageFile]:
elapsed = time.perf_counter() - t_start
paired_count = sum(1 for im in image_files if im.raw_pair)

# Log timing info if DEBUG level is enabled
if log.isEnabledFor(logging.DEBUG):
log.info("find_images: found %d images in %.3fs", len(image_files), elapsed)
log.info("Found %d JPG files and paired %d with RAWs.", len(image_files), paired_count)
log.info("Found %d JPG files and paired %d with RAWs in %.3fs", len(image_files), paired_count, elapsed)
else:
log.info("Found %d JPG files and paired %d with RAWs.", len(image_files), paired_count)
return image_files

def _find_raw_pair(
Expand All @@ -76,7 +76,7 @@ def _find_raw_pair(

for raw_path, raw_stat in potential_raws:
dt = abs(jpg_stat.st_mtime - raw_stat.st_mtime)
if dt < min_dt:
if dt <= min_dt:
min_dt = dt
best_match = raw_path

Expand Down
9 changes: 4 additions & 5 deletions faststack/faststack/io/sidecar.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@
log = logging.getLogger(__name__)

class SidecarManager:
def __init__(self, directory: Path, watcher):
def __init__(self, directory: Path, watcher, debug: bool = False):
self.path = directory / "faststack.json"
self.watcher = watcher
self.debug = debug
self.data = self.load()

def stop_watcher(self):
Expand All @@ -35,9 +36,7 @@ def load(self) -> Sidecar:
data = json.load(f)
json_load_time = time.perf_counter() - t_start

# Import debug flag from app module
from faststack.app import _debug_mode
if _debug_mode:
if self.debug:
log.info(f"SidecarManager.load: json.load() took {json_load_time:.3f}s")

if data.get("version") != 2:
Expand Down Expand Up @@ -65,7 +64,7 @@ def save(self):
temp_path = self.path.with_suffix(".tmp")
was_watcher_running = False
try:
if self.watcher and self.watcher.is_alive():
if self.watcher and hasattr(self.watcher, 'is_alive') and self.watcher.is_alive():
self.stop_watcher()
was_watcher_running = True
with temp_path.open("w") as f:
Expand Down
30 changes: 19 additions & 11 deletions faststack/faststack/qml/FilterDialog.qml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ Dialog {
title: "Filter Images"
modal: true
standardButtons: Dialog.Ok | Dialog.Cancel
width: 400
height: 200
closePolicy: Popup.CloseOnEscape
width: 500
height: 250

property string filterString: ""

Expand All @@ -24,38 +25,38 @@ Dialog {

contentItem: Column {
spacing: 16
anchors.fill: parent
anchors.margins: 20
padding: 20

Label {
text: "Show only images whose filename contains:"
wrapMode: Text.WordWrap
width: parent.width
width: parent.width - parent.padding * 2
}

TextField {
id: filterField
text: filterDialog.filterString
placeholderText: "Enter text to filter (e.g., 'stacked', 'IMG_001')..."
width: parent.width
width: parent.width - parent.padding * 2
height: 50
selectByMouse: true
focus: true
font.pixelSize: 16
verticalAlignment: TextInput.AlignVCenter

onTextChanged: {
filterDialog.filterString = text
}

Keys.onReturnPressed: {
filterDialog.accept()
}
Keys.onReturnPressed: filterDialog.accept()
Keys.onEnterPressed: filterDialog.accept()
}

Label {
text: "Leave empty to show all images."
font.italic: true
opacity: 0.7
wrapMode: Text.WordWrap
width: parent.width
width: parent.width - parent.padding * 2
}
}

Expand All @@ -66,5 +67,12 @@ Dialog {
filterField.text = filterDialog.filterString
filterField.forceActiveFocus()
filterField.selectAll()
// Notify Python that a dialog is open
controller.dialog_opened()
}

onClosed: {
// Notify Python that dialog is closed
controller.dialog_closed()
}
}
79 changes: 79 additions & 0 deletions faststack/faststack/qml/JumpToImageDialog.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import QtQuick
import QtQuick.Controls 2.15
import QtQuick.Controls.Material 2.15
import QtQuick.Layouts 1.15

Dialog {
id: jumpDialog
title: "Jump to Image"
standardButtons: Dialog.Ok | Dialog.Cancel
modal: true
closePolicy: Popup.CloseOnEscape
width: 400

property int maxImageCount: 0

// Inherit Material theme from parent
Material.theme: uiState && uiState.theme === 0 ? Material.Dark : Material.Light
Material.accent: "#4fb360"

onOpened: {
imageNumberField.text = ""
imageNumberField.forceActiveFocus()
// Notify Python that a dialog is open
controller.dialog_opened()
}

onClosed: {
// Notify Python that dialog is closed
controller.dialog_closed()
}

onAccepted: {
var num = parseInt(imageNumberField.text)
if (!isNaN(num) && num >= 1 && num <= maxImageCount) {
controller.jump_to_image(num - 1) // Convert 1-based to 0-based index
}
}

contentItem: Item {
implicitWidth: 400
implicitHeight: 100

ColumnLayout {
anchors.fill: parent
anchors.margins: 0
spacing: 20

Label {
text: "Enter image number (1-" + jumpDialog.maxImageCount + "):"
Layout.fillWidth: true
wrapMode: Text.WordWrap
}

TextField {
id: imageNumberField
Layout.preferredWidth: 100
Layout.preferredHeight: 40
Layout.alignment: Qt.AlignLeft
placeholderText: "Number"
font.pixelSize: 16
horizontalAlignment: TextInput.AlignHCenter
maximumLength: Math.max(1, Math.ceil(Math.log10(jumpDialog.maxImageCount + 1)))
selectByMouse: true
focus: true
validator: IntValidator {
bottom: 1
top: jumpDialog.maxImageCount
}

Keys.onReturnPressed: jumpDialog.accept()
Keys.onEnterPressed: jumpDialog.accept()
}

Item {
Layout.fillHeight: true
}
}
}
}
26 changes: 19 additions & 7 deletions faststack/faststack/qml/Main.qml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ ApplicationWindow {
title: "FastStack"

Material.theme: uiState.theme === 0 ? Material.Dark : Material.Light
Material.accent: "#4fb360"

property bool isDarkTheme: uiState.theme === 0
property color currentBackgroundColor: isDarkTheme ? "#000000" : "white"
Expand Down Expand Up @@ -320,6 +321,8 @@ ApplicationWindow {
title: "Key Bindings"
standardButtons: Dialog.Ok
modal: true
closePolicy: Popup.CloseOnEscape
focus: true
width: 500
height: 600

Expand All @@ -331,12 +334,12 @@ ApplicationWindow {
text: "<b>FastStack Keyboard and Mouse Commands</b><br><br>" +
"<b>Navigation:</b><br>" +
"&nbsp;&nbsp;J / Right Arrow: Next Image<br>" +
"&nbsp;&nbsp;K / Left Arrow: Previous Image<br><br>" +
"&nbsp;&nbsp;K / Left Arrow: Previous Image<br>" +
"&nbsp;&nbsp;G: Jump to Image Number<br><br>" +
"<b>Viewing:</b><br>" +
"&nbsp;&nbsp;Mouse Wheel: Zoom in/out<br>" +
"&nbsp;&nbsp;Left-click + Drag: Pan image<br>" +
"&nbsp;&nbsp;Ctrl+0: Reset zoom and pan to fit window<br>" +
"&nbsp;&nbsp;G: Toggle Grid View (not implemented)<br><br>" +
"&nbsp;&nbsp;Ctrl+0: Reset zoom and pan to fit window<br><br>" +
"<b>Rating & Stacking:</b><br>" +
"&nbsp;&nbsp;Space: Toggle Flag<br>" +
"&nbsp;&nbsp;X: Toggle Reject<br>" +
Expand All @@ -362,6 +365,8 @@ ApplicationWindow {
title: "Stack Information"
standardButtons: Dialog.Ok
modal: true
closePolicy: Popup.CloseOnEscape
focus: true
width: 400
height: 300

Expand All @@ -382,11 +387,18 @@ ApplicationWindow {
}

FilterDialog {
id: filterDialog
onAccepted: {
controller.apply_filter(filterString)
id: filterDialog
onAccepted: {
controller.apply_filter(filterString)
}
}
}

JumpToImageDialog {
id: jumpToImageDialog
maxImageCount: uiState.imageCount
}

function show_jump_to_image_dialog() {
jumpToImageDialog.open()
}
}
Loading